@1agh/maude 0.49.1 → 0.50.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 (34) 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 +284 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +26 -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 +236 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/dist/client.bundle.js +960 -958
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/http.ts +113 -0
  15. package/apps/studio/inspect.ts +49 -0
  16. package/apps/studio/server.ts +15 -0
  17. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  18. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  19. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  20. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  21. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  22. package/apps/studio/whats-new.json +18 -0
  23. package/cli/bin/maude.mjs +1 -0
  24. package/cli/commands/hub-workspace.mjs +294 -7
  25. package/cli/commands/init.mjs +33 -2
  26. package/cli/commands/kg.mjs +129 -4
  27. package/cli/commands/share.mjs +184 -0
  28. package/cli/lib/ddr-to-kgai.mjs +310 -82
  29. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  30. package/cli/lib/share-plan.mjs +96 -0
  31. package/cli/lib/share-plan.test.mjs +57 -0
  32. package/cli/lib/workspace-plan.mjs +130 -12
  33. package/cli/lib/workspace-plan.test.mjs +160 -0
  34. package/package.json +8 -8
@@ -77,6 +77,7 @@ import {
77
77
  } from './generation/whisper-models.ts';
78
78
  import { createGitEndpoints } from './git/endpoints.ts';
79
79
  import { gitShowFile } from './git/service.ts';
80
+ import { createCloudEndpoints } from './cloud/endpoints.ts';
80
81
  import { createGitHubEndpoints } from './github/endpoints.ts';
81
82
  import type { Inspect } from './inspect.ts';
82
83
  import { canvasSlug, writeLocator } from './locator.ts';
@@ -837,6 +838,11 @@ export function createHttp(
837
838
  // and every route is token-bearing (server-held keychain token via the loopback
838
839
  // bridge), so all four also carry a loopback-Host check.
839
840
  const githubApi = createGitHubEndpoints(ctx);
841
+ // Cloud Phase 23 C3 — `/_api/cloud/*`. Same dual-allowlist rule as the
842
+ // GitHub routes: MAIN-ORIGIN ONLY (absent from CANVAS_SAFE_API +
843
+ // startCanvasServer) and loopback-Host gated — every route either bears or
844
+ // stores the cloud credential.
845
+ const cloudApi = createCloudEndpoints(ctx);
840
846
  const gitJson = (r: { status: number; json: unknown }) =>
841
847
  Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
842
848
 
@@ -1515,6 +1521,66 @@ export function createHttp(
1515
1521
  );
1516
1522
  },
1517
1523
 
1524
+ // feature-file-tree-drag-drop-folders (Task 5) — move/rename a canvas.
1525
+ // POST body { file, toDir } -> 200 { fromRel, toRel, fromSlug, toSlug, moved[] }
1526
+ // Same guard stack as /_api/canvas, copied verbatim: MAIN ORIGIN ONLY —
1527
+ // intentionally absent from BOTH startCanvasServer's allowlist and
1528
+ // CANVAS_SAFE_API (DDR-054/DDR-088) — the untrusted canvas iframe origin
1529
+ // must never reach a file-move endpoint. Validation lives in
1530
+ // api.moveCanvas (containment + non-DS-group allowlist + collision +
1531
+ // collab-pin guard).
1532
+ '/_api/fs-move': async (req: Request) => {
1533
+ if (!sameOriginWrite(req))
1534
+ return new Response('cross-origin write rejected', { status: 403 });
1535
+ if (!isLoopbackHost(req.headers.get('host')))
1536
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1537
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1538
+ const body = await readJson<{ file?: unknown; toDir?: unknown }>(req, 4 * 1024);
1539
+ if (!body) return new Response('body required', { status: 400 });
1540
+ const result = await api.moveCanvas(body);
1541
+ if (!result.ok) {
1542
+ return Response.json(
1543
+ { ok: false, error: result.error },
1544
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1545
+ );
1546
+ }
1547
+ return Response.json(
1548
+ {
1549
+ ok: true,
1550
+ fromRel: result.fromRel,
1551
+ toRel: result.toRel,
1552
+ fromSlug: result.fromSlug,
1553
+ toSlug: result.toSlug,
1554
+ moved: result.moved,
1555
+ },
1556
+ { status: 200, headers: { 'Cache-Control': 'no-store' } }
1557
+ );
1558
+ },
1559
+
1560
+ // feature-file-tree-drag-drop-folders (Task 5) — create an empty folder.
1561
+ // POST body { parent?, name } -> 201 { dir }. Same guard stack, same
1562
+ // MAIN-ORIGIN-ONLY / dual-allowlist posture as /_api/fs-move above.
1563
+ '/_api/fs-mkdir': async (req: Request) => {
1564
+ if (!sameOriginWrite(req))
1565
+ return new Response('cross-origin write rejected', { status: 403 });
1566
+ if (!isLoopbackHost(req.headers.get('host')))
1567
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1568
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1569
+ const body = await readJson<{ parent?: unknown; name?: unknown }>(req, 4 * 1024);
1570
+ if (!body) return new Response('body required', { status: 400 });
1571
+ const result = await api.createFolder(body);
1572
+ if (!result.ok) {
1573
+ return Response.json(
1574
+ { ok: false, error: result.error },
1575
+ { status: result.status, headers: { 'Cache-Control': 'no-store' } }
1576
+ );
1577
+ }
1578
+ return Response.json(
1579
+ { ok: true, dir: result.dir },
1580
+ { status: 201, headers: { 'Cache-Control': 'no-store' } }
1581
+ );
1582
+ },
1583
+
1518
1584
  // ── Phase 27 (E2) — in-UI git layer. Save version / Publish / Get latest /
