@1agh/maude 0.47.0 → 0.49.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 (58) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bridge.ts +8 -1
  3. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  4. package/apps/studio/api.ts +18 -2
  5. package/apps/studio/assets-s3.ts +291 -0
  6. package/apps/studio/build.ts +1 -1
  7. package/apps/studio/client/export-center.jsx +7 -6
  8. package/apps/studio/client/github.js +11 -4
  9. package/apps/studio/collab/origins.ts +150 -0
  10. package/apps/studio/collab/protocol.ts +36 -9
  11. package/apps/studio/collab/room.ts +124 -0
  12. package/apps/studio/context.ts +9 -0
  13. package/apps/studio/dist/client.bundle.js +282 -282
  14. package/apps/studio/dist/comment-mount.js +2 -2
  15. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  16. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  17. package/apps/studio/exporters/pdf.ts +35 -1
  18. package/apps/studio/git/service.ts +4 -1
  19. package/apps/studio/http.ts +45 -0
  20. package/apps/studio/paths.ts +37 -1
  21. package/apps/studio/server.ts +75 -2
  22. package/apps/studio/sync/autocommit.ts +299 -0
  23. package/apps/studio/sync/doc-name.ts +228 -0
  24. package/apps/studio/sync/index.ts +95 -1
  25. package/apps/studio/sync/workspace-signin.ts +301 -0
  26. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  27. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  28. package/apps/studio/test/assets-s3.test.ts +249 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  30. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  31. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  32. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  33. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  34. package/apps/studio/test/workspace-containment.test.ts +258 -0
  35. package/apps/studio/test/workspace-signin.test.ts +270 -0
  36. package/apps/studio/use-collab.tsx +28 -1
  37. package/apps/studio/workspace-mode.ts +210 -0
  38. package/apps/studio/ws.ts +11 -2
  39. package/cli/bin/maude.mjs +1 -0
  40. package/cli/commands/hub-workspace.mjs +341 -0
  41. package/cli/commands/hub.mjs +325 -3
  42. package/cli/commands/hub.test.mjs +14 -1
  43. package/cli/commands/init.mjs +80 -3
  44. package/cli/commands/kg.mjs +368 -0
  45. package/cli/commands/kg.test.mjs +118 -0
  46. package/cli/lib/cell-plan.mjs +302 -0
  47. package/cli/lib/cell-plan.test.mjs +225 -0
  48. package/cli/lib/ddr-to-kgai.mjs +648 -0
  49. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  50. package/cli/lib/flow-design-integration.test.mjs +2 -2
  51. package/cli/lib/gitignore-block.mjs +16 -1
  52. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  53. package/cli/lib/workspace-plan.mjs +422 -0
  54. package/cli/lib/workspace-plan.test.mjs +223 -0
  55. package/package.json +8 -8
  56. package/plugins/design/dependencies.json +17 -0
  57. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  58. package/plugins/flow/dependencies.json +17 -0
@@ -34,6 +34,12 @@ import { createHttp } from './http.ts';
34
34
  import { createInspect } from './inspect.ts';
35
35
  import { startHeapWatch } from './mem.ts';
36
36
  import { createSyncRuntime } from './sync/index.ts';
37
+ import {
38
+ assertContainment,
39
+ isForbiddenRoute,
40
+ isWorkspaceMode,
41
+ pruneForWorkspace,
42
+ } from './workspace-mode.ts';
37
43
  import { createWs, isLoopbackHost, isSameOriginWs, parseCollabSlug, type WsData } from './ws.ts';
38
44
 
39
45
  // Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
@@ -141,16 +147,73 @@ type BunServer = ReturnType<typeof Bun.serve<WsData, never>>;
141
147
  // transient-buffer tradeoff on the buffering routes, noted in DDR-148).
142
148
  const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
143
149
 
