@1agh/maude 0.22.0 → 0.23.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 (60) hide show
  1. package/cli/commands/design-link.test.mjs +53 -1
  2. package/cli/commands/hub.test.mjs +10 -9
  3. package/cli/lib/design-link.mjs +154 -7
  4. package/cli/lib/hubs-config.mjs +42 -4
  5. package/package.json +9 -9
  6. package/plugins/design/dev-server/bin/check-runtime-bundles.sh +125 -0
  7. package/plugins/design/dev-server/bin/runtime-health.sh +47 -6
  8. package/plugins/design/dev-server/build.ts +87 -7
  9. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  10. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  11. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  12. package/plugins/design/dev-server/client/app.jsx +37 -15
  13. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  14. package/plugins/design/dev-server/collab/registry.ts +51 -0
  15. package/plugins/design/dev-server/config.schema.json +20 -0
  16. package/plugins/design/dev-server/context.ts +7 -0
  17. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  18. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  19. package/plugins/design/dev-server/dist/runtime/.min-sizes.json +16 -0
  20. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +2 -6
  21. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +2 -6
  22. package/plugins/design/dev-server/dist/runtime/motion.js +4787 -8
  23. package/plugins/design/dev-server/dist/runtime/motion_react.js +9654 -7
  24. package/plugins/design/dev-server/dist/runtime/pixi-js.js +5865 -5469
  25. package/plugins/design/dev-server/dist/runtime/react-dom.js +4 -22
  26. package/plugins/design/dev-server/dist/runtime/react-dom_client.js +5 -23
  27. package/plugins/design/dev-server/dist/runtime/react.js +4 -22
  28. package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +4 -22
  29. package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +4 -22
  30. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +2 -6
  31. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +2 -6
  32. package/plugins/design/dev-server/dist/runtime/yjs.js +2 -6
  33. package/plugins/design/dev-server/dom-selection.ts +156 -0
  34. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  35. package/plugins/design/dev-server/input-router.tsx +99 -61
  36. package/plugins/design/dev-server/server.ts +18 -0
  37. package/plugins/design/dev-server/sync/agent.ts +323 -0
  38. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  39. package/plugins/design/dev-server/sync/codec.ts +169 -0
  40. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  41. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  42. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  43. package/plugins/design/dev-server/sync/index.ts +474 -0
  44. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  45. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  46. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  47. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  48. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  49. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  50. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  51. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  52. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  53. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  54. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  55. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  56. package/plugins/design/dev-server/use-collab.tsx +157 -13
  57. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  58. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  59. package/plugins/design/templates/_shell.html +15 -5
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
@@ -6,7 +6,7 @@
6
6
 
7
7
  import assert from 'node:assert/strict';
8
8
  import { spawn } from 'node:child_process';
9
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { createServer } from 'node:http';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { dirname, join, resolve } from 'node:path';
@@ -205,3 +205,55 @@ test('status --json emits structured payload', async () => {
205
205
  assert.equal(payload.sync.agent, 'not-implemented');
206
206
  cleanup();
207
207
  });
