@1agh/maude 0.49.2 → 0.51.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 (39) hide show
  1. package/apps/studio/api.ts +751 -52
  2. package/apps/studio/canvas-artifacts.ts +153 -0
  3. package/apps/studio/canvas-create.ts +23 -0
  4. package/apps/studio/canvas-slug.ts +30 -0
  5. package/apps/studio/client/app.jsx +547 -90
  6. package/apps/studio/client/panels/CloudBar.jsx +388 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +28 -0
  8. package/apps/studio/client/tree-row-menu.jsx +132 -0
  9. package/apps/studio/client/use-tree-drag.js +120 -0
  10. package/apps/studio/cloud/endpoints.ts +284 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/debug-bundle.ts +144 -0
  13. package/apps/studio/dist/client.bundle.js +1235 -1228
  14. package/apps/studio/dist/styles.css +1 -1
  15. package/apps/studio/http.ts +205 -1
  16. package/apps/studio/inspect.ts +49 -0
  17. package/apps/studio/server.ts +15 -0
  18. package/apps/studio/sync/workspace-signin.ts +15 -0
  19. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  20. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  21. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  23. package/apps/studio/test/cloud-endpoints.test.ts +130 -0
  24. package/apps/studio/test/debug-bundle.test.ts +105 -0
  25. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  26. package/apps/studio/test/workspace-signin.test.ts +19 -0
  27. package/apps/studio/whats-new.json +27 -0
  28. package/cli/bin/maude.mjs +1 -0
  29. package/cli/commands/hub-workspace.mjs +294 -7
  30. package/cli/commands/init.mjs +33 -2
  31. package/cli/commands/kg.mjs +129 -4
  32. package/cli/commands/share.mjs +184 -0
  33. package/cli/lib/ddr-to-kgai.mjs +310 -82
  34. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  35. package/cli/lib/share-plan.mjs +96 -0
  36. package/cli/lib/share-plan.test.mjs +57 -0
  37. package/cli/lib/workspace-plan.mjs +130 -12
  38. package/cli/lib/workspace-plan.test.mjs +160 -0
  39. package/package.json +9 -8
@@ -27,6 +27,7 @@ import { TranspileError } from './canvas-pipeline.ts';
27
27
  import type { AiActivity } from './collab/ai-activity.ts';
28
28
  import type { Context } from './context.ts';
29
29
  import { reloadConfig } from './context.ts';
30
+ import { buildDebugBundle } from './debug-bundle.ts';
30
31
  import { probeSetupReadiness } from './design-setup-readiness.ts';
31
32
  import { type Format, isFormat, isScope, type Scope } from './exporters/index.ts';
32
33
  import { type ExportJobQueue, ExportQueueFullError } from './exporters/jobs.ts';
@@ -77,6 +78,7 @@ import {
77
78
  } from './generation/whisper-models.ts';
78
79
  import { createGitEndpoints } from './git/endpoints.ts';
79
80
  import { gitShowFile } from './git/service.ts';
81
+ import { createCloudEndpoints } from './cloud/endpoints.ts';
80
82
  import { createGitHubEndpoints } from './github/endpoints.ts';
81
83
  import type { Inspect } from './inspect.ts';
82
84
  import { canvasSlug, writeLocator } from './locator.ts';
@@ -87,7 +89,7 @@ import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
87
89
  import { linkHub } from './sync/hub-link.ts';
88
90
  import { signInToWorkspace, workspaceDisclosure } from './sync/workspace-signin.ts';
89
91
  import { readUiPrefs, type UiPrefs, writeUiPrefs } from './ui-prefs.ts';
90
- import { loadWhatsNew } from './whats-new.ts';
92
+ import { loadWhatsNew, resolveMaudeVersion } from './whats-new.ts';
91
93
  import { isLoopbackHost } from './ws.ts';
92
94
 
93
95
  // Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
