@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +928 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +843 -0
- package/server/config.ts +362 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +966 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +654 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
package/server/config.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { parseArgs, promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
export interface Config {
|
|
12
|
+
/** Port for plain HTTP. Also serves ACME challenges when a domain is set. */
|
|
13
|
+
port: number;
|
|
14
|
+
host: string;
|
|
15
|
+
/** When set, provisions a Let's Encrypt certificate and serves HTTPS too. */
|
|
16
|
+
domain?: string;
|
|
17
|
+
tlsPort: number;
|
|
18
|
+
acmeEmail?: string;
|
|
19
|
+
acmeStaging: boolean;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The colour an installed PWA paints its window chrome with.
|
|
23
|
+
*
|
|
24
|
+
* Configurable because it is the fastest way to tell two installed instances
|
|
25
|
+
* apart. A laptop, a staging box and the real one all look identical in a
|
|
26
|
+
* dock otherwise, and installing the wrong one is the kind of mistake you
|
|
27
|
+
* only notice after typing into it.
|
|
28
|
+
*/
|
|
29
|
+
themeColor: string;
|
|
30
|
+
|
|
31
|
+
/** Where the WebSocket bridge points. Defaults to this machine's sshd. */
|
|
32
|
+
sshHost: string;
|
|
33
|
+
sshPort: number;
|
|
34
|
+
/**
|
|
35
|
+
* `host` or `host:port` to print in the ssh commands offered for popping a
|
|
36
|
+
* terminal out to a real one.
|
|
37
|
+
*
|
|
38
|
+
* Unset is the common case and the good default: the host the browser used to
|
|
39
|
+
* reach the desktop is almost always the host sshd answers on, and unlike
|
|
40
|
+
* `sshHost` (`127.0.0.1`, where the bridge dials) it is something a person on
|
|
41
|
+
* another machine can actually type. Set this when the two really do differ —
|
|
42
|
+
* a reverse proxy in front of the web port, or sshd on a non-standard port.
|
|
43
|
+
*/
|
|
44
|
+
sshAdvertise?: string;
|
|
45
|
+
/** Unix user the browser logs in as, and the certificate principal. */
|
|
46
|
+
user: string;
|
|
47
|
+
|
|
48
|
+
stateDir: string;
|
|
49
|
+
webRoot: string;
|
|
50
|
+
/** Absolute path to the built web assets, or null if not built. */
|
|
51
|
+
workspace: string;
|
|
52
|
+
|
|
53
|
+
/** null disables the gate entirely (with a loud warning at startup). */
|
|
54
|
+
token: string | null;
|
|
55
|
+
/**
|
|
56
|
+
* Names, beyond the ones the server works out for itself, that a request may
|
|
57
|
+
* use to reach it when there is no token. See `hostAllowed` in auth.ts.
|
|
58
|
+
*/
|
|
59
|
+
allowedHosts: string[];
|
|
60
|
+
certTtlSeconds: number;
|
|
61
|
+
/**
|
|
62
|
+
* Keep each window in a dtach session so it survives a reload. Auto-detected
|
|
63
|
+
* from whether dtach is installed, unless forced.
|
|
64
|
+
*/
|
|
65
|
+
sessions: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
69
|
+
/** server/config.ts -> dist/web */
|
|
70
|
+
const DEFAULT_WEB_ROOT = path.resolve(HERE, "..", "dist", "web");
|
|
71
|
+
|
|
72
|
+
export const OPTION_SPEC = {
|
|
73
|
+
port: { type: "string" as const },
|
|
74
|
+
host: { type: "string" as const },
|
|
75
|
+
domain: { type: "string" as const },
|
|
76
|
+
"tls-port": { type: "string" as const },
|
|
77
|
+
email: { type: "string" as const },
|
|
78
|
+
"acme-staging": { type: "boolean" as const },
|
|
79
|
+
"ssh-host": { type: "string" as const },
|
|
80
|
+
"ssh-port": { type: "string" as const },
|
|
81
|
+
"ssh-advertise": { type: "string" as const },
|
|
82
|
+
user: { type: "string" as const },
|
|
83
|
+
"state-dir": { type: "string" as const },
|
|
84
|
+
"web-root": { type: "string" as const },
|
|
85
|
+
workspace: { type: "string" as const },
|
|
86
|
+
token: { type: "string" as const },
|
|
87
|
+
"no-token": { type: "boolean" as const },
|
|
88
|
+
"allowed-host": { type: "string" as const, multiple: true },
|
|
89
|
+
"cert-ttl": { type: "string" as const },
|
|
90
|
+
"theme-color": { type: "string" as const },
|
|
91
|
+
sessions: { type: "boolean" as const },
|
|
92
|
+
"no-sessions": { type: "boolean" as const },
|
|
93
|
+
// doctor only, same reasoning as the install-browser block below: parseArgs
|
|
94
|
+
// is strict, so a flag it has never heard of is an error.
|
|
95
|
+
reap: { type: "boolean" as const },
|
|
96
|
+
// install-browser only. Kept here because parseArgs is strict, and a flag it
|
|
97
|
+
// has never heard of is an error rather than something a subcommand can read.
|
|
98
|
+
geometry: { type: "string" as const },
|
|
99
|
+
display: { type: "string" as const },
|
|
100
|
+
"vnc-port": { type: "string" as const },
|
|
101
|
+
"cdp-port": { type: "string" as const },
|
|
102
|
+
"restart-at": { type: "string" as const },
|
|
103
|
+
"no-restart": { type: "boolean" as const },
|
|
104
|
+
"vnc-password": { type: "string" as const },
|
|
105
|
+
"no-vnc-password": { type: "boolean" as const },
|
|
106
|
+
help: { type: "boolean" as const, short: "h" },
|
|
107
|
+
version: { type: "boolean" as const, short: "v" },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type RawOptions = Partial<
|
|
111
|
+
Record<Exclude<keyof typeof OPTION_SPEC, "allowed-host">, string | boolean>
|
|
112
|
+
> & { "allowed-host"?: string[] };
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Flags that mean "generate one" when given no value.
|
|
116
|
+
*
|
|
117
|
+
* `--vnc-password` joins `--token` here for a reason beyond symmetry: a password
|
|
118
|
+
* typed on a command line lands in shell history and in `ps`, so the bare form
|
|
119
|
+
* that generates and prints one is the form worth reaching for.
|
|
120
|
+
*/
|
|
121
|
+
const OPTIONAL_VALUE_FLAGS = ["--token", "--vnc-password"];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Makes a bare `--token` mean "generate one".
|
|
125
|
+
*
|
|
126
|
+
* That is what the help text has always promised — `--token [value]` — and what
|
|
127
|
+
* the VPS instructions tell people to type, but `parseArgs` has no notion of an
|
|
128
|
+
* optional value: a string option with nothing after it is an error, and the
|
|
129
|
+
* error talks about ambiguity rather than saying what to do. Rewriting it to
|
|
130
|
+
* `--token=` here is the whole fix, and it is done before parsing so everything
|
|
131
|
+
* downstream still sees one shape.
|
|
132
|
+
*/
|
|
133
|
+
function allowBareToken(argv: string[]): string[] {
|
|
134
|
+
return argv.map((arg, i) => {
|
|
135
|
+
if (!OPTIONAL_VALUE_FLAGS.includes(arg)) return arg;
|
|
136
|
+
const next = argv[i + 1];
|
|
137
|
+
return next === undefined || next.startsWith("-") ? `${arg}=` : arg;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function parseCliArgs(argv: string[]): {
|
|
142
|
+
values: RawOptions;
|
|
143
|
+
positionals: string[];
|
|
144
|
+
} {
|
|
145
|
+
const { values, positionals } = parseArgs({
|
|
146
|
+
args: allowBareToken(argv),
|
|
147
|
+
options: OPTION_SPEC,
|
|
148
|
+
allowPositionals: true,
|
|
149
|
+
strict: true,
|
|
150
|
+
});
|
|
151
|
+
return { values: values as RawOptions, positionals };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function hasDtach(): Promise<boolean> {
|
|
155
|
+
try {
|
|
156
|
+
// dtach exits non-zero with no mode, so its usage text is the liveness
|
|
157
|
+
// check; `--help` is not a flag it accepts either.
|
|
158
|
+
await run("dtach", ["--help"], { timeout: 5_000 });
|
|
159
|
+
return true;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return (
|
|
162
|
+
(err as { stdout?: string })?.stdout?.includes("dtach - version") === true
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A CSS hex colour, which is all the manifest and the meta tag accept.
|
|
169
|
+
*
|
|
170
|
+
* Validated rather than passed through: it lands in a JSON document and in an
|
|
171
|
+
* HTML attribute, and "whatever the operator typed" is not something either
|
|
172
|
+
* should be asked to carry.
|
|
173
|
+
*/
|
|
174
|
+
/**
|
|
175
|
+
* A neutral slate, deliberately not the cyan accent.
|
|
176
|
+
*
|
|
177
|
+
* The accent already means "this is the thing you are pointing at" inside the
|
|
178
|
+
* app; reusing it for the window chrome would make the chrome look active.
|
|
179
|
+
*/
|
|
180
|
+
const DEFAULT_THEME = "#1c2128";
|
|
181
|
+
|
|
182
|
+
function hexColour(value: unknown, label: string): string {
|
|
183
|
+
const colour = String(value).trim();
|
|
184
|
+
if (!/^#[0-9a-fA-F]{6}$/.test(colour))
|
|
185
|
+
throw new Error(`invalid --${label}: ${colour} is not a #rrggbb colour`);
|
|
186
|
+
return colour.toLowerCase();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A Unix username, as `ssh-keygen -n` will read it.
|
|
191
|
+
*
|
|
192
|
+
* That flag takes a comma-separated *list*, so `--user vibe,root` would quietly
|
|
193
|
+
* mint certificates valid for root as well. Operator-supplied rather than
|
|
194
|
+
* attacker-supplied, so this is a footgun rather than a hole — but the check is
|
|
195
|
+
* one line, and the same value is interpolated into doctor's `-C user=…` spec,
|
|
196
|
+
* where a comma corrupts the connection string too.
|
|
197
|
+
*/
|
|
198
|
+
function userName(value: unknown, label: string): string {
|
|
199
|
+
const name = String(value);
|
|
200
|
+
if (!/^[a-z_][a-z0-9_-]*\$?$/.test(name))
|
|
201
|
+
throw new Error(`invalid --${label}: ${name}`);
|
|
202
|
+
return name;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function num(value: unknown, fallback: number, label: string): number {
|
|
206
|
+
if (value === undefined || value === "") return fallback;
|
|
207
|
+
const n = Number(value);
|
|
208
|
+
// `Number("")` is 0, which used to pass this guard and make Bun bind a random
|
|
209
|
+
// ephemeral port — while the banner printed that port as the URL. Fractions
|
|
210
|
+
// and out-of-range values were accepted just as quietly.
|
|
211
|
+
if (!Number.isInteger(n) || n < 0)
|
|
212
|
+
throw new Error(`invalid --${label}: ${String(value)}`);
|
|
213
|
+
return n;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Ports specifically: the same rules, plus the range one can actually bind. */
|
|
217
|
+
function port_(value: unknown, fallback: number, label: string): number {
|
|
218
|
+
const n = num(value, fallback, label);
|
|
219
|
+
if (n < 1 || n > 65535)
|
|
220
|
+
throw new Error(`invalid --${label}: ${String(value)} is not a port`);
|
|
221
|
+
return n;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A boolean from a flag or an environment variable.
|
|
226
|
+
*
|
|
227
|
+
* `Boolean(x)` is wrong for the environment half: every non-empty string is
|
|
228
|
+
* true, so `VIBE_OS_ACME_STAGING=0` and `=false` both turned staging *on* —
|
|
229
|
+
* which quietly points certificate issuance at Let's Encrypt's staging CA and
|
|
230
|
+
* produces a certificate no browser trusts.
|
|
231
|
+
*/
|
|
232
|
+
function flag(value: unknown): boolean {
|
|
233
|
+
if (typeof value === "boolean") return value;
|
|
234
|
+
if (value === undefined || value === null) return false;
|
|
235
|
+
const v = String(value).trim().toLowerCase();
|
|
236
|
+
return !(v === "" || v === "0" || v === "false" || v === "no" || v === "off");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
interface PersistedConfig {
|
|
240
|
+
token?: string | null;
|
|
241
|
+
[key: string]: unknown;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function loadPersisted(
|
|
245
|
+
stateDir: string,
|
|
246
|
+
): Promise<PersistedConfig> {
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(
|
|
249
|
+
await readFile(path.join(stateDir, "config.json"), "utf8"),
|
|
250
|
+
) as PersistedConfig;
|
|
251
|
+
} catch {
|
|
252
|
+
return {};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function savePersisted(
|
|
257
|
+
stateDir: string,
|
|
258
|
+
patch: PersistedConfig,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
261
|
+
const current = await loadPersisted(stateDir);
|
|
262
|
+
await writeFile(
|
|
263
|
+
path.join(stateDir, "config.json"),
|
|
264
|
+
`${JSON.stringify({ ...current, ...patch }, null, 2)}\n`,
|
|
265
|
+
{
|
|
266
|
+
mode: 0o600,
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function resolveConfig(values: RawOptions): Promise<Config> {
|
|
272
|
+
const stateDir = String(
|
|
273
|
+
values["state-dir"] ??
|
|
274
|
+
process.env.VIBE_OS_STATE_DIR ??
|
|
275
|
+
path.join(os.homedir(), ".vibe-os"),
|
|
276
|
+
);
|
|
277
|
+
const persisted = await loadPersisted(stateDir);
|
|
278
|
+
|
|
279
|
+
// A token unless told otherwise: --no-token, or VIBE_OS_NO_TOKEN for the
|
|
280
|
+
// container. Given none, the one from the last run, or a new one.
|
|
281
|
+
let token: string | null;
|
|
282
|
+
if (flag(values["no-token"] ?? process.env.VIBE_OS_NO_TOKEN)) {
|
|
283
|
+
token = null;
|
|
284
|
+
} else if (typeof values.token === "string" && values.token.length > 0) {
|
|
285
|
+
token = values.token;
|
|
286
|
+
} else if (process.env.VIBE_OS_TOKEN) {
|
|
287
|
+
token = process.env.VIBE_OS_TOKEN;
|
|
288
|
+
} else {
|
|
289
|
+
token = persisted.token ?? randomBytes(24).toString("base64url");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const allowedHosts = [
|
|
293
|
+
...(values["allowed-host"] ?? []),
|
|
294
|
+
...(process.env.VIBE_OS_ALLOWED_HOSTS?.split(",") ?? []),
|
|
295
|
+
]
|
|
296
|
+
.map((h) => h.trim().toLowerCase())
|
|
297
|
+
.filter(Boolean);
|
|
298
|
+
|
|
299
|
+
const sessions = values["no-sessions"]
|
|
300
|
+
? false
|
|
301
|
+
: values.sessions
|
|
302
|
+
? true
|
|
303
|
+
: await hasDtach();
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
port: port_(values.port ?? process.env.VIBE_OS_PORT, 80, "port"),
|
|
307
|
+
host: String(values.host ?? process.env.VIBE_OS_HOST ?? "0.0.0.0"),
|
|
308
|
+
domain:
|
|
309
|
+
(values.domain as string) ?? process.env.VIBE_OS_DOMAIN ?? undefined,
|
|
310
|
+
tlsPort: port_(
|
|
311
|
+
values["tls-port"] ?? process.env.VIBE_OS_TLS_PORT,
|
|
312
|
+
443,
|
|
313
|
+
"tls-port",
|
|
314
|
+
),
|
|
315
|
+
acmeEmail:
|
|
316
|
+
(values.email as string) ?? process.env.VIBE_OS_ACME_EMAIL ?? undefined,
|
|
317
|
+
acmeStaging: flag(
|
|
318
|
+
values["acme-staging"] ?? process.env.VIBE_OS_ACME_STAGING,
|
|
319
|
+
),
|
|
320
|
+
themeColor: hexColour(
|
|
321
|
+
values["theme-color"] ?? process.env.VIBE_OS_THEME_COLOR ?? DEFAULT_THEME,
|
|
322
|
+
"theme-color",
|
|
323
|
+
),
|
|
324
|
+
sshHost: String(
|
|
325
|
+
values["ssh-host"] ?? process.env.VIBE_OS_SSH_HOST ?? "127.0.0.1",
|
|
326
|
+
),
|
|
327
|
+
sshPort: port_(
|
|
328
|
+
values["ssh-port"] ?? process.env.VIBE_OS_SSH_PORT,
|
|
329
|
+
22,
|
|
330
|
+
"ssh-port",
|
|
331
|
+
),
|
|
332
|
+
sshAdvertise:
|
|
333
|
+
(values["ssh-advertise"] as string) ??
|
|
334
|
+
process.env.VIBE_OS_SSH_ADVERTISE ??
|
|
335
|
+
undefined,
|
|
336
|
+
user: userName(
|
|
337
|
+
values.user ?? process.env.VIBE_OS_USER ?? os.userInfo().username,
|
|
338
|
+
"user",
|
|
339
|
+
),
|
|
340
|
+
stateDir,
|
|
341
|
+
webRoot: path.resolve(
|
|
342
|
+
String(
|
|
343
|
+
values["web-root"] ?? process.env.VIBE_OS_WEB_ROOT ?? DEFAULT_WEB_ROOT,
|
|
344
|
+
),
|
|
345
|
+
),
|
|
346
|
+
workspace: path.resolve(
|
|
347
|
+
String(
|
|
348
|
+
values.workspace ??
|
|
349
|
+
process.env.VIBE_OS_WORKSPACE ??
|
|
350
|
+
path.join(os.homedir(), "workspace"),
|
|
351
|
+
),
|
|
352
|
+
),
|
|
353
|
+
token,
|
|
354
|
+
allowedHosts,
|
|
355
|
+
certTtlSeconds: num(
|
|
356
|
+
values["cert-ttl"] ?? process.env.VIBE_OS_CERT_TTL,
|
|
357
|
+
12 * 60 * 60,
|
|
358
|
+
"cert-ttl",
|
|
359
|
+
),
|
|
360
|
+
sessions,
|
|
361
|
+
};
|
|
362
|
+
}
|
package/server/db.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Persistence for projects, workspaces and window layout.
|
|
2
|
+
//
|
|
3
|
+
// SQLite via bun:sqlite, which is built into the runtime — no service to run
|
|
4
|
+
// and one file in the state directory. The schema is applied as idempotent DDL
|
|
5
|
+
// at startup rather than through drizzle-kit migration files, because a
|
|
6
|
+
// `bun build --compile` binary has no directory to read .sql from. Verified to
|
|
7
|
+
// behave identically under `bun run` and compiled.
|
|
8
|
+
|
|
9
|
+
import { Database } from "bun:sqlite";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { getTableColumns } from "drizzle-orm";
|
|
12
|
+
import { type BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
|
|
13
|
+
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
14
|
+
import { log } from "./log.ts";
|
|
15
|
+
|
|
16
|
+
/** A git repository checked out on this machine. */
|
|
17
|
+
export const projects = sqliteTable("projects", {
|
|
18
|
+
id: text("id").primaryKey(),
|
|
19
|
+
name: text("name").notNull(),
|
|
20
|
+
path: text("path").notNull().unique(),
|
|
21
|
+
/** Branch the repo's main checkout is on, refreshed on scan. */
|
|
22
|
+
branch: text("branch"),
|
|
23
|
+
/** origin remote, when it has one — usually the GitHub URL. */
|
|
24
|
+
remote: text("remote"),
|
|
25
|
+
createdAt: integer("created_at").notNull(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** A git worktree of a project — what the UI calls a workspace. */
|
|
29
|
+
export const workspaces = sqliteTable(
|
|
30
|
+
"workspaces",
|
|
31
|
+
{
|
|
32
|
+
id: text("id").primaryKey(),
|
|
33
|
+
projectId: text("project_id").notNull(),
|
|
34
|
+
/** Three words, kebab-case. Doubles as the branch and the dtach prefix. */
|
|
35
|
+
name: text("name").notNull(),
|
|
36
|
+
branch: text("branch").notNull(),
|
|
37
|
+
path: text("path").notNull(),
|
|
38
|
+
createdAt: integer("created_at").notNull(),
|
|
39
|
+
lastOpenedAt: integer("last_opened_at").notNull(),
|
|
40
|
+
},
|
|
41
|
+
(t) => [index("workspaces_project_idx").on(t.projectId)],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A role you can open a terminal as — "QA Engineer", "Backend Manager".
|
|
46
|
+
*
|
|
47
|
+
* Scoped to a project rather than a workspace: the prompts and flags belong to
|
|
48
|
+
* the codebase, and workspaces are throwaway worktrees of it.
|
|
49
|
+
*/
|
|
50
|
+
export const profiles = sqliteTable(
|
|
51
|
+
"profiles",
|
|
52
|
+
{
|
|
53
|
+
id: text("id").primaryKey(),
|
|
54
|
+
projectId: text("project_id").notNull(),
|
|
55
|
+
name: text("name").notNull(),
|
|
56
|
+
/** Palette token, not a hex value — the CSS owns what the colours are. */
|
|
57
|
+
color: text("color").notNull(),
|
|
58
|
+
/** 'claude' | 'shell' | 'custom' */
|
|
59
|
+
harness: text("harness").notNull(),
|
|
60
|
+
/** Executable for the custom harness; unused by the other two. */
|
|
61
|
+
command: text("command"),
|
|
62
|
+
/** JSON array of argv tokens, so each can be quoted on its own. */
|
|
63
|
+
args: text("args").notNull().default("[]"),
|
|
64
|
+
prompt: text("prompt").notNull().default(""),
|
|
65
|
+
/** Order in the rail. */
|
|
66
|
+
position: integer("position").notNull(),
|
|
67
|
+
createdAt: integer("created_at").notNull(),
|
|
68
|
+
},
|
|
69
|
+
(t) => [index("profiles_project_idx").on(t.projectId)],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/** One terminal window, with its place on the grid. */
|
|
73
|
+
export const windows = sqliteTable(
|
|
74
|
+
"windows",
|
|
75
|
+
{
|
|
76
|
+
id: text("id").primaryKey(),
|
|
77
|
+
workspaceId: text("workspace_id").notNull(),
|
|
78
|
+
/** Per-workspace counter; forms the session name with the workspace. */
|
|
79
|
+
idx: integer("idx").notNull(),
|
|
80
|
+
col: integer("col").notNull(),
|
|
81
|
+
row: integer("row").notNull(),
|
|
82
|
+
colSpan: integer("col_span").notNull(),
|
|
83
|
+
rowSpan: integer("row_span").notNull(),
|
|
84
|
+
z: integer("z").notNull(),
|
|
85
|
+
minimized: integer("minimized").notNull().default(0),
|
|
86
|
+
/** The profile this window was opened as, or null for a plain terminal. */
|
|
87
|
+
profileId: text("profile_id"),
|
|
88
|
+
/** The prompt band has been handed off and should stay closed. */
|
|
89
|
+
promptDone: integer("prompt_done").notNull().default(0),
|
|
90
|
+
/**
|
|
91
|
+
* 'ssh' while this window's terminal belongs to an ssh client instead of
|
|
92
|
+
* the desktop, or null. A browser pop-out is not recorded here: those two
|
|
93
|
+
* documents share an origin and settle it over a BroadcastChannel, and a
|
|
94
|
+
* flag that outlived a closed pop-up would strand the window.
|
|
95
|
+
*/
|
|
96
|
+
handoff: text("handoff"),
|
|
97
|
+
/** When the handoff was made, so one that never got used can be reaped. */
|
|
98
|
+
handoffAt: integer("handoff_at"),
|
|
99
|
+
/** Set once a terminal has actually attached to this window's session. */
|
|
100
|
+
handoffSeen: integer("handoff_seen").notNull().default(0),
|
|
101
|
+
createdAt: integer("created_at").notNull(),
|
|
102
|
+
},
|
|
103
|
+
(t) => [index("windows_workspace_idx").on(t.workspaceId)],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const DDL = [
|
|
107
|
+
`CREATE TABLE IF NOT EXISTS projects (
|
|
108
|
+
id TEXT PRIMARY KEY,
|
|
109
|
+
name TEXT NOT NULL,
|
|
110
|
+
path TEXT NOT NULL UNIQUE,
|
|
111
|
+
branch TEXT,
|
|
112
|
+
remote TEXT,
|
|
113
|
+
created_at INTEGER NOT NULL
|
|
114
|
+
)`,
|
|
115
|
+
`CREATE TABLE IF NOT EXISTS workspaces (
|
|
116
|
+
id TEXT PRIMARY KEY,
|
|
117
|
+
project_id TEXT NOT NULL,
|
|
118
|
+
name TEXT NOT NULL,
|
|
119
|
+
branch TEXT NOT NULL,
|
|
120
|
+
path TEXT NOT NULL,
|
|
121
|
+
created_at INTEGER NOT NULL,
|
|
122
|
+
last_opened_at INTEGER NOT NULL
|
|
123
|
+
)`,
|
|
124
|
+
`CREATE INDEX IF NOT EXISTS workspaces_project_idx ON workspaces (project_id)`,
|
|
125
|
+
`CREATE TABLE IF NOT EXISTS profiles (
|
|
126
|
+
id TEXT PRIMARY KEY,
|
|
127
|
+
project_id TEXT NOT NULL,
|
|
128
|
+
name TEXT NOT NULL,
|
|
129
|
+
color TEXT NOT NULL,
|
|
130
|
+
harness TEXT NOT NULL,
|
|
131
|
+
command TEXT,
|
|
132
|
+
args TEXT NOT NULL DEFAULT '[]',
|
|
133
|
+
prompt TEXT NOT NULL DEFAULT '',
|
|
134
|
+
position INTEGER NOT NULL,
|
|
135
|
+
created_at INTEGER NOT NULL
|
|
136
|
+
)`,
|
|
137
|
+
`CREATE INDEX IF NOT EXISTS profiles_project_idx ON profiles (project_id)`,
|
|
138
|
+
`CREATE TABLE IF NOT EXISTS windows (
|
|
139
|
+
id TEXT PRIMARY KEY,
|
|
140
|
+
workspace_id TEXT NOT NULL,
|
|
141
|
+
idx INTEGER NOT NULL,
|
|
142
|
+
col INTEGER NOT NULL,
|
|
143
|
+
row INTEGER NOT NULL,
|
|
144
|
+
col_span INTEGER NOT NULL,
|
|
145
|
+
row_span INTEGER NOT NULL,
|
|
146
|
+
z INTEGER NOT NULL,
|
|
147
|
+
minimized INTEGER NOT NULL DEFAULT 0,
|
|
148
|
+
profile_id TEXT,
|
|
149
|
+
prompt_done INTEGER NOT NULL DEFAULT 0,
|
|
150
|
+
handoff TEXT,
|
|
151
|
+
handoff_at INTEGER,
|
|
152
|
+
handoff_seen INTEGER NOT NULL DEFAULT 0,
|
|
153
|
+
created_at INTEGER NOT NULL
|
|
154
|
+
)`,
|
|
155
|
+
`CREATE INDEX IF NOT EXISTS windows_workspace_idx ON windows (workspace_id)`,
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Columns added to a table that already existed.
|
|
160
|
+
*
|
|
161
|
+
* The DDL above is all CREATE TABLE IF NOT EXISTS, which does nothing at all to
|
|
162
|
+
* a database from an earlier version — so a new column has to arrive this way
|
|
163
|
+
* or it only ever appears for people starting from scratch. ADD COLUMN is the
|
|
164
|
+
* one schema change SQLite does cheaply and in place.
|
|
165
|
+
*/
|
|
166
|
+
// Every value here is a compile-time literal, which is what makes the string
|
|
167
|
+
// concatenation in `ensureColumn` safe — SQLite cannot parameterise identifiers
|
|
168
|
+
// or DDL, so there is no bound-parameter form of ALTER TABLE. Nothing from a
|
|
169
|
+
// request may ever reach this list.
|
|
170
|
+
const ADDED_COLUMNS: [table: string, column: string, decl: string][] = [
|
|
171
|
+
["windows", "profile_id", "TEXT"],
|
|
172
|
+
["windows", "prompt_done", "INTEGER NOT NULL DEFAULT 0"],
|
|
173
|
+
["windows", "handoff", "TEXT"],
|
|
174
|
+
["windows", "handoff_at", "INTEGER"],
|
|
175
|
+
["windows", "handoff_seen", "INTEGER NOT NULL DEFAULT 0"],
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
function ensureColumn(
|
|
179
|
+
sqlite: Database,
|
|
180
|
+
table: string,
|
|
181
|
+
column: string,
|
|
182
|
+
decl: string,
|
|
183
|
+
): void {
|
|
184
|
+
const existing = sqlite.query(`PRAGMA table_info(${table})`).all() as {
|
|
185
|
+
name: string;
|
|
186
|
+
}[];
|
|
187
|
+
if (existing.some((c) => c.name === column)) return;
|
|
188
|
+
sqlite.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
|
|
189
|
+
log.info(`migrated ${table}: added ${column}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type Db = BunSQLiteDatabase<{
|
|
193
|
+
projects: typeof projects;
|
|
194
|
+
workspaces: typeof workspaces;
|
|
195
|
+
profiles: typeof profiles;
|
|
196
|
+
windows: typeof windows;
|
|
197
|
+
}>;
|
|
198
|
+
|
|
199
|
+
export function openDb(stateDir: string): Db {
|
|
200
|
+
const file = path.join(stateDir, "vibe-os.db");
|
|
201
|
+
const sqlite = new Database(file, { create: true });
|
|
202
|
+
|
|
203
|
+
// WAL keeps reads from blocking the writes the UI makes on every drag.
|
|
204
|
+
sqlite.run("PRAGMA journal_mode = WAL");
|
|
205
|
+
// On for anything added later. No table declares a REFERENCES clause today,
|
|
206
|
+
// so this currently enforces nothing: the cascades are done by hand, in
|
|
207
|
+
// projects.ts and profiles.ts. Do not read it as a guarantee.
|
|
208
|
+
sqlite.run("PRAGMA foreign_keys = ON");
|
|
209
|
+
for (const statement of DDL) sqlite.run(statement);
|
|
210
|
+
for (const [table, column, decl] of ADDED_COLUMNS)
|
|
211
|
+
ensureColumn(sqlite, table, column, decl);
|
|
212
|
+
|
|
213
|
+
assertSchemaMatches(sqlite);
|
|
214
|
+
|
|
215
|
+
log.debug(`opened database at ${file}`);
|
|
216
|
+
return drizzle(sqlite, {
|
|
217
|
+
schema: { projects, workspaces, profiles, windows },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fails loudly if the live tables do not have every column the code reads.
|
|
223
|
+
*
|
|
224
|
+
* Each migrated column is declared three times — in the drizzle schema, in the
|
|
225
|
+
* CREATE TABLE DDL, and in ADDED_COLUMNS — and nothing forces those to agree. A
|
|
226
|
+
* column added to the DDL and forgotten in ADDED_COLUMNS works perfectly on a
|
|
227
|
+
* fresh install and breaks only on upgrade, which is the failure the comment on
|
|
228
|
+
* ADDED_COLUMNS exists to prevent and could not actually catch. Checking what
|
|
229
|
+
* the code expects against what the file has turns that into a startup error
|
|
230
|
+
* with the column name in it.
|
|
231
|
+
*/
|
|
232
|
+
function assertSchemaMatches(sqlite: Database): void {
|
|
233
|
+
const tables = { projects, workspaces, profiles, windows };
|
|
234
|
+
const missing: string[] = [];
|
|
235
|
+
for (const [name, table] of Object.entries(tables)) {
|
|
236
|
+
const live = new Set(
|
|
237
|
+
(
|
|
238
|
+
sqlite.query(`PRAGMA table_info(${name})`).all() as { name: string }[]
|
|
239
|
+
).map((c) => c.name),
|
|
240
|
+
);
|
|
241
|
+
for (const column of Object.values(getTableColumns(table))) {
|
|
242
|
+
if (!live.has(column.name)) missing.push(`${name}.${column.name}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (missing.length > 0) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`database is missing column(s) the code expects: ${missing.join(", ")}. ` +
|
|
248
|
+
"A column was added to the schema without a matching ADDED_COLUMNS entry.",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const newId = () => crypto.randomUUID();
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* What a valid id looks like coming back over HTTP.
|
|
257
|
+
*
|
|
258
|
+
* Lives here beside `newId` because it has to accept what that produces. It was
|
|
259
|
+
* declared once in `api.ts` as `ID` and again in `attach.ts` as `WINDOW_ID`,
|
|
260
|
+
* byte-identical, and both validate the same values.
|
|
261
|
+
*/
|
|
262
|
+
export const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|