150
+ // Containment invariant (DDR-193 §2) — the boot-assert. No-op outside workspace
151
+ // mode; in a cell it refuses to start when a rendering / evaluating / exporting
152
+ // surface is reachable, naming exactly which one. See workspace-mode.ts for why
153
+ // this is a process that will not start rather than a convention.
154
+ //
155
+ // Checked against the ACTUAL route table (`http.routes` keys plus the paths the
156
+ // `fetch` fall-through owns), not a hand-maintained list, so a future route can
157
+ // only escape it by being invisible to both.
158
+ const WORKSPACE_FETCH_ROUTES = ['/_ws/acp', '/_canvas-shell.html', '/_canvas-runtime/'];
159
+ const WORKSPACE = isWorkspaceMode();
160
+ // PRUNE first, then assert over what survived — so the assert is a
161
+ // post-condition on the pruning rather than a second, driftable opinion. A
162
+ // prefix added to the vocabulary then both prunes and is verified, together.
163
+ const pruned = WORKSPACE ? pruneForWorkspace(http.routes) : { routes: http.routes, removed: [] };
164
+ const SERVER_ROUTES = pruned.routes as typeof http.routes;
165
+ if (WORKSPACE && pruned.removed.length > 0) {
166
+ console.log(
167
+ `[studio] workspace mode — withheld ${pruned.removed.length} route(s) that would evaluate ` +
168
+ `tenant content: ${pruned.removed.join(', ')}`
169
+ );
170
+ }
171
+ try {
172
+ assertContainment([...Object.keys(SERVER_ROUTES), ...(WORKSPACE ? [] : WORKSPACE_FETCH_ROUTES)], {
173
+ // Presence of the dependency is the signal: a cell image that ships
174
+ // Playwright is one import() away from rendering tenant content. Skippable
175
+ // in a dev checkout, where Playwright is a legitimate devDependency of the
176
+ // E2E harness and would otherwise make workspace mode untestable locally.
177
+ // A BUILT cell image has no such escape — scripts/check-containment.sh
178
+ // enforces the runtime-dependency half at build time.
179
+ resolveModule:
180
+ process.env.MAUDE_WORKSPACE_ALLOW_DEV_MODULES === '1'
181
+ ? undefined
182
+ : (specifier) => {
183
+ try {
184
+ return !!import.meta.resolveSync?.(specifier);
185
+ } catch {
186
+ return false;
187
+ }
188
+ },
189
+ });
190
+ } catch (err) {
191
+ console.error(`\n${(err as Error).message}\n`);
192
+ process.exit(1);
193
+ }
194
+ if (isWorkspaceMode()) {
195
+ console.log('[studio] workspace mode — sync + git + assets only (DDR-193 containment invariant)');
196
+ }
197
+
144
198
  function startServer(port: number): BunServer {
145
199
  return Bun.serve<WsData, never>({
146
200
  port,
147
201
  hostname: '127.0.0.1',
148
202
  development: process.env.NODE_ENV !== 'production',
149
203
  maxRequestBodySize: MAX_REQUEST_BODY,
150
- routes: http.routes,
204
+ routes: SERVER_ROUTES,
151
205
  async fetch(req, srv) {
152
206
  const pathname = new URL(req.url).pathname;
153
207
 
208
+ // Containment (DDR-193 §2) — the `fetch` fall-through owns paths that are
209
+ // not in the route table (`/_ws/acp`, `/_canvas-shell.html`,
210
+ // `/_canvas-runtime/*`), so pruning the table alone would leave them
211
+ // reachable. 404, not 403: a cell should look like it never had the
212
+ // feature, rather than like it is refusing one.
213
+ if (WORKSPACE && isForbiddenRoute(pathname)) {
214
+ return new Response('not found', { status: 404 });
215
+ }
216
+
154
217
  // Phase 8 — collab WS, binary y-websocket protocol. Loopback-only;
155
218
  // DDR-047 makes cross-machine collab a Phase 9 hub-deploy story, not
156
219
  // a `--bind 0.0.0.0` flag on this server.
@@ -167,6 +230,8 @@ function startServer(port: number): BunServer {
167
230
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
168
231
  kind: 'collab',
169
232
  slug: collabSlug,
233
+ // Privileged shell origin — ungated (see collab/origins.ts).
234
+ realm: 'main',
170
235
  },
171
236
  });
172
237
  if (ok) return undefined as unknown as Response;
@@ -275,6 +340,10 @@ function startCanvasServer(port: number): BunServer {
275
340
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
276
341
  kind: 'collab',
277
342
  slug: collabSlug,
343
+ // UNTRUSTED canvas iframe origin (DDR-063 split). Every sync frame
344
+ // from here goes through the origin gate and may never write a
345
+ // body lane — DDR-122 follow-up, collab/origins.ts.
346
+ realm: 'canvas',
278
347
  },
279
348
  });
