@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
@@ -0,0 +1,130 @@
1
+ // The dev-server's Maude Cloud lane — Cloud Phase 23 C3 + the maude:// code
2
+ // attach (Phase 17). The control plane AND the cell are stubbed on one local
3
+ // server; the assertions are about custody and trust posture, not transport:
4
+ // the code is exchanged only against the CONFIGURED cloud address, the hub
5
+ // token lands in the credential store, and linkedHub is written token-free.
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
8
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { parseDeepLink, shareViewUrl } from '../client/panels/CloudBar.jsx';
13
+
14
+ let stub: ReturnType<typeof Bun.serve> | null = null;
15
+ let scratch = '';
16
+ const seen: string[] = [];
17
+
18
+ beforeAll(() => {
19
+ scratch = mkdtempSync(join(tmpdir(), 'maude-cloud-test-'));
20
+ stub = Bun.serve({
21
+ port: 0,
22
+ hostname: '127.0.0.1',
23
+ fetch: async (req) => {
24
+ const url = new URL(req.url);
25
+ seen.push(`${req.method} ${url.pathname}`);
26
+ const json = (b: unknown, status = 200) =>
27
+ new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
28
+ if (url.pathname === '/auth/handoff/exchange') {
29
+ const body: any = await req.json();
30
+ if (body.code === 'mhc_' + 'a'.repeat(64)) {
31
+ return json({
32
+ token: 'x.y',
33
+ role: 'owner',
34
+ project: 'stub-project',
35
+ // The "cell" is this same stub server.
36
+ url: `http://127.0.0.1:${stub!.port}`,
37
+ expiresAt: Date.now() + 3600_000,
38
+ });
39
+ }
40
+ return json({ error: 'that code is not valid or has expired' }, 400);
41
+ }
42
+ if (url.pathname === '/auth/login') {
43
+ const body: any = await req.json();
44
+ return body?.token === 'x.y'
45
+ ? json({ token: 'mau_stub_hub_token', user: { email: 'e@example.com', role: 'owner' } })
46
+ : json({ error: 'no' }, 401);
47
+ }
48
+ return json({ error: 'not stubbed' }, 404);
49
+ },
50
+ });
51
+ process.env.MAUDE_CLOUD_URL = `http://127.0.0.1:${stub.port}`;
52
+ process.env.MAUDE_CLOUD_CONFIG = join(scratch, 'cloud.json');
53
+ process.env.HUBS_CONFIG_PATH = join(scratch, 'hubs.json');
54
+ });
55
+
56
+ afterAll(() => {
57
+ stub?.stop(true);
58
+ rmSync(scratch, { recursive: true, force: true });
59
+ delete process.env.MAUDE_CLOUD_URL;
60
+ delete process.env.MAUDE_CLOUD_CONFIG;
61
+ delete process.env.HUBS_CONFIG_PATH;
62
+ });
63
+
64
+ describe('parseDeepLink — untrusted input, strict shape', () => {
65
+ test('accepts exactly the minted shape', () => {
66
+ const p = parseDeepLink(`maude://open/alligators?code=mhc_${'a'.repeat(64)}&origin=https://cloud.maude.sh`);
67
+ expect(p).toEqual({ project: 'alligators', code: `mhc_${'a'.repeat(64)}` });
68
+ });
69
+ test('refuses everything else', () => {
70
+ for (const bad of [
71
+ 'maude://open/alligators', // no code
72
+ 'maude://open/Alligators?code=mhc_' + 'a'.repeat(64), // bad slug
73
+ 'maude://open/x?code=nothex', // bad code
74
+ 'https://evil.example/?code=mhc_' + 'a'.repeat(64), // not maude://
75
+ 'maude://join/x/y', // join is not the open shape
76
+ ]) {
77
+ expect(parseDeepLink(bad)).toBeNull();
78
+ }
79
+ });
80
+ });
81
+
82
+ describe('shareViewUrl — the viewer’s browser home', () => {
83
+ test('derives view-<host> from the project url', () => {
84
+ expect(shareViewUrl('https://alligators.cloud.maude.sh', 'alligators')).toBe(
85
+ 'https://view-alligators.cloud.maude.sh'
86
+ );
87
+ });
88
+ test('falls back to the platform zone on garbage', () => {
89
+ expect(shareViewUrl('nonsense', 'p1')).toBe('https://view-p1.cloud.maude.sh');
90
+ });
91
+ });
92
+
93
+ describe('attachCode — the maude:// lane end to end (stubbed)', () => {
94
+ test('a valid code links: hub credential stored, linkedHub written token-free', async () => {
95
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
96
+ const designRoot = join(scratch, '.design');
97
+ const { mkdirSync } = await import('node:fs');
98
+ mkdirSync(designRoot, { recursive: true });
99
+ writeFileSync(join(designRoot, 'config.json'), '{}\n');
100
+
101
+ const apiEndpoints = createCloudEndpoints({ paths: { repoRoot: scratch, designRoot } });
102
+ const r = await apiEndpoints.attachCode(`mhc_${'a'.repeat(64)}`);
103
+ expect(r.status).toBe(200);
104
+ expect((r.json as any).ok).toBe(true);
105
+ expect((r.json as any).project).toBe('stub-project');
106
+
107
+ const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
108
+ expect(cfg.linkedHub?.url).toContain('127.0.0.1');
109
+ expect(JSON.stringify(cfg)).not.toContain('mau_stub_hub_token');
110
+
111
+ // The exchange went to the CONFIGURED cloud address (this stub) — the
112
+ // only /auth/handoff/exchange call seen is ours.
113
+ expect(seen.filter((s) => s.includes('/auth/handoff/exchange')).length).toBe(1);
114
+ });
115
+
116
+ test('a malformed or dead code never reaches the network', async () => {
117
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
118
+ const apiEndpoints = createCloudEndpoints({
119
+ paths: { repoRoot: scratch, designRoot: join(scratch, '.design') },
120
+ });
121
+ const before = seen.length;
122
+ const r = await apiEndpoints.attachCode('not-a-code');
123
+ expect(r.status).toBe(400);
124
+ expect(seen.length).toBe(before);
125
+
126
+ const dead = await apiEndpoints.attachCode(`mhc_${'b'.repeat(64)}`);
127
+ expect(dead.status).toBe(410);
128
+ expect((dead.json as any).error).toMatch(/works once/);
129
+ });
130
+ });
@@ -0,0 +1,105 @@
1
+ // debug-bundle scrubber + ring — feature-bug-report-button.
2
+ //
3
+ // The scrubber is a PRIVACY GATE, not tidiness: docs/report-schema.md promises
4
+ // every log line is deterministically cleaned server-side before it can enter
5
+ // a report. Extend these tests FIRST when extending the scrubber.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import {
10
+ buildDebugBundle,
11
+ installLogRing,
12
+ logRingLines,
13
+ recordLogLine,
14
+ scrub,
15
+ } from '../debug-bundle.ts';
16
+
17
+ describe('scrub()', () => {
18
+ test('repo-absolute paths collapse to <project>/ BEFORE home collapses to ~', () => {
19
+ const out = scrub('error at /Users/jane/git/acme/src/app.ts and /Users/jane/notes.txt', {
20
+ home: '/Users/jane',
21
+ repoRoot: '/Users/jane/git/acme',
22
+ });
23
+ expect(out).toBe('error at <project>/src/app.ts and ~/notes.txt');
24
+ });
25
+
26
+ test('GitHub tokens (classic + fine-grained) are redacted', () => {
27
+ const out = scrub('using ghp_AbC123xyzAbC123xyz and github_pat_11ABCDEF0abcdef', {
28
+ home: '/h',
29
+ });
30
+ expect(out).not.toContain('ghp_');
31
+ expect(out).not.toContain('github_pat_');
32
+ expect(out).toContain('[redacted]');
33
+ });
34
+
35
+ test('bearer headers, sk- keys and JWTs are redacted', () => {
36
+ const out = scrub(
37
+ 'sent Bearer abc.def.ghi then sk-ant-api03-longsecretvalue then eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig-part',
38
+ { home: '/h' }
39
+ );
40
+ expect(out).toContain('Bearer [redacted]');
41
+ expect(out).not.toContain('sk-ant-api03');
42
+ expect(out).not.toContain('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0');
43
+ // An `Authorization:` header form over-redacts (key rule wins) — that is
44
+ // acceptable: privacy failures here are leaks, never extra redaction.
45
+ const hdr = scrub('Authorization: Bearer abc.def.ghi', { home: '/h' });
46
+ expect(hdr).not.toContain('abc.def.ghi');
47
+ });
48
+
49
+ test('secret-shaped key=value keeps the key, redacts the value', () => {
50
+ const out = scrub('token=abc123secret api_key: "xyz789" password=hunter2', { home: '/h' });
51
+ expect(out).toContain('token=[redacted]');
52
+ expect(out).toContain('password=[redacted]');
53
+ expect(out).not.toContain('abc123secret');
54
+ expect(out).not.toContain('hunter2');
55
+ });
56
+
57
+ test('emails are redacted', () => {
58
+ expect(scrub('reported by jane.doe+test@example.co.uk', { home: '/h' })).toBe(
59
+ 'reported by [email]'
60
+ );
61
+ });
62
+
63
+ test('clean text passes through untouched', () => {
64
+ const line = '[log] canvas ui/Dashboard.tsx rebuilt in 43ms';
65
+ expect(scrub(line, { home: '/h', repoRoot: '/r' })).toBe(line);
66
+ });
67
+ });
68
+
69
+ describe('log ring', () => {
70
+ test('caps at 500 lines, keeps the newest, tail selects the last N', () => {
71
+ for (let i = 0; i < 620; i++) recordLogLine(`line ${i}`);
72
+ const all = logRingLines(500);
73
+ expect(all.length).toBe(500);
74
+ expect(all[0]).toBe('line 120');
75
+ expect(all[all.length - 1]).toBe('line 619');
76
+ expect(logRingLines(3)).toEqual(['line 617', 'line 618', 'line 619']);
77
+ });
78
+
79
+ test('console tap mirrors into the ring and is idempotent', () => {
80
+ installLogRing();
81
+ installLogRing(); // second call must not double-wrap (no duplicate lines)
82
+ console.log('tap-check', { a: 1 });
83
+ const tail = logRingLines(5);
84
+ expect(tail.filter((l) => l.includes('tap-check')).length).toBe(1);
85
+ expect(tail.some((l) => l.includes('[log] tap-check {"a":1}'))).toBe(true);
86
+ });
87
+ });
88
+
89
+ describe('buildDebugBundle()', () => {
90
+ test('assembles scrubbed fields; canvas path scrubbed; no content leaks', () => {
91
+ recordLogLine('boot at /Users/jane/git/acme with token=verysecret');
92
+ const bundle = buildDebugBundle({
93
+ maudeVersion: '9.9.9',
94
+ projectName: 'acme',
95
+ activeCanvas: 'ui/Dashboard.tsx',
96
+ repoRoot: '/Users/jane/git/acme',
97
+ });
98
+ expect(bundle.app.maudeVersion).toBe('9.9.9');
99
+ expect(bundle.app.platform).toBe(process.platform);
100
+ expect(bundle.context.activeCanvas).toBe('ui/Dashboard.tsx');
101
+ expect(bundle.logs.serverLogTail).toContain('<project>');
102
+ expect(bundle.logs.serverLogTail).not.toContain('verysecret');
103
+ expect(bundle.logs.serverLogTail).not.toContain('/Users/jane/git/acme');
104
+ });
105
+ });
@@ -0,0 +1,248 @@
1
+ // /_api/fs-mkdir — feature-file-tree-drag-drop-folders (Task 4). Folder-name
2
+ // validation, containment, `.gitkeep`, collision.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+ import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ import { validateFolderName } from '../canvas-create.ts';
9
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
10
+
11
+ describe('validateFolderName', () => {
12
+ test('accepts ordinary names (incl. spaces + accents)', () => {
13
+ for (const n of ['Marketing', 'checkout-flow', 'Krok_1', 'Přihlášení']) {
14
+ const v = validateFolderName(n);
15
+ expect(v.ok).toBe(true);
16
+ expect(v.name).toBe(n);
17
+ }
18
+ });
19
+
20
+ const REJECT: Array<[string, unknown]> = [
21
+ ['empty', ''],
22
+ ['non-string', 123],
23
+ ['parent traversal', '../evil'],
24
+ ['forward slash', 'a/b'],
25
+ ['back slash', 'a\\b'],
26
+ ['leading dot', '.hidden'],
27
+ ['JSX break-out chars', 'a<b>'],
28
+ ['double quote', 'a"b'],
29
+ ['too long (61)', 'x'.repeat(61)],
30
+ ];
31
+ for (const [label, value] of REJECT) {
32
+ test(`rejects ${label}`, () => {
33
+ expect(validateFolderName(value).ok).toBe(false);
34
+ });
35
+ }
36
+ });
37
+
38
+ function mkdirReq(port: number, parent: string | undefined, name: string) {
39
+ return fetch(`http://localhost:${port}/_api/fs-mkdir`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ parent, name }),
43
+ });
44
+ }
45
+
46
+ describe('/_api/fs-mkdir — POST round-trip', () => {
47
+ test('creates an empty folder with a .gitkeep', async () => {
48
+ const { root, designRoot } = makeSandbox();
49
+ const port = nextPort();
50
+ const proc = await bootServer(root, port);
51
+ try {
52
+ const r = await mkdirReq(port, 'ui', 'New Folder');
53
+ expect(r.status).toBe(201);
54
+ const j = (await r.json()) as { ok: boolean; dir: string };
55
+ expect(j.ok).toBe(true);
56
+ expect(j.dir).toBe('.design/ui/New Folder');
57
+ expect(existsSync(join(designRoot, 'ui', 'New Folder'))).toBe(true);
58
+ expect(existsSync(join(designRoot, 'ui', 'New Folder', '.gitkeep'))).toBe(true);
59
+ expect(readFileSync(join(designRoot, 'ui', 'New Folder', '.gitkeep'), 'utf8')).toBe('');
60
+ } finally {
61
+ await killProc(proc);
62
+ }
63
+ });
64
+
65
+ test('defaults parent to the design root when omitted', async () => {
66
+ const { root } = makeSandbox();
67
+ const port = nextPort();
68
+ const proc = await bootServer(root, port);
69
+ try {
70
+ // "ui" IS a managed canvas group, so a folder directly under it works;
71
+ // an omitted parent (designRoot itself) is NOT inside any canvas group
72
+ // and must be refused — mirrors moveCanvas's group-membership rule.
73
+ const r = await mkdirReq(port, undefined, 'Root Folder');
74
+ expect(r.status).toBe(400);
75
+ } finally {
76
+ await killProc(proc);
77
+ }
78
+ });
79
+
80
+ test('supports nested parents', async () => {
81
+ const { root, designRoot } = makeSandbox();
82
+ const port = nextPort();
83
+ const proc = await bootServer(root, port);
84
+ try {
85
+ await mkdirReq(port, 'ui', 'Parent');
86
+ const r = await mkdirReq(port, 'ui/Parent', 'Child');
87
+ expect(r.status).toBe(201);
88
+ expect(existsSync(join(designRoot, 'ui', 'Parent', 'Child', '.gitkeep'))).toBe(true);
89
+ } finally {
90
+ await killProc(proc);
91
+ }
92
+ });
93
+
94
+ test('refuses a parent that escapes the design root (400)', async () => {
95
+ const { root } = makeSandbox();
96
+ const port = nextPort();
97
+ const proc = await bootServer(root, port);
98
+ try {
99
+ const r = await mkdirReq(port, '../../../../tmp', 'Escape');
100
+ expect(r.status).toBe(400);
101
+ } finally {
102
+ await killProc(proc);
103
+ }
104
+ });
105
+
106
+ test('refuses a parent inside the design-system group (400)', async () => {
107
+ const { root, designRoot } = makeSandbox();
108
+ const port = nextPort();
109
+ const proc = await bootServer(root, port);
110
+ try {
111
+ mkdirSync(join(designRoot, 'system'), { recursive: true });
112
+ const r = await mkdirReq(port, 'system', 'NotAllowed');
113
+ expect(r.status).toBe(400);
114
+ } finally {
115
+ await killProc(proc);
116
+ }
117
+ });
118
+
119
+ test('rejects an invalid name (400)', async () => {
120
+ const { root } = makeSandbox();
121
+ const port = nextPort();
122
+ const proc = await bootServer(root, port);
123
+ try {
124
+ const r = await mkdirReq(port, 'ui', '../evil');
125
+ expect(r.status).toBe(400);
126
+ } finally {
127
+ await killProc(proc);
128
+ }
129
+ });
130
+
131
+ test('a collision with an existing folder returns 409', async () => {
132
+ const { root, designRoot } = makeSandbox();
133
+ const port = nextPort();
134
+ const proc = await bootServer(root, port);
135
+ try {
136
+ mkdirSync(join(designRoot, 'ui', 'Already'), { recursive: true });
137
+ const r = await mkdirReq(port, 'ui', 'Already');
138
+ expect(r.status).toBe(409);
139
+ } finally {
140
+ await killProc(proc);
141
+ }
142
+ });
143
+
144
+ test('a collision with an existing FILE (not just a dir) also returns 409', async () => {
145
+ const { root, designRoot } = makeSandbox();
146
+ const port = nextPort();
147
+ const proc = await bootServer(root, port);
148
+ try {
149
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
150
+ // NAME_RE forbids '.', so an extension-less file is the only way a
151
+ // FILE can collide with a valid folder name — statp() must catch it
152
+ // the same as a directory collision above.
153
+ writeFileSync(join(designRoot, 'ui', 'Taken'), 'not a folder');
154
+ const r = await mkdirReq(port, 'ui', 'Taken');
155
+ expect(r.status).toBe(409);
156
+ } finally {
157
+ await killProc(proc);
158
+ }
159
+ });
160
+
161
+ test('emits a canvas-list-update "mkdir" action over the inspector WS', async () => {
162
+ const { root } = makeSandbox();
163
+ const port = nextPort();
164
+ const proc = await bootServer(root, port);
165
+ let socket: WebSocket | null = null;
166
+ try {
167
+ const messages: Array<{ type: string; payload?: Record<string, unknown> }> = [];
168
+ const sock = new WebSocket(`ws://localhost:${port}/_ws`);
169
+ socket = sock;
170
+ await new Promise<void>((res, rej) => {
171
+ sock.addEventListener('open', () => res());
172
+ sock.addEventListener('error', () => rej(new Error('ws error')));
173
+ setTimeout(() => rej(new Error('ws open timeout')), 2000);
174
+ });
175
+ sock.addEventListener('message', (ev) => {
176
+ try {
177
+ messages.push(JSON.parse(String(ev.data)));
178
+ } catch {
179
+ /* ignore */
180
+ }
181
+ });
182
+ const r = await mkdirReq(port, 'ui', 'Live Folder');
183
+ expect(r.status).toBe(201);
184
+
185
+ const deadline = Date.now() + 2000;
186
+ let hit: (typeof messages)[number] | undefined;
187
+ while (Date.now() < deadline && !hit) {
188
+ hit = messages.find(
189
+ (m) => m.type === 'canvas-list-update' && m.payload?.action === 'mkdir'
190
+ );
191
+ if (!hit) await Bun.sleep(25);
192
+ }
193
+ expect(hit?.payload?.dir).toBe('.design/ui/Live Folder');
194
+ } finally {
195
+ try {
196
+ socket?.close();
197
+ } catch {
198
+ /* ignore */
199
+ }
200
+ await killProc(proc);
201
+ }
202
+ }, 10_000);
203
+
204
+ test('unknown method (GET) returns 405', async () => {
205
+ const { root } = makeSandbox();
206
+ const port = nextPort();
207
+ const proc = await bootServer(root, port);
208
+ try {
209
+ const r = await fetch(`http://localhost:${port}/_api/fs-mkdir`, { method: 'GET' });
210
+ expect(r.status).toBe(405);
211
+ } finally {
212
+ await killProc(proc);
213
+ }
214
+ });
215
+
216
+ test('rejects a cross-origin mkdir (CSRF guard)', async () => {
217
+ const { root } = makeSandbox();
218
+ const port = nextPort();
219
+ const proc = await bootServer(root, port);
220
+ try {
221
+ const r = await fetch(`http://localhost:${port}/_api/fs-mkdir`, {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
224
+ body: JSON.stringify({ parent: 'ui', name: 'Forged' }),
225
+ });
226
+ expect(r.status).toBe(403);
227
+ } finally {
228
+ await killProc(proc);
229
+ }
230
+ });
231
+
232
+ // Security review finding — see the matching test in canvas-move-api.test.ts.
233
+ test('refuses creating a folder under a symlinked parent', async () => {
234
+ const { root, designRoot } = makeSandbox();
235
+ const outside = join(root, 'OUTSIDE');
236
+ mkdirSync(outside, { recursive: true });
237
+ const port = nextPort();
238
+ const proc = await bootServer(root, port);
239
+ try {
240
+ symlinkSync(outside, join(designRoot, 'ui', 'link'));
241
+ const r = await mkdirReq(port, 'ui/link', 'Nested');
242
+ expect(r.status).toBe(400);
243
+ expect(existsSync(join(outside, 'Nested'))).toBe(false);
244
+ } finally {
245
+ await killProc(proc);
246
+ }
247
+ });
248
+ });
@@ -164,6 +164,25 @@ describe('failure modes are distinguishable and actionable', () => {
164
164
  expect((res.json as { error: string }).error).toMatch(/wait a minute/i);
165
165
  });