208
+
209
+ // --------------------------------------------------- DDR-054 F2/F4 trust gate
210
+
211
+ const REMOTE_URL = 'http://hub.invalid:9999'; // .invalid never resolves (RFC 6761)
212
+
213
+ test('linking a non-loopback hub without --yes (non-TTY) refuses', async () => {
214
+ // The spawned child has no TTY on stdin, so the gate must refuse rather
215
+ // than silently link a remote hub in a script.
216
+ const res = await runCli(['design', 'link', REMOTE_URL, '--token', 'mau_x', '--force']);
217
+ assert.equal(res.status, 1);
218
+ assert.match(res.stderr, /requires confirmation/);
219
+ // No link written.
220
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
221
+ assert.equal(cfg.linkedHub, undefined);
222
+ cleanup();
223
+ });
224
+
225
+ test('linking a non-loopback hub with --yes records trust + links', async () => {
226
+ const res = await runCli(['design', 'link', REMOTE_URL, '--token', 'mau_x', '--yes', '--force']);
227
+ assert.equal(res.status, 0, res.stderr);
228
+ assert.match(res.stderr, /confirmed via --yes/);
229
+ assert.match(res.stderr, /experimental v1\.1 preview/); // F3 banner
230
+
231
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
232
+ assert.equal(cfg.linkedHub.url, REMOTE_URL);
233
+
234
+ // Trust is recorded PER-MACHINE (hubs.json), never in a committable repo file.
235
+ const hubs = JSON.parse(readFileSync(hubsConfigPath, 'utf8'));
236
+ assert.ok(hubs.trusted.includes(REMOTE_URL), 'hub should be trusted on this machine');
237
+ assert.equal(
238
+ existsSync(join(workspace, '.maude/trusted-hubs')),
239
+ false,
240
+ 'no committable trust file'
241
+ );
242
+
243
+ // Re-linking the now-trusted hub no longer needs --yes.
244
+ const second = await runCli(['design', 'link', REMOTE_URL, '--token', 'mau_y', '--force']);
245
+ assert.equal(second.status, 0, second.stderr);
246
+ cleanup();
247
+ });
248
+
249
+ test('--adopt against a non-loopback hub lists the upload manifest in the gate', async () => {
250
+ writeFileSync(join(workspace, '.design/screen.html'), '<button>hi</button>', 'utf8');
251
+ const res = await runCli(['design', 'adopt', REMOTE_URL, '--token', 'mau_x', '--yes', '--force']);
252
+ assert.equal(res.status, 0, res.stderr);
253
+ assert.match(res.stderr, /will UPLOAD 1 local file/);
254
+ assert.match(res.stderr, /\.design\/screen\.html/);
255
+
256
+ const hubs = JSON.parse(readFileSync(hubsConfigPath, 'utf8'));
257
+ assert.equal(typeof hubs.hubs[REMOTE_URL].adoptedAt, 'number'); // F4 attestation
258
+ cleanup();
259
+ });
@@ -3,12 +3,14 @@
3
3
 
4
4
  import assert from 'node:assert/strict';
5
5
  import { spawnSync } from 'node:child_process';
6
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { dirname, join, resolve } from 'node:path';
9
9
  import { test } from 'node:test';
10
10
  import { fileURLToPath } from 'node:url';
11
11
 
12
+ import { readTokens } from '../../plugins/design/hub/src/tokens.mjs';
13
+
12
14
  const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
13
15
 