1519
1585
  // History / visual diff. All MAIN-ORIGIN ONLY (see gitApi comment above).
1520
1586
  // POST routes add the sameOriginWrite CSRF guard (cross-site forged POST);
@@ -1663,6 +1729,53 @@ export function createHttp(
1663
1729
  // since every one is token-bearing. (Sign-out is the `github_sign_out` Tauri
1664
1730
  // command — the dev-server can't touch the OS keychain — so there is no
1665
1731
  // DELETE /_api/github/identity here; see DDR-114.)
1732
+ '/_api/cloud/status': async (req: Request) => {
1733
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1734
+ if (!isLoopbackHost(req.headers.get('host')))
1735
+ return new Response('local request required', { status: 403 });
1736
+ return gitJson(cloudApi.status());
1737
+ },
1738
+ '/_api/cloud/signin/start': async (req: Request) => {
1739
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1740
+ if (!sameOriginWrite(req))
1741
+ return new Response('cross-origin write rejected', { status: 403 });
1742
+ if (!isLoopbackHost(req.headers.get('host')))
1743
+ return new Response('local request required', { status: 403 });
1744
+ return gitJson(await cloudApi.signinStart());
1745
+ },
1746
+ '/_api/cloud/signin/poll': async (req: Request) => {
1747
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1748
+ if (!sameOriginWrite(req))
1749
+ return new Response('cross-origin write rejected', { status: 403 });
1750
+ if (!isLoopbackHost(req.headers.get('host')))
1751
+ return new Response('local request required', { status: 403 });
1752
+ const body = (await req.json().catch(() => ({}))) as { deviceCode?: string };
1753
+ return gitJson(await cloudApi.signinPoll(String(body.deviceCode ?? '')));
1754
+ },
1755
+ '/_api/cloud/signout': async (req: Request) => {
1756
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1757
+ if (!sameOriginWrite(req))
1758
+ return new Response('cross-origin write rejected', { status: 403 });
1759
+ if (!isLoopbackHost(req.headers.get('host')))
1760
+ return new Response('local request required', { status: 403 });
1761
+ return gitJson(cloudApi.signout());
1762
+ },
1763
+ '/_api/cloud/projects': async (req: Request) => {
1764
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1765
+ if (!isLoopbackHost(req.headers.get('host')))
1766
+ return new Response('local request required', { status: 403 });
1767
+ return gitJson(await cloudApi.projects());
1768
+ },
1769
+ '/_api/cloud/attach': async (req: Request) => {
1770
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
1771
+ if (!sameOriginWrite(req))
1772
+ return new Response('cross-origin write rejected', { status: 403 });
1773
+ if (!isLoopbackHost(req.headers.get('host')))
1774
+ return new Response('local request required', { status: 403 });
1775
+ const body = (await req.json().catch(() => ({}))) as { project?: string };
1776
+ return gitJson(await cloudApi.attach(String(body.project ?? '')));
1777
+ },
1778
+
1666
1779
  '/_api/github/identity': async (req: Request) => {
1667
1780
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
1668
1781
  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);
@@ -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
+ });
@@ -7,7 +7,14 @@
7
7
  // covering happy path, the rejection matrix, group allowlist, duplicate.
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ symlinkSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
11
18
  import { join } from 'node:path';
