@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.1

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +988 -2
  3. package/THIRD_PARTY_NOTICES.md +2467 -0
  4. package/bin/vibe-os.mjs +13 -0
  5. package/dist/web/apple-touch-icon.png +0 -0
  6. package/dist/web/assets/index-B77Qk8B5.js +35 -0
  7. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  8. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  9. package/dist/web/assets/index-CH5Em-Mo.css +1 -0
  10. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  11. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-app.svg +19 -0
  15. package/dist/web/icon-maskable-512.png +0 -0
  16. package/dist/web/icon-maskable.svg +13 -0
  17. package/dist/web/icon.svg +15 -0
  18. package/dist/web/index.html +36 -0
  19. package/dist/web/ssh.wasm +0 -0
  20. package/dist/web/ssh.wasm.br +0 -0
  21. package/dist/web/ssh.wasm.gz +0 -0
  22. package/dist/web/sw.js +29 -0
  23. package/dist/web/wasm_exec.js +575 -0
  24. package/dist/web/wasm_exec.js.br +0 -0
  25. package/dist/web/wasm_exec.js.gz +0 -0
  26. package/package.json +82 -4
  27. package/scripts/compile.ts +62 -0
  28. package/scripts/compress.mjs +54 -0
  29. package/scripts/fetch-wasm.mjs +242 -0
  30. package/scripts/gen-assets.ts +76 -0
  31. package/scripts/lib/walk.ts +18 -0
  32. package/scripts/notices.ts +189 -0
  33. package/server/acme.ts +182 -0
  34. package/server/api.ts +515 -0
  35. package/server/assets.generated.ts +53 -0
  36. package/server/attach.ts +524 -0
  37. package/server/auth.ts +194 -0
  38. package/server/bridge.ts +145 -0
  39. package/server/browser.ts +519 -0
  40. package/server/cli.ts +857 -0
  41. package/server/config.ts +385 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +977 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +655 -0
  46. package/server/http.ts +53 -0
  47. package/server/index.ts +469 -0
  48. package/server/log.ts +40 -0
  49. package/server/main.ts +7 -0
  50. package/server/mcp.ts +291 -0
  51. package/server/preflight.ts +87 -0
  52. package/server/profiles.ts +238 -0
  53. package/server/projects.ts +721 -0
  54. package/server/reaper.ts +303 -0
  55. package/server/runtime.ts +64 -0
  56. package/server/session.ts +330 -0
  57. package/server/setup.ts +1151 -0
  58. package/server/ssh-ca.ts +368 -0
  59. package/server/static.ts +147 -0
  60. package/server/wallpapers.ts +248 -0
  61. package/server/windows.ts +237 -0
  62. package/shared/args.ts +91 -0
  63. package/shared/grid.ts +32 -0
  64. package/shared/wire.ts +226 -0