14
16
  function runCli(args, { cwd } = {}) {
@@ -40,7 +42,7 @@ test('hub token generate without --label exits 2', () => {
40
42
  assert.match(res.stderr, /--label <name> is required/);
41
43
  });
42
44
 
43
- test('hub token generate writes tokens.json + prints the connect command', () => {
45
+ test('hub token generate persists to the store + prints the connect command', () => {
44
46
  withDataDir((dataDir) => {
45
47
  const res = runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
46
48
  assert.equal(res.status, 0, res.stderr);
@@ -48,11 +50,11 @@ test('hub token generate writes tokens.json + prints the connect command', () =>
48
50
  assert.match(res.stdout, /value:\s+mau_[0-9a-f]{32}/);
49
51
  assert.match(res.stdout, /maude design link/);
50
52
 
51
- const raw = readFileSync(join(dataDir, 'tokens.json'), 'utf8');
52
- const parsed = JSON.parse(raw);
53
- assert.equal(parsed.tokens.length, 1);
54
- assert.equal(parsed.tokens[0].label, 'alice');
55
- assert.match(parsed.tokens[0].value, /^mau_[0-9a-f]{32}$/);
53
+ const { tokens } = readTokens(dataDir);
54
+ assert.equal(tokens.length, 1);
55
+ assert.equal(tokens[0].label, 'alice');
56
+ // The raw value is never persisted — only labels/metadata are listable.
57
+ assert.equal(tokens[0].value, undefined);
56
58
  });
57
59
  });
58
60
 
@@ -61,8 +63,7 @@ test('hub token generate --label is idempotent (overwrites in place)', () => {
61
63
  runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
62
64
  runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
63
65
 
64
- const parsed = JSON.parse(readFileSync(join(dataDir, 'tokens.json'), 'utf8'));
65
- assert.equal(parsed.tokens.length, 1);
66
+ assert.equal(readTokens(dataDir).tokens.length, 1);
66
67
  });
67
68
  });
68
69
 
@@ -10,19 +10,21 @@
10
10
  //
11
11
  // Token NEVER lands in .design/config.json — that's git-committed.
12
12
 
13
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { dirname, resolve } from 'node:path';
15
+ import { createInterface } from 'node:readline';
15
16
 
16
17
  import { parseArgs } from './argv.mjs';
17
- import { addHub, getHub, normalizeUrl, removeHub } from './hubs-config.mjs';
18
+ import { addHub, getHub, isHubTrusted, normalizeUrl, removeHub, trustHub } from './hubs-config.mjs';
18
19
 
19
20
  const DESIGN_CONFIG_PATH = '.design/config.json';
21
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
20
22
 
21
23
  // ---------------------------------------------------------------- link
22
24
 
23
25
  export async function runLink({ args, cwd = process.cwd(), forceAdopt = false }) {
24
26
  const tail = args.slice(args.indexOf(forceAdopt ? 'adopt' : 'link') + 1);
25
- const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force'] });
27
+ const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force', 'yes'] });
26
28
  const url = positional[0];
27
29
  const token = flags.token;
28
30
 
@@ -52,6 +54,46 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
52
54
  process.exit(2);
53
55
  }
54
56
 
57
+ const cfg = readDesignConfig(designConfigPath);
58
+
59
+ // DDR-054 F2/F4: linking to a non-loopback hub grants a remote actor the same
60
+ // write access to .design/ as the local user (hub-pushed content lands on
61
+ // disk verbatim, like `git pull` from a stranger). Require explicit trust for
62
+ // a new remote hub. Trust is checked PER-MACHINE (`isHubTrusted`), never from
63
+ // a committable repo file — a committed allowlist (or the committed
64
+ // `linkedHub.url` itself) would let a malicious PR pre-seed trust and bypass
65
+ // this gate (trust laundering). Loopback dev hubs are exempt.
66
+ const loopback = isLoopbackUrl(normUrl);
67
+ const alreadyTrusted = loopback || isHubTrusted(normUrl);
68
+ const manifest = adopt ? collectAdoptManifest(cwd) : [];
69
+
70
+ if (!alreadyTrusted) {
71
+ process.stderr.write(trustGateText(normUrl, adopt, manifest));
72
+ if (flags.yes) {
73
+ process.stderr.write(' → confirmed via --yes\n');
74
+ } else if (process.stdin.isTTY) {
75
+ const question = adopt
76
+ ? `Link this repo to ${normUrl} AND push local design state up to it? [y/N] `
77
+ : `Link this repo to ${normUrl}? [y/N] `;
78
+ const ok = await promptYesNo(question);
79
+ if (!ok) {
80
+ process.stderr.write('maude design link: aborted — hub not trusted.\n');
81
+ process.exit(1);
82
+ }
83
+ } else {
84
+ process.stderr.write(
85
+ `maude design link: linking to a non-local hub (${normUrl}) requires confirmation.\n Re-run in an interactive terminal, or pass --yes to confirm non-interactively.\n`
86
+ );
87
+ process.exit(1);
88
+ }
89
+ // NOTE: trust is recorded AFTER the link succeeds (below), not here — an
90
+ // aborted link (probe fail without --force, config write throws) must not
91
+ // leave a persisted trust that silently skips the gate on a later re-link.
92
+ } else if (adopt && manifest.length > 0) {
93
+ // Trusted (e.g. localhost) adopt — surface the manifest informationally.
94
+ process.stdout.write(adoptManifestText(normUrl, manifest));
95
+ }
96
+
55
97
  // Reachability probe — best-effort. Hub auth happens on WS upgrade (Task 4),
56
98
  // so a successful /health response only tells us the hub is up + reachable.
57
99
  // Token validity is verified when the sync agent connects for real.
@@ -63,11 +105,10 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
63
105
  process.exit(1);
64
106
  }
65
107
 
66
- // Write hub side: tokens.json next to the user's other config.
67
- const hubRecord = addHub(normUrl, token);
108
+ // Write hub side: token (+ adopt attestation) in ~/.config/maude/hubs.json.
109
+ const hubRecord = addHub(normUrl, token, adopt ? { adoptedAt: Date.now() } : {});
68
110
 
69
111
  // Write project side: linkedHub field on .design/config.json.
70
- const cfg = readDesignConfig(designConfigPath);
71
112
  const existing = cfg.linkedHub;
72
113
  if (existing && !flags.force) {
73
114
  process.stdout.write(
@@ -81,9 +122,15 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
81
122
  };
82
123
  writeDesignConfig(designConfigPath, cfg);
83
124
 
125
+ // Record per-machine trust only now that the link fully succeeded, so a
126
+ // later re-link to this hub won't re-prompt. Skipped for loopback + hubs
127
+ // already trusted on this machine.
128
+ if (!alreadyTrusted) trustHub(normUrl);
129
+
84
130
  process.stdout.write(
85
- `[design link] linked ${cwd} to ${normUrl}.\n token: stored in ~/.config/maude/hubs.json (per-machine, never committed)\n config: .design/config.json.linkedHub = { url, linkedAt${adopt ? ', adopt: true' : ''} }\n hub: ${probe.ok ? `v${probe.version}, uptime ${Math.round((probe.uptimeMs ?? 0) / 1000)}s, ${probe.tokenCount} token(s) (${probe.authMode})` : 'NOT REACHED — linked anyway (--force)'}\n\nNext step: start 'maude design serve' — the linked sync agent ${adopt ? 'will push local state up to the hub on first connect' : 'will mirror hub state to disk on first connect'}.\n (Sync agent lands in Phase 9 Task 4.)\n`
131
+ `[design link] linked ${cwd} to ${normUrl}.\n token: stored in ~/.config/maude/hubs.json (per-machine, never committed)\n config: .design/config.json.linkedHub = { url, linkedAt${adopt ? ', adopt: true' : ''} }\n hub: ${probe.ok ? `v${probe.version}, uptime ${Math.round((probe.uptimeMs ?? 0) / 1000)}s, ${probe.tokenCount} token(s) (${probe.authMode})` : 'NOT REACHED — linked anyway (--force)'}\n\nNext step: start 'maude design serve' — the linked sync agent ${adopt ? 'will push local state up to the hub on first connect' : 'will mirror hub state to disk on first connect'}.\n`
86
132
  );
133
+ if (!loopback) process.stderr.write(linkedModeBanner());
87
134
  }
88
135
 
89
136
  // ---------------------------------------------------------------- adopt
@@ -214,3 +261,103 @@ async function probeHealth(url) {
214
261
  return { ok: false, error: err.message };
215
262
  }
216
263
  }