12
19
 
13
20
  import { componentNameFrom, renderBriefBoard, validateCanvasName } from '../canvas-create.ts';
@@ -455,6 +462,36 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
455
462
  }
456
463
  });
457
464
 
465
+ // Task 2 bug fix: deleteCanvas now consumes canvas-artifacts.ts's inventory,
466
+ // which closes two gaps the old hand-rolled sidecar list missed.
467
+ test('also trashes the .ydoc.bin cache and the footage EDL (bug fix — canvas-artifacts.ts inventory)', async () => {
468
+ const { root, designRoot } = makeSandbox();
469
+ const port = nextPort();
470
+ const proc = await bootServer(root, port);
471
+ try {
472
+ const created = await createBoard(port, 'Full Sweep');
473
+ mkdirSync(join(designRoot, '_state'), { recursive: true });
474
+ const ydocAbs = join(designRoot, '_state', `${created.slug}.ydoc.bin`);
475
+ writeFileSync(ydocAbs, Buffer.from([1, 2, 3]));
476
+ const edlAbs = join(designRoot, `${created.slug}.edl.json`);
477
+ writeFileSync(edlAbs, JSON.stringify({ beats: [] }));
478
+
479
+ const r = await del(port, created.file);
480
+ expect(r.status).toBe(200);
481
+ const j = (await r.json()) as { ok: boolean; trashed: string[] };
482
+ expect(j.trashed.some((t) => t.endsWith('.ydoc.bin'))).toBe(true);
483
+ expect(j.trashed.some((t) => t.endsWith('.edl.json'))).toBe(true);
484
+ expect(existsSync(ydocAbs)).toBe(false);
485
+ expect(existsSync(edlAbs)).toBe(false);
486
+ const trashDirs = readdirSync(join(designRoot, '_trash'));
487
+ const td = join(designRoot, '_trash', trashDirs[0] as string);
488
+ expect(existsSync(join(td, `_state__${created.slug}.ydoc.bin`))).toBe(true);
489
+ expect(existsSync(join(td, `${created.slug}.edl.json`))).toBe(true);
490
+ } finally {
491
+ await killProc(proc);
492
+ }
493
+ });
494
+
458
495
  test('refuses to delete a design-system file (400, untouched)', async () => {
459
496
  const { root, designRoot } = makeSandbox();
460
497
  const port = nextPort();
@@ -507,3 +544,124 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
507
544
  }
508
545
  });
509
546
  });
