@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.
- 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 +388 -0
- package/apps/studio/client/styles/3-shell-maude.css +28 -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 +284 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/debug-bundle.ts +144 -0
- package/apps/studio/dist/client.bundle.js +1235 -1228
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +205 -1
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/sync/workspace-signin.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/cloud-endpoints.test.ts +130 -0
- package/apps/studio/test/debug-bundle.test.ts +105 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/test/workspace-signin.test.ts +19 -0
- package/apps/studio/whats-new.json +27 -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 +9 -8
package/apps/studio/http.ts
CHANGED
|
@@ -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')))
|
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);
|
|
@@ -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
|
+
});
|