264
+
265
+ // ----------------------------------------------------- trust gate (DDR-054 F2/F4)
266
+
267
+ /** True when the hub URL points at the local machine (no remote-write risk). */
268
+ function isLoopbackUrl(normUrl) {
269
+ try {
270
+ return LOOPBACK_HOSTS.has(new URL(normUrl).hostname);
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
276
+ /** Promise-resolving [y/N] prompt. Prompt + echo go to stderr (stdout is data). */
277
+ function promptYesNo(question) {
278
+ return new Promise((res) => {
279
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
280
+ rl.question(question, (answer) => {
281
+ rl.close();
282
+ res(/^y(es)?$/i.test(answer.trim()));
283
+ });
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Files `--adopt` would push up to the hub: top-level canvases + annotation
289
+ * SVGs + comment JSON snapshots (the git-tracked sync surface per Task 9).
290
+ */
291
+ function collectAdoptManifest(cwd) {
292
+ const base = resolve(cwd, '.design');
293
+ const files = [];
294
+ try {
295
+ for (const f of readdirSync(base)) {
296
+ if (f.endsWith('.html') || f.endsWith('.annotations.svg')) {
297
+ try {
298
+ files.push({ rel: `.design/${f}`, bytes: statSync(resolve(base, f)).size });
299
+ } catch {
300
+ /* skip unreadable */
301
+ }
302
+ }
303
+ }
304
+ } catch {
305
+ /* no .design — manifest stays empty */
306
+ }
307
+ const commentsDir = resolve(base, '_comments');
308
+ try {
309
+ for (const f of readdirSync(commentsDir)) {
310
+ if (!f.endsWith('.json')) continue;
311
+ try {
312
+ files.push({
313
+ rel: `.design/_comments/${f}`,
314
+ bytes: statSync(resolve(commentsDir, f)).size,
315
+ });
316
+ } catch {
317
+ /* skip unreadable */
318
+ }
319
+ }
320
+ } catch {
321
+ /* no comments dir */
322
+ }
323
+ return files;
324
+ }
325
+
326
+ function adoptManifestText(normUrl, manifest) {
327
+ const lines = manifest.map((f) => ` ${f.rel} (${f.bytes} B)`).join('\n');
328
+ return `[design link] --adopt will push ${manifest.length} local file(s) up to ${normUrl}:\n${lines}\n`;
329
+ }
330
+
331
+ function trustGateText(normUrl, adopt, manifest) {
332
+ let url;
333
+ try {
334
+ url = new URL(normUrl);
335
+ } catch {
336
+ url = { protocol: '?', hostname: normUrl };
337
+ }
338
+ const scheme = `${url.protocol.replace(':', '')}${url.protocol === 'http:' ? ' (NOT encrypted — token + edits travel in cleartext)' : ''}`;
339
+ let out = `
340
+ ⚠ Linking to a NON-LOCAL hub.
341
+ URL: ${normUrl}
342
+ scheme: ${scheme}
343
+ host: ${url.hostname}
344
+ A linked hub can write to your .design/ files (treat like \`git pull\` from a stranger).
345
+ Only link to hubs you operate or fully trust. See DDR-054 for the trust model.
346
+ `;
347
+ if (adopt) {
348
+ const list = manifest.map((f) => ` ${f.rel} (${f.bytes} B)`).join('\n');
349
+ out +=
350
+ manifest.length > 0
351
+ ? `\n --adopt will UPLOAD ${manifest.length} local file(s) to this hub:\n${list}\n`
352
+ : '\n --adopt is set but no local canvases/comments/annotations were found to upload.\n';
353
+ }
354
+ return out;
355
+ }
356
+
357
+ function linkedModeBanner() {
358
+ return `
359
+ ⚠ Linked mode is an experimental v1.1 preview. Hub-pushed content is written
360
+ to your .design/ files as untrusted input. Only link to hubs you operate or
361
+ fully trust. See DDR-054 for the trust model.
362
+ `;
363
+ }
@@ -12,8 +12,15 @@
12
12
  // "linkedAt": 1716800000000
13
13
  // },
14
14
  // ...
15
- // }
15
+ // },
16
+ // "trusted": ["https://maude-hub-foo.fly.dev", ...]
16
17
  // }
18
+ //
19
+ // `trusted` is the per-machine trust allowlist for non-loopback hubs (the
20
+ // `maude design link` confirmation, DDR-054 F2). It lives here — NOT in a
21
+ // committable repo file — on purpose: a committable allowlist would let an
22
+ // attacker pre-seed trust via a PR and bypass the link-time confirmation
23
+ // (trust laundering). Trust is a per-machine decision, like `~/.ssh/known_hosts`.
17
24
 
18
25
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
26
  import { homedir } from 'node:os';
@@ -68,11 +75,20 @@ export function saveHubsConfig(config) {
68
75
  }
69
76
  }
70
77
 
71
- /** Upsert a hub entry. Same URL replaces the existing record. */
72
- export function addHub(url, token) {
78
+ /**
79
+ * Upsert a hub entry. Same URL replaces the existing record. `extra` merges
80
+ * additional per-machine attestations (e.g. `adoptedAt` — the timestamp this
81
+ * machine pushed its local state up via `--adopt`, used by the sync runtime to
82
+ * avoid re-adopting; DDR-054 F4).
83
+ *
84
+ * @param {string} url
85
+ * @param {string} token
86
+ * @param {{ adoptedAt?: number }} [extra]
87
+ */
88
+ export function addHub(url, token, extra = {}) {
73
89
  const norm = normalizeUrl(url);
74
90
  const cfg = loadHubsConfig();
75
- cfg.hubs[norm] = { token, linkedAt: Date.now() };
91
+ cfg.hubs[norm] = { token, linkedAt: Date.now(), ...extra };
76
92
  saveHubsConfig(cfg);
77
93
  return cfg.hubs[norm];
78
94
  }
@@ -98,6 +114,28 @@ export function getHub(url) {
98
114
  return cfg.hubs[norm] ?? null;
99
115
  }
100
116
 
117
+ /**
118
+ * Per-machine trust check for a non-loopback hub (DDR-054 F2). Trust is stored
119
+ * here, not in a committable repo file, so a malicious PR cannot pre-seed it.
120
+ */
121
+ export function isHubTrusted(url) {
122
+ const norm = normalizeUrl(url);
123
+ const cfg = loadHubsConfig();
124
+ return Array.isArray(cfg.trusted) && cfg.trusted.includes(norm);
125
+ }
126
+
127
+ /** Record a hub as trusted on THIS machine. Idempotent. */
128
+ export function trustHub(url) {
129
+ const norm = normalizeUrl(url);
130
+ const cfg = loadHubsConfig();
131
+ if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
132
+ if (!cfg.trusted.includes(norm)) {
133
+ cfg.trusted.push(norm);
134
+ saveHubsConfig(cfg);
135
+ }
136
+ return norm;
137
+ }
138
+
101
139
  /**
102
140
  * Normalize a hub URL so different spellings resolve to the same key:
103
141
  * - Trim trailing slash.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -38,16 +38,16 @@
38
38
  "video:render": "cd scripts/video/final && pnpm run render",
39
39
  "video:studio": "cd scripts/video/final && pnpm run studio",
40
40
  "postinstall": "node cli/install.cjs",
41
- "prepublishOnly": "bash scripts/check-version-parity.sh"
41
+ "prepublishOnly": "bash scripts/check-version-parity.sh && bash plugins/design/dev-server/bin/check-runtime-bundles.sh"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@1agh/maude-darwin-arm64": "0.22.0",
45
- "@1agh/maude-darwin-x64": "0.22.0",
46
- "@1agh/maude-linux-arm64": "0.22.0",
47
- "@1agh/maude-linux-arm64-musl": "0.22.0",
48
- "@1agh/maude-linux-x64": "0.22.0",
49
- "@1agh/maude-linux-x64-musl": "0.22.0",
50
- "@1agh/maude-win32-x64": "0.22.0"
44
+ "@1agh/maude-darwin-arm64": "0.23.0",
45
+ "@1agh/maude-darwin-x64": "0.23.0",
46
+ "@1agh/maude-linux-arm64": "0.23.0",
47
+ "@1agh/maude-linux-arm64-musl": "0.23.0",
48
+ "@1agh/maude-linux-x64": "0.23.0",
49
+ "@1agh/maude-linux-x64-musl": "0.23.0",
50
+ "@1agh/maude-win32-x64": "0.23.0"
51
51
  },
52
52
  "files": [
53
53
  "cli",
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ # check-runtime-bundles.sh — pre-publish guard against shipping defective
3
+ # /_canvas-runtime/<slug>.js bundles.
4
+ #
5
+ # Why: Bun.build's output for `motion` + `motion/react` is environment-sensitive
6
+ # (Bun version, OS, transitive dep resolution). v0.22.0 shipped a 13 kB
7
+ # motion_react.js where the working bundle is 155 kB+ — the smaller artifact
8
+ # parses cleanly + serves HTTP 200 but throws `ReferenceError: AcceleratedAnimation
9
+ # is not defined` at module-eval time, breaking every canvas that uses the
10
+ # motion lib. CI build was green; the regression slipped because nothing
11
+ # asserted bundle SIZE.
12
+ #
13
+ # This guard reads dist/runtime/.min-sizes.json and asserts each on-disk
14
+ # bundle ≥ its declared floor. Run from CI before `npm publish`. Hard-fails
15
+ # the publish job → the bad tarball never reaches npm.
16
+ #
17
+ # Floors are at ~70% of release-minified size (see manifest comment). Any
18
+ # minifier improvement that drops a bundle below the floor needs an explicit
19
+ # manifest bump + investigation — that's the point.
20
+ #
21
+ # Usage:
22
+ # check-runtime-bundles.sh [--runtime-dir <path>] [--manifest <path>]
23
+ #
24
+ # Defaults:
25
+ # --runtime-dir = <plugin>/dev-server/dist/runtime/
26
+ # --manifest = <runtime-dir>/.min-sizes.json
27
+ #
28
+ # Exit codes:
29
+ # 0 all bundles meet floor
30
+ # 1 manifest or runtime dir missing
31
+ # 2 bad args
32
+ # 3 one or more bundles below floor
33
+
34
+ set -euo pipefail
35
+
36
+ RUNTIME_DIR=""
37
+ MANIFEST=""
38
+
39
+ while [ $# -gt 0 ]; do
40
+ case "$1" in
41
+ --runtime-dir) RUNTIME_DIR="$2"; shift 2 ;;
42
+ --manifest) MANIFEST="$2"; shift 2 ;;
43
+ --help|-h)
44
+ sed -n '2,30p' "$0" | sed 's/^# \?//'
45
+ exit 0
46
+ ;;
47
+ *)
48
+ echo "check-runtime-bundles.sh: unknown arg '$1' (try --help)" >&2
49
+ exit 2
50
+ ;;
51
+ esac
52
+ done
53
+
54
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
55
+ if [ -z "$RUNTIME_DIR" ]; then
56
+ RUNTIME_DIR="$SCRIPT_DIR/../dist/runtime"
57
+ fi
58
+ if [ -z "$MANIFEST" ]; then
59
+ MANIFEST="$RUNTIME_DIR/.min-sizes.json"
60
+ fi
61
+
62
+ if [ ! -d "$RUNTIME_DIR" ]; then
63
+ echo "check-runtime-bundles.sh: runtime dir not found at $RUNTIME_DIR" >&2
64
+ exit 1
65
+ fi
66
+ if [ ! -f "$MANIFEST" ]; then
67
+ echo "check-runtime-bundles.sh: manifest not found at $MANIFEST" >&2
68
+ exit 1
69
+ fi
70
+
71
+ # Read manifest as <slug> <floor> pairs, one per line. Skip $-prefixed
72
+ # metadata keys ($comment etc.). Prefer jq when available; fall back to
73
+ # python3 (always present in CI runners + macOS).
74
+ read_pairs() {
75
+ if command -v jq >/dev/null 2>&1; then
76
+ jq -r 'to_entries | map(select(.key | startswith("$") | not)) | .[] | "\(.key) \(.value)"' "$MANIFEST"
77
+ else
78
+ python3 -c '
79
+ import json, sys
80
+ with open("'"$MANIFEST"'") as f:
81
+ data = json.load(f)
82
+ for k, v in data.items():
83
+ if k.startswith("$"): continue
84
+ print(k, v)
85
+ '
86
+ fi
87
+ }
88
+
89
+ FAIL_COUNT=0
90
+ CHECK_COUNT=0
91
+ MISSING_COUNT=0
92
+
93
+ while read -r slug floor; do
94
+ [ -z "$slug" ] && continue
95
+ CHECK_COUNT=$((CHECK_COUNT + 1))
96
+ PATH_JS="$RUNTIME_DIR/$slug"
97
+ if [ ! -f "$PATH_JS" ]; then
98
+ echo "✗ $slug — missing on disk (expected at $PATH_JS)" >&2
99
+ MISSING_COUNT=$((MISSING_COUNT + 1))
100
+ FAIL_COUNT=$((FAIL_COUNT + 1))
101
+ continue
102
+ fi
103
+ SIZE=$(wc -c < "$PATH_JS" | tr -d ' ')
104
+ if [ "$SIZE" -lt "$floor" ]; then
105
+ echo "✗ $slug — $SIZE B < floor $floor B (likely defective bundle)" >&2
106
+ FAIL_COUNT=$((FAIL_COUNT + 1))
107
+ else
108
+ echo "✓ $slug — $SIZE B ≥ floor $floor B"
109
+ fi
110
+ done < <(read_pairs)
111
+
112
+ if [ "$FAIL_COUNT" -eq 0 ]; then
113
+ echo ""
114
+ echo "✓ check-runtime-bundles OK — $CHECK_COUNT bundles, all above floor"
115
+ exit 0
116
+ fi
117
+
118
+ echo "" >&2
119
+ echo "✗ check-runtime-bundles FAIL — $FAIL_COUNT/$CHECK_COUNT bundle(s) below floor" >&2
120
+ if [ "$MISSING_COUNT" -gt 0 ]; then
121
+ echo " ($MISSING_COUNT missing from disk — was buildRuntimeBundles() skipped?)" >&2
122
+ fi
123
+ echo " Refusing to ship a tarball with defective /_canvas-runtime artifacts." >&2
124
+ echo " Investigate the Bun.build output for the offending package(s) before publishing." >&2
125
+ exit 3
@@ -8,9 +8,14 @@
8
8
  # server returns HTTP 200, but the iframe throws at module-eval time with
9
9
  # `ReferenceError: AcceleratedAnimation is not defined` (or similar).
10
10
  #
11
- # Parse-clean ≠ run-clean. This helper closes that gap: probe every URL the
12
- # canvas-lib pulls in, compare byte-count to the disk pre-built, fail loud
13
- # when the served body is suspiciously small.
11
+ # Parse-clean ≠ run-clean. This helper closes that gap with TWO checks:
12
+ # 1. Per-bundle absolute floor served body MUST clear the size declared
13
+ # in dist/runtime/.min-sizes.json. This catches the v0.22.0 regression
14
+ # class where the installed bundle on disk is ITSELF defective (so
15
+ # compare-to-disk would trivially pass).
16
+ # 2. Compare-to-disk ratio — served body MUST be ≥ threshold × on-disk
17
+ # size. Catches the original case where disk is good but the running
18
+ # dev-server returned a defective dynamic Bun.build cached in memory.
14
19
  #
15
20
  # Usage:
16
21
  # runtime-health.sh [--port N] [--root <repo>] [--threshold 0.5]
@@ -67,6 +72,26 @@ if [ ! -d "$PREBUILT_DIR" ]; then
67
72
  exit 1
68
73
  fi
69
74
 
75
+ # Absolute-floor manifest. Optional — if missing we fall back to the
76
+ # disk-ratio check alone (older installs from before .min-sizes.json shipped).
77
+ MIN_SIZES_MANIFEST="$PREBUILT_DIR/.min-sizes.json"
78
+ floor_for() {
79
+ local slug="$1"
80
+ [ -f "$MIN_SIZES_MANIFEST" ] || { echo ""; return; }
81
+ if command -v jq >/dev/null 2>&1; then
82
+ jq -r --arg k "$slug" '.[$k] // empty' "$MIN_SIZES_MANIFEST" 2>/dev/null
83
+ else
84
+ python3 -c '
85
+ import json, sys
86
+ try:
87
+ with open("'"$MIN_SIZES_MANIFEST"'") as f: data = json.load(f)
88
+ v = data.get("'"$slug"'")
89
+ print(v if v is not None else "")
90
+ except Exception: print("")
91
+ ' 2>/dev/null
92
+ fi
93
+ }
94
+
70
95
  # ---------- resolve port from _server.json if not given ----------
71
96
  DESIGN_ROOT="$REPO/.design"
72
97
  STATE="$DESIGN_ROOT/_server.json"
@@ -101,11 +126,19 @@ probe_one() {
101
126
  echo "✗ $slug — served HTTP error (curl failed for $url)" >&2
102
127
  return 1
103
128
  }
104
- # Floor at 256 bytes — any working ESM bundle is bigger than that.
129
+ # Hard floor at 256 bytes — any working ESM bundle is bigger than that.
105
130
  if [ "$served" -lt 256 ]; then
106
131
  echo "✗ $slug — served body $served B < 256 B floor (server returned empty)" >&2
107
132
  return 1
108
133
  fi
134
+ # Absolute floor from .min-sizes.json (independent of disk — catches the
135
+ # case where the SHIPPED bundle is itself defective, like v0.22.0 motion_react).
136
+ local abs_floor
137
+ abs_floor=$(floor_for "$slug")
138
+ if [ -n "$abs_floor" ] && [ "$served" -lt "$abs_floor" ]; then
139
+ echo "✗ $slug — served $served B < absolute floor $abs_floor B (declared in .min-sizes.json — defective bundle in install)" >&2
140
+ return 1
141
+ fi
109
142
  # Threshold ratio: served must be ≥ threshold × disk.
110
143
  # awk handles the fractional math without bc dependency.
111
144
  local ratio
@@ -113,7 +146,7 @@ probe_one() {
113
146
  local ok
114
147
  ok=$(awk -v r="$ratio" -v t="$THRESHOLD" 'BEGIN{print (r >= t) ? 1 : 0}')
115
148
  if [ "$ok" = "1" ]; then
116
- [ $QUIET -eq 0 ] && echo "✓ $slug — $served B / $disk_size B disk (ratio $ratio)" >&2
149
+ [ $QUIET -eq 0 ] && echo "✓ $slug — $served B / $disk_size B disk (ratio $ratio${abs_floor:+, floor ${abs_floor} B})" >&2
117
150
  return 0
118
151
  fi
119
152
  echo "✗ $slug — served $served B / $disk_size B disk (ratio $ratio < $THRESHOLD) — defective dynamic build" >&2
@@ -139,8 +172,16 @@ fi
139
172
 
140
173
  echo "" >&2
141
174
  echo "✗ runtime-health FAIL — $FAIL_COUNT bundle(s) below threshold:$FAIL_LIST" >&2
142
- echo " These bundles look like defective dynamic Bun.build output." >&2
175
+ echo " These bundles look defective." >&2
143
176
  echo " The canvas TSX will parse + serve cleanly, but the iframe will throw at runtime." >&2
177
+ echo "" >&2
178
+ echo " If the failure was from the 'absolute floor' check (see lines above):" >&2
179
+ echo " → the SHIPPED bundle on disk is itself defective (rare release-time regression)." >&2
180
+ echo " → --restart will NOT help; upgrade the package: \`npm i -g @1agh/maude@latest\`" >&2
181
+ echo " (or for marketplace installs: \`/plugin marketplace update maude\`)." >&2
182
+ echo " If the failure was from the 'ratio < threshold' check:" >&2
183
+ echo " → the running server cached a defective dynamic Bun.build output." >&2
184
+ echo " → --restart will respawn the server and load the (good) disk pre-built." >&2
144
185
 
145
186
  if [ "$RESTART" -eq 1 ]; then
146
187
  echo "→ --restart given; killing server and respawning via server-up.sh" >&2