280
349
  if (ok) return undefined as unknown as Response;
@@ -377,7 +446,11 @@ ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port
377
446
  const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
378
447
  process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
379
448
  );
380
- const canvasServer = CANVAS_ORIGIN_SPLIT ? startCanvasServer(0) : null;
449
+ // The canvas origin exists to SERVE AND RENDER canvases (DDR-063). A workspace
450
+ // cell has no business starting it — that second origin is the surface the
451
+ // containment invariant is about. Not started here rather than started-and-
452
+ // pruned: there would be nothing left to serve.
453
+ const canvasServer = CANVAS_ORIGIN_SPLIT && !WORKSPACE ? startCanvasServer(0) : null;
381
454
  const canvasOrigin = canvasServer ? `http://localhost:${canvasServer.port}` : undefined;
382
455
  if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
383
456
 
@@ -0,0 +1,299 @@
1
+ // Autosave → append-only git commits — Cloud Phase 3 Task 1.
2
+ //
3
+ // On a laptop, autosave writing a file IS the save: the developer's own git is
4
+ // the history, and they commit when they mean to. In a workspace cell there is
5
+ // no developer at the keyboard, so an unwritten history means the only record
6
+ // of a design is its current bytes — one bad sync away from unrecoverable.
7
+ //
8
+ // So the cell commits. Three rules make that safe rather than merely noisy:
9
+ //
10
+ // 1. APPEND-ONLY. `git add` + `git commit`, ever. No amend, no rebase, no
11
+ // reset, no `checkout --` over a dirty tree, and NEVER a force-push. The
12
+ // history is allowed to be ugly; it is not allowed to lose a state that
13
+ // once existed.
14
+ // 2. AUTHORSHIP IS THE EDITING HUMAN. git separates author from committer
15
+ // precisely for this: the author is the person whose edit this was (from
16
+ // presence), the committer is the workspace bot. `git blame` then answers
17
+ // "who designed this" instead of "the server did", which is the whole
18
+ // reason to keep history at all.
19
+ // 3. QUIESCENCE, NOT KEYSTROKES. Commits fire after edits stop, so a typing
20
+ // session is one commit rather than four hundred.
21
+ //
22
+ // The disk write has ALREADY happened by the time anything here runs. A git
23
+ // failure therefore never loses work — it leaves the change uncommitted and
24
+ // retries on the next quiescence. That ordering is deliberate: making the
25
+ // commit a precondition of the save would turn a transient git error into
26
+ // data loss, which is precisely backwards.
27
+
28
+ import path from 'node:path';
29
+
30
+ /** How long the tree must be quiet before a commit fires. */
31
+ const DEFAULT_DEBOUNCE_MS = 3000;
32
+
33
+ export interface GitRunResult {
34
+ code: number;
35
+ stdout: string;
36
+ stderr: string;
37
+ }
38
+
39
+ /** Injected so tests drive a real repo and callers can swap the runner. */
40
+ export type GitRunner = (args: string[], opts: { cwd: string }) => Promise<GitRunResult>;
41
+
42
+ export interface EditAttribution {
43
+ /** Display name from presence, e.g. "Alice Novak". */
44
+ name: string;
45
+ /** Address from presence, or a synthesized stable one. */
46
+ email: string;
47
+ }
48
+
49
+ export interface AutoCommitOptions {
50
+ repoRoot: string;
51
+ run: GitRunner;
52
+ /** Quiescence window. */
53
+ debounceMs?: number;
54
+ /** Committer identity — the machine, never the human. */
55
+ bot?: EditAttribution;
56
+ log?: Pick<Console, 'warn' | 'error' | 'log'>;
57
+ }
58
+
59
+ export interface AutoCommit {
60
+ /**
61
+ * Record that `relPath` changed, attributed to `who`. Repeated calls within
62
+ * the debounce window coalesce into one commit.
63
+ */
64
+ note(relPath: string, who?: EditAttribution | null): void;
65
+ /** Force the pending commit now (branch switch, shutdown). */
66
+ flush(): Promise<CommitOutcome | null>;
67
+ /** Pending paths, for tests + status surfaces. */
68
+ pending(): string[];
69
+ stop(): void;
70
+ }
71
+
72
+ export type CommitOutcome =
73
+ | { ok: true; sha: string; files: string[]; author: EditAttribution }
74
+ | { ok: false; reason: 'nothing-to-commit' | 'git-failed'; detail?: string; files: string[] };
75
+
76
+ const DEFAULT_BOT: EditAttribution = {
77
+ name: 'Maude Workspace',
78
+ email: 'workspace@maude.local',
79
+ };
80
+
81
+ /**
82
+ * Attribution for an edit whose author we don't know.
83
+ *
84
+ * Deliberately NOT the bot: attributing an anonymous human's work to the server
85
+ * makes `git blame` lie in a way that is hard to notice later. "Unknown" is
86
+ * honest, and it is visibly wrong in a way that prompts a fix.
87
+ */
88
+ export const UNKNOWN_AUTHOR: EditAttribution = {
89
+ name: 'Unknown editor',
90
+ email: 'unknown@maude.local',
91
+ };
92
+
93
+ /**
94
+ * Sanitize a presence-supplied identity before it reaches a git argument.
95
+ *
96
+ * Presence comes from peers over the hub, which is semi-trusted (DDR-054): a
97
+ * name is attacker-influenceable text. Newlines are the specific hazard —
98
+ * `git commit --author` takes `Name <email>`, and an embedded newline could
99
+ * forge trailer lines in the commit message. Everything is passed as argv (no
100
+ * shell), so this is about the git format, not shell quoting.
101
+ */
102
+ export function sanitizeAttribution(who: EditAttribution | null | undefined): EditAttribution {
103
+ if (!who) return UNKNOWN_AUTHOR;
104
+ const clean = (s: string, fallback: string) => {
105
+ const out = String(s ?? '')
106
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point.
107
+ .replace(/[\s\u0000-\u001f\u007f-\u009f]+/g, ' ')
108
+ .replace(/[<>]/g, '')
109
+ .trim()
110
+ .slice(0, 96);
111
+ return out || fallback;
112
+ };
113
+ return {
114
+ name: clean(who.name, UNKNOWN_AUTHOR.name),
115
+ email: clean(who.email, UNKNOWN_AUTHOR.email),
116
+ };
117
+ }
118
+
119
+ /** `Name <email>` as git's `--author` wants it. */
120
+ export function formatAuthor(who: EditAttribution): string {
121
+ const s = sanitizeAttribution(who);
122
+ return `${s.name} <${s.email}>`;
123
+ }
124
+
125
+ /**
126
+ * Commit subject. Names WHAT changed rather than "autosave", because a history
127
+ * of four hundred identical subjects is the same as no history.
128
+ */
129
+ export function commitMessage(files: string[], who: EditAttribution): string {
130
+ // Strip from the FIRST dot, not the last: `Screen.tsx` and `Screen.meta.json`
131
+ // are one canvas, and counting them as two would make every commit subject
132
+ // overstate what changed.
133
+ const canvases = [...new Set(files.map((f) => path.basename(f).replace(/\..*$/, '')))].sort();
134
+ const subject =
135
+ canvases.length === 1
136
+ ? `design: update ${canvases[0]}`
137
+ : `design: update ${canvases.length} canvases`;
138
+ const body = [
139
+ '',
140
+ canvases.length > 1 ? canvases.map((c) => `- ${c}`).join('\n') : '',
141
+ '',
142
+ `Edited by ${who.name} <${who.email}> via the Maude workspace.`,
143
+ 'Autosaved — append-only; this history is never rewritten.',
144
+ ]
145
+ .filter((line, i, all) => !(line === '' && all[i - 1] === ''))
146
+ .join('\n');
147
+ return `${subject}\n${body}`;
148
+ }
149
+
150
+ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
151
+ const {
152
+ repoRoot,
153
+ run,
154
+ debounceMs = DEFAULT_DEBOUNCE_MS,
155
+ bot = DEFAULT_BOT,
156
+ log = console,
157
+ } = opts;
158
+
159
+ const touched = new Set<string>();
160
+ let author: EditAttribution | null = null;
161
+ let timer: ReturnType<typeof setTimeout> | null = null;
162
+ let inFlight: Promise<CommitOutcome | null> | null = null;
163
+ let stopped = false;
164
+
165
+ function note(relPath: string, who?: EditAttribution | null): void {
166
+ if (stopped) return;
167
+ touched.add(relPath);
168
+ // Last writer wins for attribution. A commit that coalesced two people's
169
+ // edits can only name one author; the message body names them, and the
170
+ // alternative (splitting per author) would fight the quiescence batching
171
+ // that keeps this history readable.
172
+ if (who) author = who;
173
+ if (timer) clearTimeout(timer);
174
+ timer = setTimeout(() => {
175
+ timer = null;
176
+ void flush();
177
+ }, debounceMs);
178
+ }
179
+
180
+ async function flush(): Promise<CommitOutcome | null> {
181
+ if (inFlight) return inFlight;
182
+ if (timer) {
183
+ clearTimeout(timer);
184
+ timer = null;
185
+ }
186
+ if (touched.size === 0) return null;
187
+
188
+ const files = [...touched].sort();
189
+ const who = sanitizeAttribution(author);
190
+ touched.clear();
191
+ author = null;
192
+
193
+ inFlight = (async (): Promise<CommitOutcome> => {
194
+ try {
195
+ // Stage ONLY what changed. `git add -A` in a workspace would sweep in
196
+ // whatever else is in the tree — including files a future feature drops
197
+ // there — and the cell must never commit something it wasn't told about.
198
+ const add = await run(['add', '--', ...files], { cwd: repoRoot });
199
+ if (add.code !== 0) {
200
+ log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
201
+ return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
202
+ }
203
+
204
+ // Nothing staged ⇒ the write was a no-op (an echo, or identical bytes).
205
+ // Not an error, and committing an empty change would be noise.
206
+ const staged = await run(['diff', '--cached', '--name-only'], { cwd: repoRoot });
207
+ if (staged.code === 0 && staged.stdout.trim() === '') {
208
+ return { ok: false, reason: 'nothing-to-commit', files };
209
+ }
210
+
211
+ const commit = await run(
212
+ [
213
+ '-c',
214
+ `user.name=${bot.name}`,
215
+ '-c',
216
+ `user.email=${bot.email}`,
217
+ 'commit',
218
+ '--author',
219
+ formatAuthor(who),
220
+ '--only',
221
+ '--message',
222
+ commitMessage(files, who),
223
+ '--',
224
+ ...files,
225
+ ],
226
+ { cwd: repoRoot }
227
+ );
228
+ if (commit.code !== 0) {
229
+ log.warn?.(`[autocommit] git commit failed: ${commit.stderr.trim()}`);
230
+ // The bytes are on disk. Re-queue so the next quiescence retries
231
+ // rather than silently dropping the change from history.
232
+ for (const f of files) touched.add(f);
233
+ return { ok: false, reason: 'git-failed', detail: commit.stderr.trim(), files };
234
+ }
235
+
236
+ const head = await run(['rev-parse', 'HEAD'], { cwd: repoRoot });
237
+ const sha = head.stdout.trim();
238
+ log.log?.(`[autocommit] ${sha.slice(0, 8)} ${files.length} file(s) by ${who.name}`);
239
+ return { ok: true, sha, files, author: who };
240
+ } finally {
241
+ inFlight = null;
242
+ }
243
+ })();
244
+
245
+ return inFlight;
246
+ }
247
+
248
+ return {
249
+ note,
250
+ flush,
251
+ pending: () => [...touched].sort(),
252
+ stop() {
253
+ stopped = true;
254
+ if (timer) {
255
+ clearTimeout(timer);
256
+ timer = null;
257
+ }
258
+ },
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Push to a mirror remote, refusing anything that would rewrite it.
264
+ *
265
+ * A cell that force-pushes destroys work that exists only on the remote —
266
+ * exactly the hazard DDR-119 was written about, arriving from the other
267
+ * direction. On rejection the correct behaviour is to STOP and surface it, not
268
+ * to "resolve" it: a non-fast-forward means someone else's commits are there,
269
+ * and the cell has no way to know whether merging them is right.
270
+ */
271
+ export async function pushMirror({
272
+ repoRoot,
273
+ run,
274
+ remote = 'origin',
275
+ branch,
276
+ log = console,
277
+ }: {
278
+ repoRoot: string;
279
+ run: GitRunner;
280
+ remote?: string;
281
+ branch: string;
282
+ log?: Pick<Console, 'warn'>;
283
+ }): Promise<{ ok: boolean; rejected: boolean; detail?: string }> {
284
+ // No --force, no --force-with-lease, no +refspec. If this ever needs one,
285
+ // that is a design conversation, not a flag.
286
+ const res = await run(['push', remote, `refs/heads/${branch}:refs/heads/${branch}`], {
287
+ cwd: repoRoot,
288
+ });
289
+ if (res.code === 0) return { ok: true, rejected: false };
290
+ const detail = `${res.stderr}\n${res.stdout}`.trim();
291
+ const rejected = /\brejected\b|non-fast-forward|fetch first/i.test(detail);
292
+ if (rejected) {
293
+ log.warn?.(
294
+ '[autocommit] mirror push REJECTED — someone else saved first. Stopping rather than ' +
295
+ 'rewriting their work; the local history is intact and nothing was lost.'
296
+ );
297
+ }
298
+ return { ok: false, rejected, detail };
299
+ }
@@ -0,0 +1,228 @@
1
+ // Document namespace, client side — `ws/<workspace-id>/<branch>/<slug>` (DDR-192 §5).
2
+ //
3
+ // ⚠ THE GRAMMAR IS MIRRORED in `apps/hub/src/doc-namespace.mjs` and the two are
4
+ // pinned to each other by `test/sync-doc-name.test.ts`, which imports the hub's
5
+ // implementation and asserts both agree over the same corpus. They are separate
6
+ // files on purpose: the hub image installs frozen against its own bun.lock and
7
+ // must not reach into apps/studio. Change one, change the other.
8
+ //
9
+ // WHAT IS NAMESPACED AND WHAT IS NOT: only the WIRE name changes. Every local
10
+ // map — the provider registry, the sync agent, the projection, `_history/`,
11
+ // `_comments/` — stays keyed by the flat slug. The namespace exists to keep two
12
+ // projects (or two branches) from colliding INSIDE A HUB; on disk they already
13
+ // live in different directories.
14
+
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ /** The prefix that marks a namespaced documentName. */
19
+ export const DOC_NAMESPACE_PREFIX = 'ws';
20
+
21
+ /** Max length of one path component (workspace id / branch / slug). */
22
+ export const COMPONENT_MAX = 64;
23
+
24
+ export interface DocNamespace {
25
+ workspaceId: string;
26
+ branch: string;
27
+ slug: string;
28
+ }
29
+
30
+ /**
31
+ * Normalize one path component into the namespace charset. `/` is the
32
+ * separator, so a git branch like `feature/foo` becomes `feature-foo`.
33
+ */
34
+ export function sanitizeComponent(raw: string): string {
35
+ if (typeof raw !== 'string') return '';
36
+ return raw
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9._-]+/g, '-')
39
+ .replace(/-{2,}/g, '-')
40
+ .replace(/^[-.]+|[-.]+$/g, '')
41
+ .slice(0, COMPONENT_MAX)
42
+ .replace(/[-.]+$/g, '');
43
+ }
44
+
45
+ /** Build `ws/<workspace-id>/<branch>/<slug>`. Throws if a component is empty. */
46
+ export function buildDocName({ workspaceId, branch, slug }: DocNamespace): string {
47
+ const w = sanitizeComponent(workspaceId);
48
+ const b = sanitizeComponent(branch);
49
+ const s = sanitizeComponent(slug);
50
+ if (!w) throw new Error('buildDocName: workspaceId is empty after normalization');
51
+ if (!b) throw new Error('buildDocName: branch is empty after normalization');
52
+ if (!s) throw new Error('buildDocName: slug is empty after normalization');
53
+ return `${DOC_NAMESPACE_PREFIX}/${w}/${b}/${s}`;
54
+ }
55
+
56
+ /** Parse a documentName; null means a legacy flat slug (expected, not an error). */
57
+ export function parseDocName(name: string): DocNamespace | null {
58
+ if (typeof name !== 'string' || name.length === 0) return null;
59
+ const parts = name.split('/');
60
+ if (parts.length !== 4) return null;
61
+ const [prefix, workspaceId, branch, slug] = parts;
62
+ if (prefix !== DOC_NAMESPACE_PREFIX) return null;
63
+ if (!workspaceId || !branch || !slug) return null;
64
+ return { workspaceId, branch, slug };
65
+ }
66
+
67
+ /** True when `name` is a namespaced documentName. */
68
+ export function isNamespaced(name: string): boolean {
69
+ return parseDocName(name) !== null;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Resolution — where the workspace id and the branch actually come from
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Current branch, read straight from `.git/HEAD` (no subprocess — the same
78
+ * source `collab/git-lifecycle.ts` watches).
79
+ *
80
+ * A detached HEAD yields `detached-<sha7>`, which is a *stable* name for that
81
+ * commit: two peers detached at the same commit meet, and a peer on a branch
82
+ * never accidentally shares a doc with a detached checkout.
83
+ *
84
+ * Returns null when there is no git repo at all.
85
+ */
86
+ export function readBranch(repoRoot: string): string | null {
87
+ const headPath = path.join(repoRoot, '.git', 'HEAD');
88
+ if (!existsSync(headPath)) return null;
89
+ let head: string;
90
+ try {
91
+ head = readFileSync(headPath, 'utf8').trim();
92
+ } catch {
93
+ return null;
94
+ }
95
+ const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
96
+ if (ref?.[1]) return ref[1];
97
+ if (/^[0-9a-f]{7,40}$/i.test(head)) return `detached-${head.slice(0, 7)}`;
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Read the `origin` remote URL from `.git/config` without shelling out.
103
+ * Returns null when there is no origin (a purely local repo).
104
+ */
105
+ export function readOriginUrl(repoRoot: string): string | null {
106
+ const cfgPath = path.join(repoRoot, '.git', 'config');
107
+ if (!existsSync(cfgPath)) return null;
108
+ let raw: string;
109
+ try {
110
+ raw = readFileSync(cfgPath, 'utf8');
111
+ } catch {
112
+ return null;
113
+ }
114
+ const section = raw.match(/\[remote "origin"\]([\s\S]*?)(?=\n\[|$)/);
115
+ const url = section?.[1]?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
116
+ return url && url.length > 0 ? url : null;
117
+ }
118
+
119
+ /**
120
+ * Reduce a git remote URL to a stable `<owner>-<repo>` identity, so
121
+ * `git@github.com:1aGh/maude.git` and `https://github.com/1aGh/maude` — the
122
+ * same project cloned two different ways — produce the SAME workspace id.
123
+ *
124
+ * Getting this wrong is not a cosmetic bug: two peers of one project that
125
+ * derive different ids would stop meeting, and each would see the other's docs
126
+ * as absent.
127
+ */
128
+ export function workspaceIdFromRemote(url: string): string {
129
+ let s = url.trim().replace(/\.git$/i, '');
130
+ s = s.replace(/^[a-z0-9+.-]+:\/\//i, ''); // scheme
131
+ s = s.replace(/^[^@/]+@/, ''); // user@
132
+ s = s.replace(/^([^/:]+):/, '$1/'); // scp-style host:path → host/path
133
+ const segments = s.split('/').filter(Boolean);
134
+ const tail = segments.slice(-2); // <owner>/<repo>
135
+ return sanitizeComponent(tail.join('-'));
136
+ }
137
+
138
+ export interface ResolveWorkspaceOpts {
139
+ /** Explicit id from config — authoritative when present (the cloud sets it). */
140
+ explicit?: string | undefined;
141
+ repoRoot: string;
142
+ }
143
+
144
+ /**
145
+ * Resolve the workspace id, or null when it cannot be derived in a way that is
146
+ * STABLE ACROSS MACHINES.
147
+ *
148
+ * Order: explicit config → git origin remote. There is deliberately no
149
+ * directory-name fallback: a local path is not the same on two machines, so
150
+ * deriving from it would split peers of the same project into separate
151
+ * documents — the exact failure the namespace exists to prevent, arrived at
152
+ * from the other direction. No stable id ⇒ stay flat (see `createDocNameResolver`).
153
+ */
154
+ export function resolveWorkspaceId({ explicit, repoRoot }: ResolveWorkspaceOpts): string | null {
155
+ const fromConfig = sanitizeComponent(explicit ?? '');
156
+ if (fromConfig) return fromConfig;
157
+ const origin = readOriginUrl(repoRoot);
158
+ if (!origin) return null;
159
+ const derived = workspaceIdFromRemote(origin);
160
+ return derived || null;
161
+ }
162
+
163
+ export interface DocNameResolver {
164
+ /** Map a local canvas slug to the name used on the wire. */
165
+ (slug: string): string;
166
+ }
167
+
168
+ export interface DocNameResolverOpts {
169
+ repoRoot: string;
170
+ /** `linkedHub.workspaceId` when the config carries one. */
171
+ explicitWorkspaceId?: string | undefined;
172
+ /** `MAUDE_HUB_NAMESPACED` — '1' forces on, '0' forces off, absent = auto. */
173
+ flag?: string | undefined;
174
+ /** Test seam: override branch detection. */
175
+ branch?: string | undefined;
176
+ }
177
+
178
+ /**
179
+ * Build the slug → documentName mapping for this process.
180
+ *
181
+ * Rollout rule (DDR-192 §5): namespacing CHANGES DOC IDENTITY, so it is opt-in
182
+ * for now and becomes default-on in workspace mode (Phase 3).
183
+ *
184
+ * MAUDE_HUB_NAMESPACED=0 → always flat, even with an explicit workspace id
185
+ * MAUDE_HUB_NAMESPACED=1 → namespaced, and it is an ERROR to be unable to
186
+ * (an operator who asked for isolation gets a loud
187
+ * failure, never a silent fallback into a shared doc)
188
+ * unset → namespaced only when config declares a workspace
189
+ * id explicitly; otherwise flat
190
+ *
191
+ * The auto case is deliberately NOT "namespace whenever a git origin exists":
192
+ * flipping identity under an existing linked hub would make every doc look
193
+ * freshly empty. DDR-076 keeps that from eating local files, but the hub-side
194
+ * history would be orphaned, and nobody asked for that on upgrade.
195
+ */
196
+ export function createDocNameResolver(opts: DocNameResolverOpts): DocNameResolver {
197
+ const flag = opts.flag;
198
+ if (flag === '0') return (slug) => slug;
199
+
200
+ const workspaceId = resolveWorkspaceId({
201
+ explicit: opts.explicitWorkspaceId,
202
+ repoRoot: opts.repoRoot,
203
+ });
204
+ const branch = sanitizeComponent(opts.branch ?? readBranch(opts.repoRoot) ?? '');
205
+
206
+ if (flag === '1') {
207
+ if (!workspaceId) {
208
+ throw new Error(
209
+ 'MAUDE_HUB_NAMESPACED=1 but no workspace id could be resolved. Set ' +
210
+ '`linkedHub.workspaceId` in .design/config.json, or give the repo an ' +
211
+ '`origin` remote. Refusing to fall back to flat slugs — that would put ' +
212
+ 'this project in a shared document namespace (DDR-192 §5).'
213
+ );
214
+ }
215
+ if (!branch) {
216
+ throw new Error(
217
+ 'MAUDE_HUB_NAMESPACED=1 but the current branch could not be read from ' +
218
+ '.git/HEAD. Refusing to fall back to flat slugs (DDR-192 §5).'
219
+ );
220
+ }
221
+ }
222
+
223
+ const explicitlyDeclared = sanitizeComponent(opts.explicitWorkspaceId ?? '') !== '';
224
+ const on = flag === '1' || (explicitlyDeclared && !!workspaceId && !!branch);
225
+ if (!on || !workspaceId || !branch) return (slug) => slug;
226
+
227
+ return (slug) => buildDocName({ workspaceId, branch, slug });
228
+ }