@@ -837,6 +839,11 @@ export function createHttp(
837
839
  // and every route is token-bearing (server-held keychain token via the loopback
838
840
  // bridge), so all four also carry a loopback-Host check.
839
841
  const githubApi = createGitHubEndpoints(ctx);
842
+ // Cloud Phase 23 C3 — `/_api/cloud/*`. Same dual-allowlist rule as the
843
+ // GitHub routes: MAIN-ORIGIN ONLY (absent from CANVAS_SAFE_API +
844
+ // startCanvasServer) and loopback-Host gated — every route either bears or
845
+ // stores the cloud credential.
846
+ const cloudApi = createCloudEndpoints(ctx);
840
847
  const gitJson = (r: { status: number; json: unknown }) =>
841
848
  Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
842
849
 
@@ -1164,6 +1171,84 @@ export function createHttp(
1164
1171
  '/_api/whats-new': () =>
1165
1172
  Response.json(loadWhatsNew(), { headers: { 'Cache-Control': 'no-store' } }),
1166
1173
 
1174
+ // feature-bug-report-button — the scrubbed diagnostic bundle the Report-a-Bug
1175
+ // dialog previews for consent. PRIVILEGED (logs could reveal paths pre-scrub;
1176
+ // the scrubber runs here, server-side): MAIN-ORIGIN ONLY — absent from
1177
+ // CANVAS_SAFE_API + startCanvasServer routes — plus the same double gate as
1178
+ // /_api/acp/status (a drive-by page must not read logs via DNS rebinding).
1179
+ '/_api/debug-bundle': (req: Request) => {
1180
+ if (!sameOriginRead(req)) return new Response('cross-origin rejected', { status: 403 });
1181
+ if (!isLoopbackHost(req.headers.get('host')))
1182
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1183
+ return Response.json(
1184
+ buildDebugBundle({
1185
+ maudeVersion: resolveMaudeVersion(),
1186
+ projectName: ctx.cfg.name ?? null,
1187
+ activeCanvas: (inspect.state as { active?: string | null }).active ?? null,
1188
+ repoRoot: ctx.paths.repoRoot,
1189
+ }),
1190
+ { headers: { 'Cache-Control': 'no-store' } }
1191
+ );
1192
+ },
1193
+
1194
+ // feature-bug-report-button — submit proxy. The client never talks to
1195
+ // cloud.maude.sh directly (no CORS surface to open, and the endpoint can be
1196
+ // overridden for self-hosters/tests via MAUDE_REPORT_URL). Forwards the
1197
+ // multipart body verbatim; the cloud route owns validation + quota.
1198
+ // PRIVILEGED: main-origin only + double gate (it triggers outbound network).
1199
+ '/_api/report': async (req: Request) => {
1200
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1201
+ if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1202
+ if (!isLoopbackHost(req.headers.get('host')))
1203
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1204
+ const endpoint = process.env.MAUDE_REPORT_URL || 'https://cloud.maude.sh/report';
1205
+ try {
1206
+ const upstream = await fetch(endpoint, {
1207
+ method: 'POST',
1208
+ body: await req.formData(),
1209
+ signal: AbortSignal.timeout(30_000),
1210
+ });
1211
+ const body = await upstream.text();
1212
+ return new Response(body, {
1213
+ status: upstream.status,
1214
+ headers: { 'content-type': 'application/json', 'Cache-Control': 'no-store' },
1215
+ });
1216
+ } catch {
1217
+ // Unreachable/timeout — the client falls back to the local bundle path.
1218
+ return Response.json({ error: 'report service unreachable' }, { status: 502 });
1219
+ }
1220
+ },
1221
+
1222
+ // feature-bug-report-button — offline/declined fallback: persist the bundle
1223
+ // locally under `<designRoot>/_reports/<ts>/` (gitignored runtime state,
1224
+ // DDR-115 taxonomy) so the user can attach it to a hand-filed issue.
1225
+ // PRIVILEGED: main-origin only + double gate (filesystem write).
1226
+ '/_api/report-fallback': async (req: Request) => {
1227
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1228
+ if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
1229
+ if (!isLoopbackHost(req.headers.get('host')))
1230
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1231
+ const body = await readJson<{ report?: unknown; screenshots?: unknown }>(req);
1232
+ if (!body || typeof body.report !== 'object' || body.report === null) {
1233
+ return new Response('body must include { report }', { status: 400 });
1234
+ }
1235
+ const shots = Array.isArray(body.screenshots) ? body.screenshots.slice(0, 3) : [];
1236
+ const dir = join(ctx.paths.designRoot, '_reports', String(Date.now()));
1237
+ await Bun.write(join(dir, 'report.json'), JSON.stringify(body.report, null, 2));
1238
+ let n = 0;
1239
+ for (const shot of shots) {
1240
+ if (typeof shot !== 'string') continue;
1241
+ const m = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/.exec(shot);
1242
+ if (!m || m[2].length > 7_000_000) continue; // ~5 MB decoded cap
1243
+ n += 1;
1244
+ await Bun.write(join(dir, `screenshot-${n}.${m[1] === 'png' ? 'png' : 'jpg'}`), Buffer.from(m[2], 'base64'));
1245
+ }
1246
+ return Response.json(
1247
+ { dir: relative(ctx.paths.repoRoot, dir), screenshots: n },
1248
+ { headers: { 'Cache-Control': 'no-store' } }
1249
+ );
1250
+ },
1251
+
1167
1252
  '/_index-data': async () =>
1168
1253
  Response.json(await api.buildIndexData(), { headers: { 'Cache-Control': 'no-store' } }),
1169
1254
 
@@ -1515,6 +1600,66 @@ export function createHttp(
1515
1600
  );
1516
1601
  },
1517
1602
 
1603
+ // feature-file-tree-drag-drop-folders (Task 5) — move/rename a canvas.
1604
+ // POST body { file, toDir } -> 200 { fromRel, toRel, fromSlug, toSlug, moved[] }
1605
+ // Same guard stack as /_api/canvas, copied verbatim: MAIN ORIGIN ONLY —
1606
+ // intentionally absent from BOTH startCanvasServer's allowlist and
1607
+ // CANVAS_SAFE_API (DDR-054/DDR-088) — the untrusted canvas iframe origin
1608
+ // must never reach a file-move endpoint. Validation lives in
1609
+ // api.moveCanvas (containment + non-DS-group allowlist + collision +
1610
+ // collab-pin guard).
1611
+ '/_api/fs-move': async (req: Request) => {
1612
+ if (!sameOriginWrite(req))
1613
+ return new Response('cross-origin write rejected', { status: 403 });
1614
+ if (!isLoopbackHost(req.headers.get('host')))
1615
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1616
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1617
+ const body = await readJson<{ file?: unknown; toDir?: unknown }>(req, 4 * 1024);
1618
+ if (!body) return new Response('body required', { status: 400 });
1619
+ const result = await api.moveCanvas(body);
1620
+ if (!result.ok) {
1621
+ return Response.json(
1622
+ { ok: false, error: result.error },
1623
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1624
+ );
1625
+ }
1626
+ return Response.json(
1627
+ {
1628
+ ok: true,
1629
+ fromRel: result.fromRel,
1630
+ toRel: result.toRel,
1631
+ fromSlug: result.fromSlug,
1632
+ toSlug: result.toSlug,
1633
+ moved: result.moved,
1634
+ },
1635
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1636
+ );
1637
+ },
1638
+
1639
+ // feature-file-tree-drag-drop-folders (Task 5) — create an empty folder.
1640
+ // POST body { parent?, name } -> 201 { dir }. Same guard stack, same
1641
+ // MAIN-ORIGIN-ONLY / dual-allowlist posture as /_api/fs-move above.
1642
+ '/_api/fs-mkdir': async (req: Request) => {
1643
+ if (!sameOriginWrite(req))
1644
+ return new Response('cross-origin write rejected', { status: 403 });
1645
+ if (!isLoopbackHost(req.headers.get('host')))
1646
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1647
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1648
+ const body = await readJson<{ parent?: unknown; name?: unknown }>(req, 4 * 1024);
1649
+ if (!body) return new Response('body required', { status: 400 });
1650
+ const result = await api.createFolder(body);
1651
+ if (!result.ok) {
1652
+ return Response.json(
1653
+ { ok: false, error: result.error },
1654
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1655
+ );
1656
+ }
1657
+ return Response.json(
1658
+ { ok: true, dir: result.dir },
1659
+ { status: 201, headers: { 'Cache-Control': 'no-store' } }
1660
+ );
1661
+ },
1662
+
1518
1663
  // ── Phase 27 (E2) — in-UI git layer. Save version / Publish / Get latest /
1519
1664
  // History / visual diff. All MAIN-ORIGIN ONLY (see gitApi comment above).
1520
1665
  // POST routes add the sameOriginWrite CSRF guard (cross-site forged POST);
@@ -1663,6 +1808,65 @@ export function createHttp(
1663
1808
  // since every one is token-bearing. (Sign-out is the `github_sign_out` Tauri
1664
1809
  // command — the dev-server can't touch the OS keychain — so there is no
1665
1810
  // DELETE /_api/github/identity here; see DDR-114.)
1811
+ '/_api/cloud/status': async (req: Request) => {
1812
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1813
+ if (!isLoopbackHost(req.headers.get('host')))
1814
+ return new Response('local request required', { status: 403 });
1815
+ return gitJson(cloudApi.status());
1816
+ },
1817
+ '/_api/cloud/signin/start': async (req: Request) => {
1818
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1819
+ if (!sameOriginWrite(req))
1820
+ return new Response('cross-origin write rejected', { status: 403 });
1821
+ if (!isLoopbackHost(req.headers.get('host')))
1822
+ return new Response('local request required', { status: 403 });
1823
+ return gitJson(await cloudApi.signinStart());
1824
+ },
1825
+ '/_api/cloud/signin/poll': async (req: Request) => {
1826
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1827
+ if (!sameOriginWrite(req))
1828
+ return new Response('cross-origin write rejected', { status: 403 });
1829
+ if (!isLoopbackHost(req.headers.get('host')))
1830
+ return new Response('local request required', { status: 403 });
1831
+ const body = (await req.json().catch(() => ({}))) as { deviceCode?: string };
1832
+ return gitJson(await cloudApi.signinPoll(String(body.deviceCode ?? '')));
1833
+ },
1834
+ '/_api/cloud/signout': async (req: Request) => {
1835
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1836
+ if (!sameOriginWrite(req))
1837
+ return new Response('cross-origin write rejected', { status: 403 });
1838
+ if (!isLoopbackHost(req.headers.get('host')))
1839
+ return new Response('local request required', { status: 403 });
1840
+ return gitJson(cloudApi.signout());
1841
+ },
1842
+ '/_api/cloud/projects': async (req: Request) => {
1843
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1844
+ if (!isLoopbackHost(req.headers.get('host')))
1845
+ return new Response('local request required', { status: 403 });
1846
+ return gitJson(await cloudApi.projects());
1847
+ },
1848
+ '/_api/cloud/attach': async (req: Request) => {
1849
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1850
+ if (!sameOriginWrite(req))
1851
+ return new Response('cross-origin write rejected', { status: 403 });
1852
+ if (!isLoopbackHost(req.headers.get('host')))
1853
+ return new Response('local request required', { status: 403 });
1854
+ const body = (await req.json().catch(() => ({}))) as { project?: string };
1855
+ return gitJson(await cloudApi.attach(String(body.project ?? '')));
1856
+ },
1857
+ '/_api/cloud/attach/code': async (req: Request) => {
1858
+ // The maude:// lane (Phase 17): a one-time handoff code from a deep
1859
+ // link. Same gates as attach; the code is exchanged only against the
1860
+ // CONFIGURED cloud address, never one the link named.
1861
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1862
+ if (!sameOriginWrite(req))
1863
+ return new Response('cross-origin write rejected', { status: 403 });
1864
+ if (!isLoopbackHost(req.headers.get('host')))
1865
+ return new Response('local request required', { status: 403 });
1866
+ const body = (await req.json().catch(() => ({}))) as { code?: string };
1867
+ return gitJson(await cloudApi.attachCode(String(body.code ?? '')));
1868
+ },
1869
+
1666
1870
  '/_api/github/identity': async (req: Request) => {
1667
1871
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1668
1872
  if (!isLoopbackHost(req.headers.get('host')))
@@ -101,6 +101,16 @@ export interface Inspect {
101
101
  setActive(file: string): void;
102
102
  setOpenTabs(tabs: string[]): void;
103
103
  setSelected(sel: SetSelectedInput): void;
104
+ /**
105
+ * feature-file-tree-drag-drop-folders (Task 3) — a canvas moved server-side
106
+ * (`moveCanvas`). Retarget every reference to the OLD designRel-prefixed
107
+ * file path (`active`, `open_tabs[]`, the active `selected`) to the new
108
+ * one, and re-key the parked `selections` map entry from the old slug to
109
+ * the new one. Returns whether anything actually changed (so the caller
110
+ * can skip an unnecessary `canvas-list-update` detail). Idempotent no-op
111
+ * when the moved canvas wasn't referenced anywhere in this state.
112
+ */
113
+ retarget(fromFile: string, toFile: string): boolean;
104
114
  save(): Promise<void>;
105
115
  injectInspector(html: string): string;
106
116
  }
@@ -313,6 +323,44 @@ export function createInspect(
313
323
  ctx.bus.emit('selected', state.selected);
314
324
  }
315
325
 
326
+ function retarget(fromFile: string, toFile: string): boolean {
327
+ const fromSlug = deriveCanvasSlug(fromFile);
328
+ const toSlug = deriveCanvasSlug(toFile);
329
+ let changed = false;
330
+
331
+ if (state.active === fromFile) {
332
+ state.active = toFile;
333
+ changed = true;
334
+ }
335
+ if (state.open_tabs.includes(fromFile)) {
336
+ state.open_tabs = state.open_tabs.map((t) => (t === fromFile ? toFile : t));
337
+ changed = true;
338
+ }
339
+ if (Object.hasOwn(state.selections, fromSlug)) {
340
+ state.selections[toSlug] = state.selections[fromSlug] as SelectedValue;
341
+ delete state.selections[fromSlug];
342
+ changed = true;
343
+ }
344
+ if (state.selected != null) {
345
+ const list = Array.isArray(state.selected) ? state.selected : [state.selected];
346
+ let selChanged = false;
347
+ const next = list.map((e) => {
348
+ if (e.canvas !== fromSlug) return e;
349
+ selChanged = true;
350
+ return { ...e, canvas: toSlug, file: e.file === fromFile ? toFile : e.file };
351
+ });
352
+ if (selChanged) {
353
+ state.selected = Array.isArray(state.selected) ? next : (next[0] ?? null);
354
+ changed = true;
355
+ }
356
+ }
357
+
358
+ if (!changed) return false;
359
+ state.last_change = new Date().toISOString();
360
+ scheduleSave();
361
+ return true;
362
+ }
363
+
316
364
  /**
317
365
  * Canvas slug for v2 selections. Mirrors `canvasSlug()` from locator.ts but
318
366
  * accepts a designRoot-relative `file` path (which is what the iframe reports)
@@ -336,6 +384,7 @@ export function createInspect(
336
384
  setActive,
337
385
  setOpenTabs,
338
386
  setSelected,
387
+ retarget,
339
388
  save,
340
389
  injectInspector,
341
390
  };
@@ -62,6 +62,10 @@ ctx.sharedDoc = /^(1|true|on|yes)$/i.test(process.env.MAUDE_SHARED_DOC ?? '');
62
62
  // synchronously below; the callback only fires at runtime, by which point the
63
63
  // binding is set.
64
64
  let collab: ReturnType<typeof createCollab> | null = null;
65
+ // Forward-declared for the same reason — moveCanvas (feature-file-tree-
66
+ // drag-drop-folders, Task 3) retargets `_active.json` through the live
67
+ // Inspect instance, which is constructed after `api`.
68
+ let inspectHandle: ReturnType<typeof createInspect> | null = null;
65
69
 
66
70
  const api = createApi(ctx, {
67
71
  onCommentsChanged: async (file) => {
@@ -82,9 +86,20 @@ const api = createApi(ctx, {
82
86
  collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg);
83
87
  }
84
88
  },
89
+ // feature-file-tree-drag-drop-folders (Task 3) — moveCanvas's collab guard
90
+ // + `_active.json` retarget, bridged the same forward-declared way as the
91
+ // comments/annotations hooks above.
92
+ isRoomPinned: (slug) => collab?.registry.isPinned(slug) ?? false,
93
+ flushAndDropRoom: async (slug) => {
94
+ if (collab) await collab.registry.forceDrop(slug);
95
+ },
96
+ retargetActive: (fromFile, toFile) => {
97
+ inspectHandle?.retarget(fromFile, toFile);
98
+ },
85
99
  });
86
100
 
87
101
  const inspect = createInspect(ctx, (file) => api.loadCommentsForFile(file));
102
+ inspectHandle = inspect;
88
103
  await inspect.load();
89
104
 
90
105
  collab = createCollab(ctx, api);
@@ -53,6 +53,7 @@ export type SignInFailure =
53
53
  | 'unreachable'
54
54
  | 'not-a-maude-workspace'
55
55
  | 'bad-credentials'
56
+ | 'cloud-identity'
56
57
  | 'rate-limited'
57
58
  | 'save-failed'
58
59
  | 'server-error';
@@ -148,6 +149,20 @@ export async function signInToWorkspace(
148
149
  if (res.status === 429) {
149
150
  return fail(429, 'Too many attempts. Wait a minute and try again.', 'rate-limited');
150
151
  }
152
+ if (res.status === 400) {
153
+ // The ONE refusal that comes with directions (Phase 23 B3): a cloud
154
+ // workspace saying "this door signs in through Maude Cloud" — or a viewer
155
+ // being told what viewing needs. Swallowing it into "try again shortly"
156
+ // turned the most actionable message on this path into a shrug.
157
+ let message = 'The workspace refused the sign-in.';
158
+ try {
159
+ const body = (await res.json()) as { error?: unknown };
160
+ if (typeof body.error === 'string' && body.error) message = body.error;
161
+ } catch {
162
+ /* keep the generic sentence */
163
+ }
164
+ return fail(400, message, 'cloud-identity');
165
+ }
151
166
  if (!res.ok) {
152
167
  return fail(502, 'The workspace refused the sign-in. Try again shortly.', 'server-error');
153
168
  }
@@ -0,0 +1,125 @@
1
+ // canvas-artifacts.ts — the canonical artifact inventory. Pure, no server boot
2
+ // needed: cover inventory completeness, the divergent `_locator.json` slug
3
+ // shape, and relocatedName()'s path math for both a pure directory move and a
4
+ // combined move+rename.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import path from 'node:path';
8
+
9
+ import { canvasArtifacts, locatorKeyFor, relocatedName } from '../canvas-artifacts.ts';
10
+ import type { Paths } from '../context.ts';
11
+
12
+ function fakePaths(repoRoot: string): Paths {
13
+ const designRoot = path.join(repoRoot, '.design');
14
+ return {
15
+ repoRoot,
16
+ designRel: '.design',
17
+ designRoot,
18
+ serverInfoFile: path.join(designRoot, '_server.json'),
19
+ activeFile: path.join(designRoot, '_active.json'),
20
+ commentsDir: path.join(designRoot, '_comments'),
21
+ canvasStateDir: path.join(designRoot, '_canvas-state'),
22
+ historyDir: path.join(designRoot, '_history'),
23
+ tokensUrlRel: '.design/system/colors_and_type.css',
24
+ systemDirRel: 'system',
25
+ };
26
+ }
27
+
28
+ describe('canvasArtifacts', () => {
29
+ const paths = fakePaths('/repo');
30
+
31
+ test('lists the primary + siblings + every slug-keyed sidecar', () => {
32
+ const artifacts = canvasArtifacts({ rel: 'ui/Foo.tsx', paths });
33
+ const abs = artifacts.map((a) => a.abs);
34
+ expect(abs).toContain(path.join(paths.designRoot, 'ui', 'Foo.tsx'));
35
+ expect(abs).toContain(path.join(paths.designRoot, 'ui', 'Foo.meta.json'));
36
+ expect(abs).toContain(path.join(paths.designRoot, 'ui', 'Foo.css'));
37
+ expect(abs).toContain(path.join(paths.designRoot, 'ui', 'Foo.registry.json'));
38
+ const slug = 'ui-foo';
39
+ expect(abs).toContain(path.join(paths.historyDir, slug));
40
+ expect(abs).toContain(path.join(paths.canvasStateDir, `${slug}.json`));
41
+ expect(abs).toContain(path.join(paths.canvasStateDir, `${slug}.view.json`));
42
+ expect(abs).toContain(path.join(paths.commentsDir, `${slug}.json`));
43
+ expect(abs).toContain(path.join(paths.designRoot, '_state', `${slug}.ydoc.bin`));
44
+ expect(abs).toContain(path.join(paths.designRoot, `${slug}.annotations.svg`));
45
+ expect(abs).toContain(path.join(paths.designRoot, `${slug}.edl.json`));
46
+ });
47
+
48
+ test('does NOT include _photo/ or _draw/ (asset-keyed / mark-slug-keyed, not canvas-slug-keyed)', () => {
49
+ const abs = canvasArtifacts({ rel: 'ui/Foo.tsx', paths }).map((a) => a.abs);
50
+ expect(abs.some((p) => p.includes('_photo'))).toBe(false);
51
+ expect(abs.some((p) => p.includes('_draw'))).toBe(false);
52
+ });
53
+
54
+ test('marks exactly one primary and the rest sibling/slug-keyed', () => {
55
+ const artifacts = canvasArtifacts({ rel: 'ui/Foo.tsx', paths });
56
+ const primaries = artifacts.filter((a) => a.kind === 'primary');
57
+ expect(primaries.length).toBe(1);
58
+ expect(primaries[0]?.abs).toBe(path.join(paths.designRoot, 'ui', 'Foo.tsx'));
59
+ });
60
+
61
+ test('handles a nested rel (subfolder)', () => {
62
+ const artifacts = canvasArtifacts({ rel: 'ui/sub/Bar.tsx', paths });
63
+ const primary = artifacts.find((a) => a.kind === 'primary');
64
+ expect(primary?.abs).toBe(path.join(paths.designRoot, 'ui', 'sub', 'Bar.tsx'));
65
+ const slug = 'ui-sub-bar';
66
+ expect(artifacts.some((a) => a.abs === path.join(paths.historyDir, slug))).toBe(true);
67
+ });
68
+ });
69
+
70
+ describe('locatorKeyFor — the DIVERGENT slug shape', () => {
71
+ test('posix, extension-less, NOT lowercased or dash-flattened', () => {
72
+ expect(locatorKeyFor('ui/Onboarding Brief.tsx')).toBe('ui/Onboarding Brief');
73
+ });
74
+
75
+ test('differs from canvasSlugFromRel for a mixed-case / spaced name', () => {
76
+ // canvasSlugFromRel('ui/Onboarding Brief.tsx', ...) => 'ui-onboarding_brief'
77
+ expect(locatorKeyFor('ui/Onboarding Brief.tsx')).not.toBe('ui-onboarding_brief');
78
+ });
79
+ });
80
+
81
+ describe('relocatedName', () => {
82
+ const paths = fakePaths('/repo');
83
+
84
+ test('pure directory move (basename unchanged) — siblings track the new dir', () => {
85
+ const artifacts = canvasArtifacts({ rel: 'ui/Foo.tsx', paths });
86
+ const primary = artifacts.find((a) => a.kind === 'primary');
87
+ if (!primary) throw new Error('no primary');
88
+ expect(relocatedName(primary, 'ui/Foo.tsx', 'ui/sub/Foo.tsx', paths)).toBe(
89
+ path.join(paths.designRoot, 'ui', 'sub', 'Foo.tsx')
90
+ );
91
+ const meta = artifacts.find((a) => a.abs.endsWith('Foo.meta.json'));
92
+ if (!meta) throw new Error('no meta sibling');
93
+ expect(relocatedName(meta, 'ui/Foo.tsx', 'ui/sub/Foo.tsx', paths)).toBe(
94
+ path.join(paths.designRoot, 'ui', 'sub', 'Foo.meta.json')
95
+ );
96
+ });
97
+
98
+ test('slug-keyed sidecars stay in their fixed dir but re-key the filename', () => {
99
+ const artifacts = canvasArtifacts({ rel: 'ui/Foo.tsx', paths });
100
+ const history = artifacts.find((a) => a.abs === path.join(paths.historyDir, 'ui-foo'));
101
+ if (!history) throw new Error('no history artifact');
102
+ expect(relocatedName(history, 'ui/Foo.tsx', 'ui/sub/Foo.tsx', paths)).toBe(
103
+ path.join(paths.historyDir, 'ui-sub-foo')
104
+ );
105
+ const view = artifacts.find((a) => a.abs.endsWith('ui-foo.view.json'));
106
+ if (!view) throw new Error('no view artifact');
107
+ expect(relocatedName(view, 'ui/Foo.tsx', 'ui/sub/Foo.tsx', paths)).toBe(
108
+ path.join(paths.canvasStateDir, 'ui-sub-foo.view.json')
109
+ );
110
+ });
111
+
112
+ test('a rename (same dir, new basename) re-keys both the sibling and the slug-keyed sidecar', () => {
113
+ const artifacts = canvasArtifacts({ rel: 'ui/Foo.tsx', paths });
114
+ const primary = artifacts.find((a) => a.kind === 'primary');
115
+ if (!primary) throw new Error('no primary');
116
+ expect(relocatedName(primary, 'ui/Foo.tsx', 'ui/Bar.tsx', paths)).toBe(
117
+ path.join(paths.designRoot, 'ui', 'Bar.tsx')
118
+ );
119
+ const comments = artifacts.find((a) => a.abs === path.join(paths.commentsDir, 'ui-foo.json'));
120
+ if (!comments) throw new Error('no comments artifact');
121
+ expect(relocatedName(comments, 'ui/Foo.tsx', 'ui/Bar.tsx', paths)).toBe(
122
+ path.join(paths.commentsDir, 'ui-bar.json')
123
+ );
124
+ });
125
+ });