@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +928 -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 +843 -0
  41. package/server/config.ts +362 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +966 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +654 -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/ssh-ca.ts +368 -0
  58. package/server/static.ts +147 -0
  59. package/server/wallpapers.ts +248 -0
  60. package/server/windows.ts +237 -0
  61. package/shared/args.ts +91 -0
  62. package/shared/grid.ts +32 -0
  63. package/shared/wire.ts +226 -0
@@ -0,0 +1,248 @@
1
+ // Wallpaper storage.
2
+ //
3
+ // Stored content-addressed: the filename is derived from a hash of the bytes,
4
+ // never from anything the client sent. That removes path traversal as a
5
+ // category rather than trying to sanitise around it, and deduplicates uploads
6
+ // for free. The user's chosen display name lives in a side index.
7
+ //
8
+ // The type is decided by sniffing magic bytes, not by trusting Content-Type or
9
+ // a file extension. These files are served back to a browser, so letting a
10
+ // caller label an HTML document as an image would be a stored-XSS primitive.
11
+
12
+ import { createHash } from "node:crypto";
13
+ import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { serialise, writeAtomic } from "./fsx.ts";
16
+ import { log } from "./log.ts";
17
+
18
+ export const MAX_WALLPAPER_BYTES = 16 * 1024 * 1024;
19
+
20
+ const SIGNATURES: {
21
+ ext: string;
22
+ mime: string;
23
+ test: (b: Uint8Array) => boolean;
24
+ }[] = [
25
+ {
26
+ ext: "png",
27
+ mime: "image/png",
28
+ test: (b) =>
29
+ b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
30
+ },
31
+ {
32
+ ext: "jpg",
33
+ mime: "image/jpeg",
34
+ test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
35
+ },
36
+ {
37
+ ext: "gif",
38
+ mime: "image/gif",
39
+ test: (b) =>
40
+ b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38,
41
+ },
42
+ {
43
+ ext: "webp",
44
+ mime: "image/webp",
45
+ test: (b) =>
46
+ b[0] === 0x52 &&
47
+ b[1] === 0x49 &&
48
+ b[2] === 0x46 &&
49
+ b[3] === 0x46 &&
50
+ b[8] === 0x57 &&
51
+ b[9] === 0x45 &&
52
+ b[10] === 0x42 &&
53
+ b[11] === 0x50,
54
+ },
55
+ {
56
+ ext: "avif",
57
+ mime: "image/avif",
58
+ test: (b) =>
59
+ b[4] === 0x66 &&
60
+ b[5] === 0x74 &&
61
+ b[6] === 0x79 &&
62
+ b[7] === 0x70 &&
63
+ b[8] === 0x61 &&
64
+ b[9] === 0x76 &&
65
+ b[10] === 0x69 &&
66
+ b[11] === 0x66,
67
+ },
68
+ ];
69
+
70
+ const ID_PATTERN = /^wp-[a-f0-9]{16}\.(png|jpg|gif|webp|avif)$/;
71
+
72
+ /**
73
+ * Anything in Unicode's Control category, which a display name has no business
74
+ * carrying: it is echoed back to the browser and written into the index file.
75
+ */
76
+ const CONTROL_CHARS = /\p{Cc}/gu;
77
+
78
+ import type { DesktopPrefs, Wallpaper } from "../shared/wire.ts";
79
+
80
+ export type { DesktopPrefs, Wallpaper };
81
+
82
+ const DEFAULT_PREFS: DesktopPrefs = {
83
+ wallpaper: null,
84
+ fit: "cover",
85
+ dim: 0.35,
86
+ };
87
+
88
+ /**
89
+ * One rule for a prefs object, applied on the way in and on the way out.
90
+ *
91
+ * These were two implementations that disagreed: reading an out-of-range `dim`
92
+ * fell back to the default, writing the same value clamped it to 0.9. Whichever
93
+ * you believed, the other one was wrong.
94
+ */
95
+ function sanitisePrefs(raw: unknown): DesktopPrefs {
96
+ const v = (raw ?? {}) as Partial<DesktopPrefs>;
97
+ const dim = Number(v.dim);
98
+ return {
99
+ wallpaper:
100
+ typeof v.wallpaper === "string" && ID_PATTERN.test(v.wallpaper)
101
+ ? v.wallpaper
102
+ : null,
103
+ fit: v.fit === "contain" || v.fit === "tile" ? v.fit : "cover",
104
+ dim: Number.isFinite(dim)
105
+ ? Math.min(0.9, Math.max(0, dim))
106
+ : DEFAULT_PREFS.dim,
107
+ };
108
+ }
109
+
110
+ /** Mime for a stored id, which is content-addressed and carries its extension. */
111
+ function mimeForId(id: string): string {
112
+ return (
113
+ SIGNATURES.find((s) => s.ext === id.split(".").pop())?.mime ??
114
+ "application/octet-stream"
115
+ );
116
+ }
117
+
118
+ function sniff(bytes: Uint8Array): { ext: string; mime: string } | null {
119
+ if (bytes.length < 16) return null;
120
+ return SIGNATURES.find((s) => s.test(bytes)) ?? null;
121
+ }
122
+
123
+ export class WallpaperStore {
124
+ private readonly dir: string;
125
+ private readonly indexPath: string;
126
+ private readonly prefsPath: string;
127
+ /**
128
+ * The name index is read, modified and written back, which two concurrent
129
+ * uploads would otherwise interleave — both read the old map, and the second
130
+ * write drops the first's display name. Every mutation queues here instead.
131
+ */
132
+ private readonly queue = serialise();
133
+
134
+ constructor(stateDir: string) {
135
+ this.dir = path.join(stateDir, "wallpapers");
136
+ this.indexPath = path.join(this.dir, "index.json");
137
+ this.prefsPath = path.join(stateDir, "desktop.json");
138
+ }
139
+
140
+ async init(): Promise<void> {
141
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
142
+ }
143
+
144
+ private async names(): Promise<Record<string, string>> {
145
+ try {
146
+ return JSON.parse(await readFile(this.indexPath, "utf8")) as Record<
147
+ string,
148
+ string
149
+ >;
150
+ } catch {
151
+ return {};
152
+ }
153
+ }
154
+
155
+ async list(): Promise<Wallpaper[]> {
156
+ await this.init();
157
+ const names = await this.names();
158
+ const entries = await readdir(this.dir).catch(() => [] as string[]);
159
+ const out: Wallpaper[] = [];
160
+ for (const id of entries) {
161
+ if (!ID_PATTERN.test(id)) continue;
162
+ const file = Bun.file(path.join(this.dir, id));
163
+ const mime = mimeForId(id);
164
+ out.push({ id, name: names[id] ?? id, mime, size: file.size });
165
+ }
166
+ return out.sort((a, b) => a.name.localeCompare(b.name));
167
+ }
168
+
169
+ /** Returns the stored wallpaper, or null if the id is unknown or malformed. */
170
+ async read(
171
+ id: string,
172
+ ): Promise<{ file: ReturnType<typeof Bun.file>; mime: string } | null> {
173
+ if (!ID_PATTERN.test(id)) return null;
174
+ const file = Bun.file(path.join(this.dir, id));
175
+ if (!(await file.exists())) return null;
176
+ const mime = mimeForId(id);
177
+ return { file, mime };
178
+ }
179
+
180
+ async save(bytes: Uint8Array, displayName: string): Promise<Wallpaper> {
181
+ if (bytes.length === 0) throw new Error("empty upload");
182
+ if (bytes.length > MAX_WALLPAPER_BYTES) {
183
+ throw new Error(
184
+ `image is larger than ${Math.round(MAX_WALLPAPER_BYTES / 1024 / 1024)}MB`,
185
+ );
186
+ }
187
+ const kind = sniff(bytes);
188
+ if (!kind)
189
+ throw new Error("not a recognised image (png, jpeg, gif, webp or avif)");
190
+
191
+ await this.init();
192
+ const digest = createHash("sha256")
193
+ .update(bytes)
194
+ .digest("hex")
195
+ .slice(0, 16);
196
+ const id = `wp-${digest}.${kind.ext}`;
197
+ await writeFile(path.join(this.dir, id), bytes, { mode: 0o600 });
198
+
199
+ const clean =
200
+ displayName.replace(CONTROL_CHARS, "").trim().slice(0, 80) || id;
201
+ await this.queue(async () => {
202
+ const names = await this.names();
203
+ names[id] = clean;
204
+ await writeAtomic(this.indexPath, JSON.stringify(names, null, 2), 0o600);
205
+ });
206
+
207
+ log.info(
208
+ `stored wallpaper ${clean} (${(bytes.length / 1024).toFixed(0)}KB, ${kind.mime})`,
209
+ );
210
+ return { id, name: clean, mime: kind.mime, size: bytes.length };
211
+ }
212
+
213
+ async remove(id: string): Promise<boolean> {
214
+ if (!ID_PATTERN.test(id)) return false;
215
+ try {
216
+ await unlink(path.join(this.dir, id));
217
+ } catch {
218
+ return false;
219
+ }
220
+ await this.queue(async () => {
221
+ const names = await this.names();
222
+ delete names[id];
223
+ await writeAtomic(this.indexPath, JSON.stringify(names, null, 2), 0o600);
224
+ }).catch(() => {});
225
+
226
+ // Do not leave the desktop pointing at something that no longer exists.
227
+ const prefs = await this.prefs();
228
+ if (prefs.wallpaper === id)
229
+ await this.setPrefs({ ...prefs, wallpaper: null });
230
+ return true;
231
+ }
232
+
233
+ async prefs(): Promise<DesktopPrefs> {
234
+ try {
235
+ return sanitisePrefs(
236
+ JSON.parse(await readFile(this.prefsPath, "utf8")) as unknown,
237
+ );
238
+ } catch {
239
+ return { ...DEFAULT_PREFS };
240
+ }
241
+ }
242
+
243
+ async setPrefs(next: DesktopPrefs): Promise<DesktopPrefs> {
244
+ const clean = sanitisePrefs(next);
245
+ await writeAtomic(this.prefsPath, JSON.stringify(clean, null, 2), 0o600);
246
+ return clean;
247
+ }
248
+ }
@@ -0,0 +1,237 @@
1
+ // Window layout, stored per workspace.
2
+ //
3
+ // This lives on the server rather than in localStorage so a desktop follows you
4
+ // between browsers and machines, and so switching workspaces can restore an
5
+ // arrangement rather than rebuild one.
6
+
7
+ import { eq, sql } from "drizzle-orm";
8
+ import {
9
+ DEFAULT_WINDOW_COLS,
10
+ DEFAULT_WINDOW_ROWS,
11
+ GRID_COLS,
12
+ GRID_ROWS,
13
+ MIN_WINDOW_COLS,
14
+ MIN_WINDOW_ROWS,
15
+ } from "../shared/grid.ts";
16
+ import { sessionName } from "./attach.ts";
17
+ import { type Db, newId, windows, workspaces } from "./db.ts";
18
+
19
+ const MIN_COLS = MIN_WINDOW_COLS;
20
+ const MIN_ROWS = MIN_WINDOW_ROWS;
21
+
22
+ export interface Geometry {
23
+ col: number;
24
+ row: number;
25
+ colSpan: number;
26
+ rowSpan: number;
27
+ z?: number;
28
+ minimized?: boolean;
29
+ }
30
+
31
+ const clampInt = (
32
+ v: unknown,
33
+ lo: number,
34
+ hi: number,
35
+ fallback: number,
36
+ ): number => {
37
+ const n = Math.round(Number(v));
38
+ return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : fallback;
39
+ };
40
+
41
+ /**
42
+ * Server-side bounds check.
43
+ *
44
+ * The client already clamps, but geometry arrives over HTTP and a row that says
45
+ * a window is 900 cells wide would break every later render. Same rule as the
46
+ * client: size is honoured first, then position is pulled into range.
47
+ */
48
+ export function sanitize(
49
+ input: Partial<Geometry>,
50
+ current?: Geometry,
51
+ ): Required<Geometry> {
52
+ const colSpan = clampInt(
53
+ input.colSpan ?? current?.colSpan,
54
+ MIN_COLS,
55
+ GRID_COLS,
56
+ DEFAULT_WINDOW_COLS,
57
+ );
58
+ const rowSpan = clampInt(
59
+ input.rowSpan ?? current?.rowSpan,
60
+ MIN_ROWS,
61
+ GRID_ROWS,
62
+ DEFAULT_WINDOW_ROWS,
63
+ );
64
+ return {
65
+ colSpan,
66
+ rowSpan,
67
+ col: clampInt(input.col ?? current?.col, 0, GRID_COLS - colSpan, 0),
68
+ row: clampInt(input.row ?? current?.row, 0, GRID_ROWS - rowSpan, 0),
69
+ z: clampInt(input.z ?? current?.z, 0, Number.MAX_SAFE_INTEGER, 1),
70
+ minimized: Boolean(input.minimized ?? current?.minimized),
71
+ };
72
+ }
73
+
74
+ export function listWindows(db: Db, workspaceId: string) {
75
+ return db
76
+ .select()
77
+ .from(windows)
78
+ .where(eq(windows.workspaceId, workspaceId))
79
+ .orderBy(windows.z)
80
+ .all()
81
+ .map(toRow);
82
+ }
83
+
84
+ export function getWindow(db: Db, id: string) {
85
+ const row = db.select().from(windows).where(eq(windows.id, id)).get();
86
+ return row ? toRow(row) : undefined;
87
+ }
88
+
89
+ /**
90
+ * SQLite has no booleans, so the flags come back as 0 and 1.
91
+ *
92
+ * Concrete in its parameter rather than generic: a generic here produces
93
+ * `T & { minimized: boolean }`, and intersecting `number` with `boolean` is
94
+ * `never`, which makes every field of the result unreadable.
95
+ */
96
+ function toRow(w: typeof windows.$inferSelect) {
97
+ return {
98
+ ...w,
99
+ minimized: Boolean(w.minimized),
100
+ promptDone: Boolean(w.promptDone),
101
+ handoffSeen: Boolean(w.handoffSeen),
102
+ handoff: (w.handoff as "ssh" | null) ?? null,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Places a new window without landing exactly on an existing one.
108
+ *
109
+ * A plain cascade walks off the bottom-right after a few windows, so this wraps
110
+ * back and nudges sideways instead.
111
+ */
112
+ function placement(index: number): Geometry {
113
+ const colSpan = DEFAULT_WINDOW_COLS;
114
+ const rowSpan = DEFAULT_WINDOW_ROWS;
115
+ const step = 2;
116
+ const slots = Math.max(1, Math.floor((GRID_COLS - colSpan) / step));
117
+ const lane = index % slots;
118
+ const wrap = Math.floor(index / slots);
119
+ return sanitize({
120
+ col: lane * step + wrap,
121
+ row: (lane * step + wrap) % Math.max(1, GRID_ROWS - rowSpan),
122
+ colSpan,
123
+ rowSpan,
124
+ });
125
+ }
126
+
127
+ export function createWindow(
128
+ db: Db,
129
+ workspaceId: string,
130
+ profileId?: string | null,
131
+ ) {
132
+ const existing = listWindows(db, workspaceId);
133
+
134
+ // Lowest unused index: it becomes part of the session name, which people read
135
+ // when picking a window to attach to, so it should stay short and get reused.
136
+ const used = new Set(existing.map((w) => w.idx));
137
+ let idx = 1;
138
+ while (used.has(idx)) idx += 1;
139
+
140
+ const topZ = existing.reduce((max, w) => Math.max(max, w.z), 0);
141
+ const geometry = placement(existing.length);
142
+ const id = newId();
143
+
144
+ db.insert(windows)
145
+ .values({
146
+ id,
147
+ workspaceId,
148
+ idx,
149
+ col: geometry.col,
150
+ row: geometry.row,
151
+ colSpan: geometry.colSpan,
152
+ rowSpan: geometry.rowSpan,
153
+ z: topZ + 1,
154
+ minimized: 0,
155
+ profileId: profileId ?? null,
156
+ promptDone: 0,
157
+ createdAt: Date.now(),
158
+ })
159
+ .run();
160
+
161
+ const created = getWindow(db, id);
162
+ if (!created) throw new Error(`window ${id} vanished after insert`);
163
+ return created;
164
+ }
165
+
166
+ export function updateWindow(
167
+ db: Db,
168
+ id: string,
169
+ patch: Partial<Geometry> & { raise?: boolean; promptDone?: boolean },
170
+ ) {
171
+ const current = getWindow(db, id);
172
+ if (!current) return undefined;
173
+
174
+ let z = current.z;
175
+ if (patch.raise) {
176
+ const top = db
177
+ .select({ max: sql<number>`coalesce(max(${windows.z}), 0)` })
178
+ .from(windows)
179
+ .where(eq(windows.workspaceId, current.workspaceId))
180
+ .get();
181
+ z = (top?.max ?? 0) + 1;
182
+ }
183
+
184
+ const geometry = sanitize({ ...patch, z: patch.z ?? z }, current);
185
+ db.update(windows)
186
+ .set({
187
+ col: geometry.col,
188
+ row: geometry.row,
189
+ colSpan: geometry.colSpan,
190
+ rowSpan: geometry.rowSpan,
191
+ z: geometry.z,
192
+ minimized: geometry.minimized ? 1 : 0,
193
+ // One-way: the prompt band, once handed off, stays closed.
194
+ promptDone: patch.promptDone || current.promptDone ? 1 : 0,
195
+ })
196
+ .where(eq(windows.id, id))
197
+ .run();
198
+
199
+ return getWindow(db, id);
200
+ }
201
+
202
+ export function deleteWindow(db: Db, id: string) {
203
+ const row = getWindow(db, id);
204
+ if (row) db.delete(windows).where(eq(windows.id, id)).run();
205
+ return row;
206
+ }
207
+
208
+ /**
209
+ * The session name for a window, with everything else needed to launch it.
210
+ *
211
+ * Built from the workspace name and the window index so the session list reads as
212
+ * something a person recognises: `vibe-quiet-amber-otter-1`. The profile is
213
+ * deliberately *not* folded into the name — renaming a role would then orphan
214
+ * the session running it, for no gain a person would notice.
215
+ */
216
+ export function sessionNameFor(
217
+ db: Db,
218
+ windowId: string,
219
+ ): { session: string; cwd: string; profileId: string | null } | undefined {
220
+ const row = db
221
+ .select({
222
+ idx: windows.idx,
223
+ profileId: windows.profileId,
224
+ name: workspaces.name,
225
+ path: workspaces.path,
226
+ })
227
+ .from(windows)
228
+ .innerJoin(workspaces, eq(windows.workspaceId, workspaces.id))
229
+ .where(eq(windows.id, windowId))
230
+ .get();
231
+ if (!row) return undefined;
232
+ return {
233
+ session: sessionName(row.name, row.idx),
234
+ cwd: row.path,
235
+ profileId: row.profileId,
236
+ };
237
+ }
package/shared/args.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Turning a flag string into argv, and back.
3
+ *
4
+ * Shared rather than mirrored. These two are inverses of each other, and the
5
+ * editor relies on that: `detokenize` renders stored argv into the text field,
6
+ * `tokenize` parses it on the way back, so every save of an unmodified profile
7
+ * runs a full cycle. They lived in two files that agreed by hand until they
8
+ * didn't — the browser's copy had no unbalanced-quote check and no idea what
9
+ * the escaping the server emitted meant, so an argument with an apostrophe in
10
+ * it came back mangled on one side and unsaveable on the other.
11
+ *
12
+ * Runtime code, unlike the type-only `wire.ts` beside it, so `shared/` is in
13
+ * the package's `files` list.
14
+ */
15
+
16
+ /**
17
+ * Splits a flag string into argv tokens, honouring quotes.
18
+ *
19
+ * The tokens are shell-quoted individually when the launch command is built, so
20
+ * this is what stops `--model "gpt 4"` from becoming two arguments and equally
21
+ * what stops a stray `;` from becoming a second command. Unbalanced quotes are
22
+ * an error here rather than a mystery at spawn time — you find out when you
23
+ * press save, not when the terminal comes up empty.
24
+ */
25
+ export function tokenize(input: string): string[] {
26
+ const tokens: string[] = [];
27
+ let current = "";
28
+ let quote: '"' | "'" | null = null;
29
+ let started = false;
30
+
31
+ for (let i = 0; i < input.length; i += 1) {
32
+ const ch = input[i];
33
+
34
+ // Single quotes are literal in POSIX: no escape survives inside them, and
35
+ // the only way out is the closing quote. This is what makes the `'\''`
36
+ // idiom detokenize emits work — it closes, escapes, and reopens.
37
+ if (quote === "'") {
38
+ if (ch === "'") quote = null;
39
+ else current += ch;
40
+ continue;
41
+ }
42
+ if (quote === '"') {
43
+ if (ch === "\\" && (input[i + 1] === '"' || input[i + 1] === "\\")) {
44
+ current += input[i + 1];
45
+ i += 1;
46
+ } else if (ch === '"') quote = null;
47
+ else current += ch;
48
+ continue;
49
+ }
50
+ if (ch === "\\") {
51
+ if (i + 1 >= input.length)
52
+ throw new Error("the arguments end in a dangling backslash");
53
+ current += input[i + 1];
54
+ i += 1;
55
+ started = true;
56
+ continue;
57
+ }
58
+ if (ch === '"' || ch === "'") {
59
+ quote = ch;
60
+ started = true;
61
+ continue;
62
+ }
63
+ if (/\s/.test(ch)) {
64
+ if (started) tokens.push(current);
65
+ current = "";
66
+ started = false;
67
+ continue;
68
+ }
69
+ current += ch;
70
+ started = true;
71
+ }
72
+
73
+ if (quote) throw new Error(`unbalanced ${quote} in the arguments`);
74
+ if (started) tokens.push(current);
75
+ return tokens;
76
+ }
77
+
78
+ /**
79
+ * Renders tokens back to editable text. Round-trips through `tokenize`.
80
+ *
81
+ * Backslash is in the quote-me set alongside whitespace and the quote
82
+ * characters, because `tokenize` now treats it as an escape: without this a
83
+ * token like `C:\\path` would come back as `C:path`.
84
+ */
85
+ export function detokenize(tokens: string[]): string {
86
+ return tokens
87
+ .map((t) =>
88
+ t === "" || /[\s"'\\]/.test(t) ? `'${t.replaceAll("'", `'\\''`)}'` : t,
89
+ )
90
+ .join(" ");
91
+ }
package/shared/grid.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The window grid, agreed on by both halves of the desktop.
3
+ *
4
+ * Geometry is stored in grid units rather than pixels, and both sides clamp
5
+ * against these bounds: the browser so a drag cannot leave the screen, the
6
+ * server so a row arriving over HTTP cannot claim a window is 900 cells wide.
7
+ * They were declared separately in `server/windows.ts` and
8
+ * `src/desktop/useWindows.ts` with the server's copy imported by nobody — so
9
+ * changing the grid on one side silently clamped every stored window to a
10
+ * rectangle the other side was not drawing.
11
+ *
12
+ * Runtime values, so `shared/` ships with the package. See `shared/wire.ts` for
13
+ * the type-only half.
14
+ */
15
+
16
+ /** Columns across the desktop. */
17
+ export const GRID_COLS = 24;
18
+ /** Rows down the desktop. */
19
+ export const GRID_ROWS = 14;
20
+
21
+ /**
22
+ * The smallest a window may be, in cells.
23
+ *
24
+ * Below this a terminal has too few columns to be worth having — an 80-column
25
+ * program in four cells is unreadable rather than merely small.
26
+ */
27
+ export const MIN_WINDOW_COLS = 5;
28
+ export const MIN_WINDOW_ROWS = 4;
29
+
30
+ /** The default size a new window opens at. */
31
+ export const DEFAULT_WINDOW_COLS = 11;
32
+ export const DEFAULT_WINDOW_ROWS = 8;