@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.
- package/cli/commands/design-link.test.mjs +53 -1
- package/cli/commands/hub.test.mjs +10 -9
- package/cli/lib/design-link.mjs +154 -7
- package/cli/lib/hubs-config.mjs +42 -4
- package/package.json +9 -9
- package/plugins/design/dev-server/bin/check-runtime-bundles.sh +125 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +47 -6
- package/plugins/design/dev-server/build.ts +87 -7
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-lib.tsx +22 -6
- package/plugins/design/dev-server/canvas-shell.tsx +25 -226
- package/plugins/design/dev-server/client/app.jsx +37 -15
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/registry.ts +51 -0
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context.ts +7 -0
- package/plugins/design/dev-server/dist/client.bundle.js +21 -12
- package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
- package/plugins/design/dev-server/dist/runtime/.min-sizes.json +16 -0
- package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +2 -6
- package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +2 -6
- package/plugins/design/dev-server/dist/runtime/motion.js +4787 -8
- package/plugins/design/dev-server/dist/runtime/motion_react.js +9654 -7
- package/plugins/design/dev-server/dist/runtime/pixi-js.js +5865 -5469
- package/plugins/design/dev-server/dist/runtime/react-dom.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react-dom_client.js +5 -23
- package/plugins/design/dev-server/dist/runtime/react.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +4 -22
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +2 -6
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +2 -6
- package/plugins/design/dev-server/dist/runtime/yjs.js +2 -6
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/input-router.tsx +99 -61
- package/plugins/design/dev-server/server.ts +18 -0
- package/plugins/design/dev-server/sync/agent.ts +323 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +169 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +474 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
- package/plugins/design/templates/_shell.html +15 -5
- 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,
|
|
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
|
|
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
|
|
52
|
-
|
|
53
|
-
assert.equal(
|
|
54
|
-
|
|
55
|
-
assert.
|
|
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
|
-
|
|
65
|
-
assert.equal(parsed.tokens.length, 1);
|
|
66
|
+
assert.equal(readTokens(dataDir).tokens.length, 1);
|
|
66
67
|
});
|
|
67
68
|
});
|
|
68
69
|
|
package/cli/lib/design-link.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
+
}
|
package/cli/lib/hubs-config.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
72
|
-
|
|
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.
|
|
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.
|
|
45
|
-
"@1agh/maude-darwin-x64": "0.
|
|
46
|
-
"@1agh/maude-linux-arm64": "0.
|
|
47
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
48
|
-
"@1agh/maude-linux-x64": "0.
|
|
49
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
50
|
-
"@1agh/maude-win32-x64": "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
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|