package/server/http.ts ADDED
@@ -0,0 +1,53 @@
1
+ // Small HTTP pieces shared by the routes and the static server.
2
+ //
3
+ // Deliberately free of imports. The cache-control constant used to live in
4
+ // static.ts, which is the module that embeds the built web assets — so any
5
+ // module wanting one string pulled the whole asset manifest in with it, and
6
+ // anything importing api.ts could not be loaded without a completed build.
7
+
8
+ /**
9
+ * A year, the conventional "forever" for content-addressed assets: the hashed
10
+ * bundles, and the wallpapers, whose filenames are a digest of their bytes.
11
+ */
12
+ export const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
13
+
14
+ /**
15
+ * A JSON response.
16
+ *
17
+ * `no-store` on every one of them. These describe live server state — which
18
+ * windows exist, which are running — and a cached answer is worse than a slow
19
+ * one: a stale window list draws terminals that are gone.
20
+ */
21
+ export function json(body: unknown, status = 200): Response {
22
+ return Response.json(body, {
23
+ status,
24
+ headers: { "cache-control": "no-store" },
25
+ });
26
+ }
27
+
28
+ /** The 405 body, which fourteen routes were spelling out individually. */
29
+ export const methodNotAllowed = (): Response =>
30
+ json({ error: "method not allowed" }, 405);
31
+
32
+ /** The 400 for an id that does not even have the right shape. */
33
+ export const badId = (what: string): Response =>
34
+ json({ error: `invalid ${what} id` }, 400);
35
+
36
+ /**
37
+ * A JSON body, or `{}` when there isn't one.
38
+ *
39
+ * Every write route wants the same thing: parse if you can, and treat a missing
40
+ * or malformed body as empty so validation reports the real problem ("a profile
41
+ * needs a name") rather than a parse error. One route was the odd one out and
42
+ * threw instead.
43
+ *
44
+ * A body not labelled JSON counts as missing. `text/plain` is one of the types
45
+ * a page on another site can POST without a preflight, so honouring it would
46
+ * let such a page fill in a route's fields. The Origin check already stops
47
+ * that; this means a gap in it would only ever deliver an empty body.
48
+ */
49
+ export const readJson = async <T>(req: Request): Promise<T> => {
50
+ const type = req.headers.get("content-type")?.split(";")[0].trim();
51
+ if (type !== "application/json") return {} as T;
52
+ return (await req.json().catch(() => ({}))) as T;
53
+ };
@@ -0,0 +1,469 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import { promisify } from "node:util";
5
+ import type { Server } from "bun";
6
+ import { Acme } from "./acme.ts";
7
+ import { createApi } from "./api.ts";
8
+ import { createGate, hostAllowed, hostName, originAllowed } from "./auth.ts";
9
+ import {
10
+ type BridgeData,
11
+ bridgeConnections,
12
+ createBridgeHandlers,
13
+ } from "./bridge.ts";
14
+ import type { Config } from "./config.ts";
15
+ import { openDb } from "./db.ts";
16
+ import { discoverClaude, discoverCursor, discoverHermes } from "./harness.ts";
17
+ import { json } from "./http.ts";
18
+ import { color, describeError, log } from "./log.ts";
19
+ import { ensureWasm, ensureWebRoot } from "./preflight.ts";
20
+ import { scanProjects } from "./projects.ts";
21
+ import { windowCommand } from "./session.ts";
22
+ import {
23
+ discoverHostKey,
24
+ fingerprint,
25
+ requireSshKeygen,
26
+ SshCa,
27
+ } from "./ssh-ca.ts";
28
+ import { createStaticServer } from "./static.ts";
29
+ import { WallpaperStore } from "./wallpapers.ts";
30
+
31
+ const run = promisify(execFile);
32
+
33
+ export interface RunningServer {
34
+ port: number;
35
+ close: () => void;
36
+ }
37
+
38
+ /** Ceiling on an HTTP request body, sized for a wallpaper upload. */
39
+ const MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024;
40
+ /**
41
+ * How long a request may go quiet before Bun hangs up, in seconds.
42
+ *
43
+ * Bun's default is 10, and it counts a request that is still being handled as
44
+ * idle, not just a socket between requests. Creating a workspace fetches origin
45
+ * before it branches, so on a large repo or a slow link the browser got
46
+ * `ERR_EMPTY_RESPONSE` while the server carried on and made the workspace
47
+ * anyway: the one thing here that is slow was the one thing that could not
48
+ * finish. Bun's maximum, because the budget it has to cover is git's own
49
+ * timeouts in projects.ts plus checking out a worktree at the end of them.
50
+ */
51
+ const REQUEST_IDLE_TIMEOUT_S = 255;
52
+ /** Below this, binding needs root or CAP_NET_BIND_SERVICE. */
53
+ const FIRST_UNPRIVILEGED_PORT = 1024;
54
+ /** Where we land when port 80 is refused, so a first run still gets a URL. */
55
+ const UNPRIVILEGED_FALLBACK_PORT = 8080;
56
+
57
+ /**
58
+ * The installable-app description Chrome reads before it will offer "Install".
59
+ *
60
+ * `display: standalone` is what drops the browser chrome; without the 192 and
61
+ * 512 icons Chrome declines to offer installation at all. `id` is fixed so that
62
+ * changing `start_url` later updates the installed app rather than creating a
63
+ * second one beside it.
64
+ */
65
+ function webManifest(themeColor: string) {
66
+ return {
67
+ id: "/",
68
+ name: "vibe-os",
69
+ short_name: "vibe-os",
70
+ description: "A coding desktop in the browser, on a box you own.",
71
+ start_url: "/",
72
+ scope: "/",
73
+ display: "standalone",
74
+ orientation: "any",
75
+ background_color: "#0d1117",
76
+ theme_color: themeColor,
77
+ // PNGs only, and every one of them opaque. `icon.svg` is deliberately
78
+ // absent: it has the rounded corners that suit a favicon and a README, and
79
+ // an installer that fills transparency with white turns those into a white
80
+ // border around a dark icon in the Dock.
81
+ icons: [
82
+ { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
83
+ { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
84
+ {
85
+ src: "/icon-maskable-512.png",
86
+ sizes: "512x512",
87
+ type: "image/png",
88
+ purpose: "maskable",
89
+ },
90
+ ],
91
+ };
92
+ }
93
+
94
+ const UNAUTHORISED_PAGE = `<!doctype html><meta charset="utf-8"><title>vibe-os</title>
95
+ <body style="font:14px ui-monospace,monospace;background:#07090d;color:#c8cedb;padding:2rem">
96
+ <h1 style="font-weight:600">vibe-os</h1>
97
+ <p>This server requires a token. Open the URL printed in the server log.</p></body>`;
98
+
99
+ /**
100
+ * Addresses a browser can actually reach.
101
+ *
102
+ * `os.hostname()` is the tempting answer and the wrong one — on a VPS it is
103
+ * usually something like "ubuntu-2gb-fsn1" that resolves nowhere, and in a
104
+ * container it is a hex id. Interface addresses are what someone can type.
105
+ */
106
+ function reachableHosts(config: Config): string[] {
107
+ if (config.domain) return [config.domain];
108
+ if (config.host !== "0.0.0.0" && config.host !== "::") return [config.host];
109
+
110
+ const addresses: string[] = [];
111
+ for (const iface of Object.values(os.networkInterfaces())) {
112
+ for (const info of iface ?? []) {
113
+ if (info.family === "IPv4" && !info.internal)
114
+ addresses.push(info.address);
115
+ }
116
+ }
117
+ return addresses.length > 0 ? addresses : [os.hostname()];
118
+ }
119
+
120
+ /**
121
+ * This machine's name on the tailnet, if Tailscale is up.
122
+ *
123
+ * `tailscale serve` passes the browser's Host through, so behind it every
124
+ * request names the box by its MagicDNS name, which nothing else here knows.
125
+ */
126
+ async function tailscaleName(): Promise<string | null> {
127
+ try {
128
+ const { stdout } = await run("tailscale", ["status", "--json"], {
129
+ timeout: 3_000,
130
+ });
131
+ const name = (JSON.parse(stdout) as { Self?: { DNSName?: string } }).Self
132
+ ?.DNSName;
133
+ return name ? hostName(name) : null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Every name a request may use to reach this server when there is no token.
141
+ *
142
+ * Addresses and `localhost` need no listing; `hostAllowed` takes those as
143
+ * given. A dotted name brings its first label too, since MagicDNS and most
144
+ * LANs answer to the short form.
145
+ */
146
+ async function knownHosts(config: Config): Promise<Set<string>> {
147
+ const names = [
148
+ os.hostname(),
149
+ config.domain,
150
+ config.sshAdvertise && hostName(config.sshAdvertise),
151
+ await tailscaleName(),
152
+ ...config.allowedHosts,
153
+ ]
154
+ .filter((n): n is string => Boolean(n))
155
+ .map(hostName);
156
+ return new Set(names.flatMap((n) => [n, n.split(".")[0]]));
157
+ }
158
+
159
+ function banner(
160
+ config: Config,
161
+ port: number,
162
+ urls: string[],
163
+ extras: string[],
164
+ ): void {
165
+ const line = color.dim("─".repeat(58));
166
+ console.log("");
167
+ console.log(
168
+ ` ${color.bold(color.cyan("vibe-os"))} ${color.dim(`· ${os.hostname()}`)}`,
169
+ );
170
+ console.log(` ${line}`);
171
+ console.log(` ${color.bold("open")} ${color.cyan(urls[0])}`);
172
+ for (const extra of urls.slice(1, 4))
173
+ console.log(` ${color.dim(extra)}`);
174
+ console.log(
175
+ ` ${color.dim("shell")} ${config.user}@${config.sshHost}:${config.sshPort}`,
176
+ );
177
+ console.log(
178
+ ` ${color.dim("windows")} ${config.sessions ? "dtach-backed (survive reload)" : "plain login shell"}`,
179
+ );
180
+ for (const extra of extras) console.log(` ${color.dim(extra)}`);
181
+ console.log(` ${line}`);
182
+
183
+ if (!config.token) {
184
+ console.log("");
185
+ console.log(
186
+ ` ${color.yellow("!")} ${color.bold("This server is unauthenticated.")}`,
187
+ );
188
+ console.log(
189
+ ` Anyone who can reach ${color.bold(`${config.host}:${port}`)} gets a shell as ${color.bold(config.user)}.`,
190
+ );
191
+ console.log(
192
+ ` Keep it on a private network, or restart with ${color.bold("--token")}.`,
193
+ );
194
+ }
195
+ console.log("");
196
+ }
197
+
198
+ export async function startServer(config: Config): Promise<RunningServer> {
199
+ await mkdir(config.stateDir, { recursive: true, mode: 0o700 });
200
+ await ensureWebRoot(config.webRoot);
201
+ await ensureWasm(config.webRoot);
202
+ await requireSshKeygen();
203
+
204
+ // Certificate authority: created once, then trusted by sshd for this user.
205
+ const ca = new SshCa(config.stateDir);
206
+ await ca.ensure();
207
+ const trust = await ca.trustInAuthorizedKeys(os.homedir());
208
+ if (trust === "added") {
209
+ log.ok(`added vibe-os CA to ${os.homedir()}/.ssh/authorized_keys`);
210
+ } else {
211
+ log.debug("vibe-os CA already trusted in authorized_keys");
212
+ }
213
+
214
+ const hostKey = await discoverHostKey(config.sshHost, config.sshPort);
215
+ if (hostKey) {
216
+ log.ok(
217
+ `pinned host key ${fingerprint(hostKey)} for ${config.sshHost}:${config.sshPort}`,
218
+ );
219
+ } else {
220
+ log.warn(
221
+ "could not discover the SSH host key — the browser will ask you to confirm it on first connect",
222
+ );
223
+ }
224
+
225
+ const wallpapers = new WallpaperStore(config.stateDir);
226
+ await wallpapers.init();
227
+
228
+ const db = openDb(config.stateDir);
229
+ // Warm the project list before the first request so the picker is populated
230
+ // on the very first page load rather than one poll later.
231
+ await scanProjects(db, config);
232
+
233
+ // Warmed here, deliberately not awaited: asking the Claude binary what it
234
+ // supports costs a few seconds of its startup, and the answer is only needed
235
+ // the first time someone opens the profile editor. Doing it now means it is
236
+ // ready by then; doing it there would make the panel hang on first open.
237
+ void discoverClaude();
238
+ // Hermes is Python and answers three `config get` calls, so it is slower
239
+ // still. Same reasoning, more of it.
240
+ void discoverHermes();
241
+ // Cursor asks its own service for the model list and login state, so this
242
+ // one can wait on the network rather than a binary. Same reasoning again.
243
+ void discoverCursor();
244
+
245
+ const gate = createGate(config.token);
246
+ // Only consulted without a token, so only worth the Tailscale probe then.
247
+ const hosts = config.token ? new Set<string>() : await knownHosts(config);
248
+ const serveStatic = createStaticServer(config.webRoot);
249
+ const handleApi = createApi({ config, ca, hostKey, wallpapers, db });
250
+ const bridge = createBridgeHandlers({
251
+ host: config.sshHost,
252
+ port: config.sshPort,
253
+ });
254
+ const acme = config.domain
255
+ ? new Acme({
256
+ domain: config.domain,
257
+ email: config.acmeEmail,
258
+ staging: config.acmeStaging,
259
+ stateDir: config.stateDir,
260
+ })
261
+ : null;
262
+
263
+ let tlsReady = false;
264
+
265
+ const handle = async (
266
+ req: Request,
267
+ server: Server<BridgeData>,
268
+ secure: boolean,
269
+ ): Promise<Response | undefined> => {
270
+ const url = new URL(req.url);
271
+
272
+ // ACME challenges must answer on plain HTTP, unauthenticated, before any
273
+ // redirect — Let's Encrypt will not follow a 302 to a cert we do not have.
274
+ if (!secure && acme) {
275
+ const challenge = acme.handleChallenge(url);
276
+ if (challenge) return challenge;
277
+ }
278
+
279
+ if (!secure && tlsReady && config.domain) {
280
+ return Response.redirect(
281
+ `https://${config.domain}${url.pathname}${url.search}`,
282
+ 308,
283
+ );
284
+ }
285
+
286
+ if (!originAllowed(req)) {
287
+ log.warn(
288
+ `rejected ${req.method} ${url.pathname} from origin ${req.headers.get("origin")}`,
289
+ );
290
+ return new Response("forbidden: cross-origin request\n", {
291
+ status: 403,
292
+ });
293
+ }
294
+ if (!config.token && !hostAllowed(req, hosts)) {
295
+ const name = hostName(req.headers.get("host") ?? "");
296
+ log.warn(
297
+ `rejected a request for host ${JSON.stringify(name)}; if that is this server, add --allowed-host ${name}`,
298
+ );
299
+ return new Response(
300
+ `forbidden: this server does not answer to ${name}\n` +
301
+ `If it should, restart it with --allowed-host ${name}, or with a token.\n`,
302
+ { status: 403 },
303
+ );
304
+ }
305
+
306
+ const redirect = gate.consumeTokenParam(req, url, secure);
307
+ if (redirect) return redirect;
308
+
309
+ const denied = gate.check(req);
310
+
311
+ if (url.pathname === "/websocket") {
312
+ if (denied) return new Response("unauthorized", { status: 401 });
313
+ if (bridge.atCapacity())
314
+ return new Response("too many connections", { status: 503 });
315
+
316
+ const data: BridgeData = {
317
+ socket: null,
318
+ peer: server.requestIP(req)?.address ?? "?",
319
+ closed: false,
320
+ };
321
+ if (server.upgrade(req, { data })) return undefined;
322
+ return new Response("websocket upgrade failed", { status: 400 });
323
+ }
324
+
325
+ if (denied) {
326
+ if (url.pathname.startsWith("/api/")) return json({ error: denied }, 401);
327
+ return new Response(UNAUTHORISED_PAGE, {
328
+ status: 401,
329
+ headers: { "content-type": "text/html; charset=utf-8" },
330
+ });
331
+ }
332
+
333
+ // Generated rather than a file in public/, because the theme colour is
334
+ // configurable and the whole point of that is telling two installed
335
+ // instances apart.
336
+ if (url.pathname === "/manifest.webmanifest") {
337
+ return Response.json(webManifest(config.themeColor), {
338
+ headers: {
339
+ "content-type": "application/manifest+json",
340
+ // Cheap to fetch and it changes with a flag, so never cache it.
341
+ "cache-control": "no-store",
342
+ },
343
+ });
344
+ }
345
+
346
+ const api = await handleApi(req, url);
347
+ if (api) return api;
348
+
349
+ const asset = await serveStatic(req, url.pathname);
350
+ if (asset) return asset;
351
+
352
+ return new Response("not found\n", {
353
+ status: 404,
354
+ headers: { "content-type": "text/plain; charset=utf-8" },
355
+ });
356
+ };
357
+
358
+ const makeServer = (
359
+ port: number,
360
+ secure: boolean,
361
+ tls?: { key: string; cert: string },
362
+ ): Server<BridgeData> =>
363
+ Bun.serve<BridgeData>({
364
+ port,
365
+ hostname: config.host,
366
+ ...(tls ? { tls } : {}),
367
+ // Wallpaper uploads are the largest body any route accepts, and this is
368
+ // the ceiling before per-route checks see it. WebSocket framing is a
369
+ // different knob entirely — that is `backpressureLimit` in bridge.ts.
370
+ maxRequestBodySize: MAX_REQUEST_BODY_BYTES,
371
+ idleTimeout: REQUEST_IDLE_TIMEOUT_S,
372
+ development: false,
373
+ async fetch(req, server) {
374
+ try {
375
+ return (await handle(req, server, secure)) as Response;
376
+ } catch (err) {
377
+ log.error(`request failed: ${describeError(err)}`);
378
+ return new Response("internal error\n", { status: 500 });
379
+ }
380
+ },
381
+ websocket: bridge.handlers,
382
+ });
383
+
384
+ /**
385
+ * Binds the requested port, falling back rather than dying.
386
+ *
387
+ * Port 80 needs root or CAP_NET_BIND_SERVICE, and on a fresh VPS the user is
388
+ * usually root so it just works. When it does not, exiting with EACCES is a
389
+ * bad experience — come up somewhere reachable and say how to fix it.
390
+ */
391
+ let httpServer: Server<BridgeData>;
392
+ let port = config.port;
393
+ try {
394
+ httpServer = makeServer(config.port, false);
395
+ } catch (err) {
396
+ const code = (err as NodeJS.ErrnoException).code;
397
+ if (
398
+ (code !== "EACCES" && code !== "EPERM") ||
399
+ config.port >= FIRST_UNPRIVILEGED_PORT
400
+ )
401
+ throw err;
402
+
403
+ log.warn(
404
+ `cannot bind port ${config.port} as ${os.userInfo().username} (needs root or CAP_NET_BIND_SERVICE)`,
405
+ );
406
+ log.warn(
407
+ ` grant it once: sudo setcap 'cap_net_bind_service=+ep' $(readlink -f "$(which bun)")`,
408
+ );
409
+ log.warn(" or install the service: sudo vibe-os install-service");
410
+ port = UNPRIVILEGED_FALLBACK_PORT;
411
+ httpServer = makeServer(port, false);
412
+ log.warn(`listening on ${port} instead of ${config.port}`);
413
+ }
414
+
415
+ let httpsServer: Server<BridgeData> | undefined;
416
+ let stopRenewal: (() => void) | undefined;
417
+ const extras: string[] = [];
418
+
419
+ if (acme && config.domain) {
420
+ try {
421
+ const material = await acme.obtain();
422
+ httpsServer = makeServer(config.tlsPort, true, material);
423
+ tlsReady = true;
424
+ extras.push(`http://${config.domain} redirects to https`);
425
+ // Certificates last 90 days and this process is meant to run for longer,
426
+ // so expiry has to be re-checked while it runs, not only at boot.
427
+ stopRenewal = acme.watch((renewed) => {
428
+ httpsServer?.stop(true);
429
+ httpsServer = makeServer(config.tlsPort, true, renewed);
430
+ log.ok(`TLS certificate renewed for ${config.domain}`);
431
+ });
432
+ } catch (err) {
433
+ log.error(`TLS setup failed: ${describeError(err)}`);
434
+ log.warn(`continuing on plain HTTP at port ${port}`);
435
+ }
436
+ }
437
+
438
+ const query = config.token ? `?token=${config.token}` : "";
439
+ const urls = tlsReady
440
+ ? [
441
+ `https://${config.domain}${config.tlsPort === 443 ? "" : `:${config.tlsPort}`}/${query}`,
442
+ ]
443
+ : reachableHosts(config).map(
444
+ (h) => `http://${h}${port === 80 ? "" : `:${port}`}/${query}`,
445
+ );
446
+
447
+ const sample = windowCommand(
448
+ "vibe-<workspace>-1",
449
+ "<worktree>",
450
+ config,
451
+ null,
452
+ { forDisplay: true },
453
+ );
454
+ if (sample) extras.push(`each window runs: ${sample}`);
455
+ extras.push(`state: ${config.stateDir}`);
456
+ extras.push(`runtime: bun ${Bun.version}`);
457
+
458
+ banner(config, port, urls, extras);
459
+
460
+ return {
461
+ port,
462
+ close() {
463
+ stopRenewal?.();
464
+ httpServer.stop(true);
465
+ httpsServer?.stop(true);
466
+ log.info(`stopped (${bridgeConnections()} bridge connections were open)`);
467
+ },
468
+ };
469
+ }
package/server/log.ts ADDED
@@ -0,0 +1,40 @@
1
+ const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
2
+
3
+ const paint = (code: string, s: string) =>
4
+ useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
5
+
6
+ export const color = {
7
+ dim: (s: string) => paint("2", s),
8
+ bold: (s: string) => paint("1", s),
9
+ cyan: (s: string) => paint("36", s),
10
+ green: (s: string) => paint("32", s),
11
+ yellow: (s: string) => paint("33", s),
12
+ red: (s: string) => paint("31", s),
13
+ };
14
+
15
+ function stamp(): string {
16
+ return color.dim(new Date().toISOString().slice(11, 19));
17
+ }
18
+
19
+ /**
20
+ * What a caught `unknown` should say to a person.
21
+ *
22
+ * `catch` binds `unknown`, so every site that wants to report a failure has to
23
+ * narrow it first. That one-liner was written out verbatim in thirteen places
24
+ * across the server before this existed; the client has its own copy in
25
+ * `src/data.ts` for the same reason.
26
+ */
27
+ export function describeError(err: unknown): string {
28
+ return err instanceof Error ? err.message : String(err);
29
+ }
30
+
31
+ export const log = {
32
+ info: (msg: string) => console.log(`${stamp()} ${msg}`),
33
+ ok: (msg: string) => console.log(`${stamp()} ${color.green("✓")} ${msg}`),
34
+ warn: (msg: string) => console.warn(`${stamp()} ${color.yellow("!")} ${msg}`),
35
+ error: (msg: string) => console.error(`${stamp()} ${color.red("✗")} ${msg}`),
36
+ debug: (msg: string) => {
37
+ if (process.env.VIBE_OS_DEBUG)
38
+ console.log(`${stamp()} ${color.dim(`· ${msg}`)}`);
39
+ },
40
+ };
package/server/main.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Entry point for `bun build --compile`.
2
+ import { main } from "./cli.ts";
3
+ import { cliArgs } from "./runtime.ts";
4
+
5
+ const code = await main(cliArgs());
6
+ // -1 means "the server is running"; anything else is a one-shot command.
7
+ if (code >= 0) process.exit(code);