@1agh/maude 0.22.2 → 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 (44) 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 +8 -8
  6. package/plugins/design/dev-server/build.ts +69 -3
  7. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  9. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  10. package/plugins/design/dev-server/client/app.jsx +37 -15
  11. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  12. package/plugins/design/dev-server/collab/registry.ts +51 -0
  13. package/plugins/design/dev-server/config.schema.json +20 -0
  14. package/plugins/design/dev-server/context.ts +7 -0
  15. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  16. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  17. package/plugins/design/dev-server/dom-selection.ts +156 -0
  18. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  19. package/plugins/design/dev-server/input-router.tsx +99 -61
  20. package/plugins/design/dev-server/server.ts +18 -0
  21. package/plugins/design/dev-server/sync/agent.ts +323 -0
  22. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  23. package/plugins/design/dev-server/sync/codec.ts +169 -0
  24. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  25. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  26. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  27. package/plugins/design/dev-server/sync/index.ts +474 -0
  28. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  29. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  30. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  31. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  32. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  33. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  34. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  35. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  36. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  37. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  38. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  39. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  40. package/plugins/design/dev-server/use-collab.tsx +157 -13
  41. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  42. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  43. package/plugins/design/templates/_shell.html +15 -5
  44. 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.2",
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": {
@@ -41,13 +41,13 @@
41
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.2",
45
- "@1agh/maude-darwin-x64": "0.22.2",
46
- "@1agh/maude-linux-arm64": "0.22.2",
47
- "@1agh/maude-linux-arm64-musl": "0.22.2",
48
- "@1agh/maude-linux-x64": "0.22.2",
49
- "@1agh/maude-linux-x64-musl": "0.22.2",
50
- "@1agh/maude-win32-x64": "0.22.2"
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",
@@ -114,6 +114,58 @@ async function buildClient(): Promise<{ outBytes: number; outPath: string }> {
114
114
  return { outBytes: out.size, outPath };
115
115
  }
116
116
 
117
+ // ---------- (a.5) Comment mount layer (shell-owned comments) ----------
118
+ //
119
+ // canvas-comment-mount.tsx → dist/comment-mount.js. Loaded by _shell.html via
120
+ // a `<script type="module">`; its `mountCanvas(...)` wraps any canvas default
121
+ // export in the lite comment provider tree. React + the other canvas-runtime
122
+ // packages are EXTERNAL (bare specifiers resolved by _shell.html's importmap)
123
+ // so this bundle shares the single React/yjs singletons with the canvas
124
+ // module — inlining a second React would break hooks ("invalid hook call").
125
+
126
+ const COMMENT_MOUNT_EXTERNALS = [
127
+ 'react',
128
+ 'react-dom',
129
+ 'react-dom/client',
130
+ 'react/jsx-runtime',
131
+ 'react/jsx-dev-runtime',
132
+ 'yjs',
133
+ 'y-protocols/sync',
134
+ 'y-protocols/awareness',
135
+ 'lib0/decoding',
136
+ 'lib0/encoding',
137
+ ];
138
+
139
+ async function buildCommentMount(): Promise<{ outBytes: number; outPath: string }> {
140
+ ensureDist();
141
+ const outPath = join(DIST, 'comment-mount.js');
142
+ const result = await Bun.build({
143
+ entrypoints: [join(ROOT, 'canvas-comment-mount.tsx')],
144
+ outdir: DIST,
145
+ target: 'browser',
146
+ format: 'esm',
147
+ naming: 'comment-mount.js',
148
+ external: COMMENT_MOUNT_EXTERNALS,
149
+ minify: MODE === 'release',
150
+ sourcemap: MODE === 'dev' ? 'inline' : 'none',
151
+ define: {
152
+ // ALWAYS production React — like canvas-build.ts. React is external and
153
+ // resolves through the importmap to the dev-server's PRODUCTION runtime
154
+ // bundles, where `react/jsx-dev-runtime`'s `jsxDEV` is a no-op (undefined
155
+ // at call time). Emitting dev jsx (`jsxDEV`) against a production runtime
156
+ // throws "jsxDEV is not a function". Both halves must agree on the JSX
157
+ // flavour — production jsx (`react/jsx-runtime`) is the contract.
158
+ 'process.env.NODE_ENV': '"production"',
159
+ },
160
+ });
161
+ if (!result.success) {
162
+ const messages = result.logs.map((l) => l.message ?? String(l)).join('\n');
163
+ throw new Error(`Comment-mount build failed:\n${messages}`);
164
+ }
165
+ const out = Bun.file(outPath);
166
+ return { outBytes: out.size, outPath };
167
+ }
168
+
117
169
  // ---------- (b) CSS bundle (Lightning CSS) ----------
118
170
 
119
171
  async function buildCss(): Promise<{ outBytes: number; outPath: string }> {
@@ -325,6 +377,7 @@ async function buildServerBinary(target: PlatformTarget): Promise<{ outPath: str
325
377
 
326
378
  async function watch() {
327
379
  await buildClient();
380
+ await buildCommentMount();
328
381
  await buildCss();
329
382
  console.log('[build:watch] initial build complete; watching client/ + server source...');
330
383
 
@@ -396,10 +449,16 @@ async function main() {
396
449
  `[build] client.bundle.js ${client.outBytes.toLocaleString()} B (${(t1 - t0).toFixed(0)} ms)`
397
450
  );
398
451
 
452
+ const commentMount = await buildCommentMount();
453
+ const t1b = performance.now();
454
+ console.log(
455
+ `[build] comment-mount.js ${commentMount.outBytes.toLocaleString()} B (${(t1b - t1).toFixed(0)} ms)`
456
+ );
457
+
399
458
  const css = await buildCss();
400
459
  const t2 = performance.now();
401
460
  console.log(
402
- `[build] styles.css ${css.outBytes.toLocaleString()} B (${(t2 - t1).toFixed(0)} ms)`
461
+ `[build] styles.css ${css.outBytes.toLocaleString()} B (${(t2 - t1b).toFixed(0)} ms)`
403
462
  );
404
463
 
405
464
  // Pre-built runtime bundles — ship to disk so /_canvas-runtime/* never
@@ -415,7 +474,7 @@ async function main() {
415
474
  // on-disk artifacts against .min-sizes.json after the build.
416
475
  if (process.env.MAUDE_SKIP_RUNTIME_BUILD === '1') {
417
476
  console.log(
418
- `[build] dist/runtime/*.js SKIPPED (MAUDE_SKIP_RUNTIME_BUILD=1 — using committed pre-built)`
477
+ '[build] dist/runtime/*.js SKIPPED (MAUDE_SKIP_RUNTIME_BUILD=1 — using committed pre-built)'
419
478
  );
420
479
  } else {
421
480
  const runtime = await buildRuntimeBundles();
@@ -444,4 +503,11 @@ if (import.meta.main) {
444
503
  await main();
445
504
  }
446
505
 
447
- export { buildClient, buildCss, buildServerBinary, PLATFORM_MATRIX, type PlatformTarget };
506
+ export {
507
+ buildClient,
508
+ buildCommentMount,
509
+ buildCss,
510
+ buildServerBinary,
511
+ PLATFORM_MATRIX,
512
+ type PlatformTarget,
513
+ };