547
+
548
+ // feature-file-tree-drag-drop-folders (dogfood follow-up) — DELETE now
549
+ // auto-detects a non-.tsx target as a folder delete.
550
+ describe('/_api/canvas — DELETE (folder delete, dogfood follow-up)', () => {
551
+ async function createBoard(port: number, name: string) {
552
+ const r = await fetch(`http://localhost:${port}/_api/canvas`, {
553
+ method: 'POST',
554
+ headers: { 'Content-Type': 'application/json' },
555
+ body: JSON.stringify({ name }),
556
+ });
557
+ return (await r.json()) as { file: string; slug: string };
558
+ }
559
+ const del = (port: number, file: string) =>
560
+ fetch(`http://localhost:${port}/_api/canvas?file=${encodeURIComponent(file)}`, {
561
+ method: 'DELETE',
562
+ });
563
+
564
+ test('deletes a folder: trashes every nested canvas, folder itself is gone', async () => {
565
+ const { root, designRoot } = makeSandbox();
566
+ const port = nextPort();
567
+ const proc = await bootServer(root, port);
568
+ try {
569
+ const created = await createBoard(port, 'Inside');
570
+ const moved = await fetch(`http://localhost:${port}/_api/fs-move`, {
571
+ method: 'POST',
572
+ headers: { 'Content-Type': 'application/json' },
573
+ body: JSON.stringify({ file: created.file, toDir: 'ui/Box' }),
574
+ });
575
+ expect(moved.status).toBe(200);
576
+ expect(existsSync(join(designRoot, 'ui', 'Box', 'Inside.tsx'))).toBe(true);
577
+
578
+ const r = await del(port, 'ui/Box');
579
+ expect(r.status).toBe(200);
580
+ const j = (await r.json()) as { ok: boolean; trashed: string[] };
581
+ expect(j.ok).toBe(true);
582
+ expect(j.trashed.some((t) => t.endsWith('Inside.tsx'))).toBe(true);
583
+ expect(existsSync(join(designRoot, 'ui', 'Box'))).toBe(false);
584
+ const trashDirs = readdirSync(join(designRoot, '_trash'));
585
+ expect(trashDirs.length).toBe(1);
586
+ } finally {
587
+ await killProc(proc);
588
+ }
589
+ });
590
+
591
+ test('deletes an EMPTY folder (mkdir + delete, no canvases inside)', async () => {
592
+ const { root, designRoot } = makeSandbox();
593
+ const port = nextPort();
594
+ const proc = await bootServer(root, port);
595
+ try {
596
+ await fetch(`http://localhost:${port}/_api/fs-mkdir`, {
597
+ method: 'POST',
598
+ headers: { 'Content-Type': 'application/json' },
599
+ body: JSON.stringify({ parent: 'ui', name: 'Empty' }),
600
+ });
601
+ expect(existsSync(join(designRoot, 'ui', 'Empty'))).toBe(true);
602
+ const r = await del(port, 'ui/Empty');
603
+ expect(r.status).toBe(200);
604
+ expect(existsSync(join(designRoot, 'ui', 'Empty'))).toBe(false);
605
+ } finally {
606
+ await killProc(proc);
607
+ }
608
+ });
609
+
610
+ test('refuses deleting a canvas GROUP ROOT (400, untouched)', async () => {
611
+ const { root, designRoot } = makeSandbox();
612
+ const port = nextPort();
613
+ const proc = await bootServer(root, port);
614
+ try {
615
+ const r = await del(port, 'ui');
616
+ expect(r.status).toBe(400);
617
+ expect(existsSync(join(designRoot, 'ui'))).toBe(true);
618
+ } finally {
619
+ await killProc(proc);
620
+ }
621
+ });
622
+
623
+ test('refuses deleting a design-system folder (400, untouched)', async () => {
624
+ const { root, designRoot } = makeSandbox();
625
+ const port = nextPort();
626
+ const proc = await bootServer(root, port);
627
+ try {
628
+ mkdirSync(join(designRoot, 'system', 'sub'), { recursive: true });
629
+ const r = await del(port, 'system/sub');
630
+ expect(r.status).toBe(400);
631
+ expect(existsSync(join(designRoot, 'system', 'sub'))).toBe(true);
632
+ } finally {
633
+ await killProc(proc);
634
+ }
635
+ });
636
+
637
+ test('a missing folder returns 404', async () => {
638
+ const { root } = makeSandbox();
639
+ const port = nextPort();
640
+ const proc = await bootServer(root, port);
641
+ try {
642
+ const r = await del(port, 'ui/Nope');
643
+ expect(r.status).toBe(404);
644
+ } finally {
645
+ await killProc(proc);
646
+ }
647
+ });
648
+
649
+ // Security review finding — see the matching tests in canvas-move-api.test.ts
650
+ // and fs-mkdir-api.test.ts.
651
+ test('refuses deleting a symlinked "folder" (would rm -rf outside designRoot)', async () => {
652
+ const { root, designRoot } = makeSandbox();
653
+ const outside = join(root, 'OUTSIDE');
654
+ mkdirSync(outside, { recursive: true });
655
+ writeFileSync(join(outside, 'Secret.txt'), 'do not delete me');
656
+ const port = nextPort();
657
+ const proc = await bootServer(root, port);
658
+ try {
659
+ symlinkSync(outside, join(designRoot, 'ui', 'evil'));
660
+ const r = await del(port, 'ui/evil');
661
+ expect(r.status).toBe(400);
662
+ expect(existsSync(join(outside, 'Secret.txt'))).toBe(true);
663
+ } finally {
664
+ await killProc(proc);
665
+ }
666
+ });
667
+ });