@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.
- package/apps/studio/api.ts +751 -52
- package/apps/studio/canvas-artifacts.ts +153 -0
- package/apps/studio/canvas-create.ts +23 -0
- package/apps/studio/canvas-slug.ts +30 -0
- package/apps/studio/client/app.jsx +547 -90
- package/apps/studio/client/panels/CloudBar.jsx +284 -0
- package/apps/studio/client/styles/3-shell-maude.css +26 -0
- package/apps/studio/client/tree-row-menu.jsx +132 -0
- package/apps/studio/client/use-tree-drag.js +120 -0
- package/apps/studio/cloud/endpoints.ts +236 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/dist/client.bundle.js +960 -958
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +113 -0
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/test/canvas-artifacts.test.ts +125 -0
- package/apps/studio/test/canvas-create-api.test.ts +159 -1
- package/apps/studio/test/canvas-move-api.test.ts +556 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +294 -7
- package/cli/commands/init.mjs +33 -2
- package/cli/commands/kg.mjs +129 -4
- package/cli/commands/share.mjs +184 -0
- package/cli/lib/ddr-to-kgai.mjs +310 -82
- package/cli/lib/ddr-to-kgai.test.mjs +20 -0
- package/cli/lib/share-plan.mjs +96 -0
- package/cli/lib/share-plan.test.mjs +57 -0
- package/cli/lib/workspace-plan.mjs +130 -12
- package/cli/lib/workspace-plan.test.mjs +160 -0
- package/package.json +8 -8
package/apps/studio/http.ts
CHANGED
|
@@ -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')))
|
package/apps/studio/inspect.ts
CHANGED
|
@@ -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
|
};
|
package/apps/studio/server.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|