166
166
 
167
+ test("a cloud workspace's 400 message reaches the person verbatim (Phase 23 B3)", async () => {
168
+ // The hub's cloud-identity refusal comes WITH directions ("sign in through
169
+ // Maude Cloud" / what viewing needs). Swallowing it into "try again
170
+ // shortly" turned the most actionable message on this path into a shrug.
171
+ const { impl } = scriptedFetch({
172
+ '/health': HEALTHY,
173
+ '/auth/login': () =>
174
+ json(
175
+ { error: 'This workspace signs in through Maude Cloud — use Sign in to Maude Cloud in the app.' },
176
+ 400
177
+ ),
178
+ });
179
+ const res = await signInToWorkspace(CREDS, { fetchImpl: impl, save: () => {} });
180
+ expect(res.status).toBe(400);
181
+ expect((res.json as { reason: string }).reason).toBe('cloud-identity');
182
+ expect((res.json as { error: string }).error).toMatch(/signs in through Maude Cloud/);
183
+ expect((res.json as { error: string }).error).not.toMatch(/try again shortly/i);
184
+ });
185
+
167
186
  test('a failed save is reported instead of pretending success', async () => {
168
187
  const { impl } = scriptedFetch({ '/health': HEALTHY, '/auth/login': LOGGED_IN });
169
188
  const res = await signInToWorkspace(CREDS, {
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "maude-deeplink-open-in-app",
6
+ "version": "0.51.0",
7
+ "date": "2026-07-30",
8
+ "kind": "feature",
9
+ "title": "Open a cloud project straight from the browser",
10
+ "summary": "Press “Open in Maude” on your project’s page and the app opens it signed in — a one-time link, nothing to copy. The app always asks before connecting, and viewers get a working “View in the browser” door instead of a dead button.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "maude-cloud-signin-attach",
15
+ "version": "0.50.0",
16
+ "date": "2026-07-30",
17
+ "kind": "feature",
18
+ "title": "Sign in to Maude Cloud from the studio",
19
+ "summary": "The sidebar footer now signs you into Maude Cloud with a short code — no pasted addresses or keys — and connects this project to your cloud workspace in one click.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "file-tree-move-and-folders",
24
+ "version": "0.50.0",
25
+ "date": "2026-07-30",
26
+ "kind": "feature",
27
+ "title": "Organize your files — folders, drag & drop, move",
28
+ "summary": "The file tree is a real file explorer now. Create folders from the header or right-click a canvas or folder for Move to… / New folder here / Delete folder — or just drag a canvas (or a whole folder) onto another folder, including back out to the top level. Every history, comment, annotation, and camera position moves with it, and every move shows an Undo toast.",
29
+ "surface": "design-ui"
30
+ },
4
31
  {
5
32
  "id": "figma-smart-select",
6
33
  "version": "0.47.0",
package/cli/bin/maude.mjs CHANGED
@@ -21,6 +21,7 @@ const COMMANDS = {
21
21
  kg: () => import('../commands/kg.mjs'),
22
22
  help: () => import('../commands/help.mjs'),
23
23
  hub: () => import('../commands/hub.mjs'),
24
+ share: () => import('../commands/share.mjs'),
24
25
  version: () => import('../commands/version.mjs'),
25
26
  };
26
27