@1agh/maude 0.22.2 → 0.24.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/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -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 +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -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-connection-state.test.ts +146 -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-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- 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 +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import {
|
|
7
|
+
check,
|
|
8
|
+
clear,
|
|
9
|
+
getOrCompute,
|
|
10
|
+
list,
|
|
11
|
+
resolveCacheRoot,
|
|
12
|
+
sha8,
|
|
13
|
+
stats,
|
|
14
|
+
write,
|
|
15
|
+
} from './cache.mjs';
|
|
16
|
+
|
|
17
|
+
function tmpCache() {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-cache-'));
|
|
19
|
+
return { cacheDir: dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('write then check returns the same payload', () => {
|
|
23
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
24
|
+
try {
|
|
25
|
+
write('research/domain', 'finance.abc12345', { mood: ['calm'], n: 3 }, {}, { cacheDir });
|
|
26
|
+
const hit = check('research/domain', 'finance.abc12345', { cacheDir });
|
|
27
|
+
assert.ok(hit);
|
|
28
|
+
assert.deepEqual(hit.value, { mood: ['calm'], n: 3 });
|
|
29
|
+
assert.equal(typeof hit.ageMs, 'number');
|
|
30
|
+
assert.ok(hit.ageMs >= 0);
|
|
31
|
+
} finally {
|
|
32
|
+
cleanup();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('first write drops a README documenting the invalidation policy', () => {
|
|
37
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
38
|
+
try {
|
|
39
|
+
write('research/domain', 'k', { x: 1 }, {}, { cacheDir });
|
|
40
|
+
const readme = readFileSync(join(cacheDir, 'README.md'), 'utf8');
|
|
41
|
+
assert.match(readme, /Invalidation policy/);
|
|
42
|
+
assert.match(readme, /research\/domain/);
|
|
43
|
+
} finally {
|
|
44
|
+
cleanup();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('check returns null on miss', () => {
|
|
49
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
50
|
+
try {
|
|
51
|
+
assert.equal(check('research/domain', 'nope', { cacheDir }), null);
|
|
52
|
+
} finally {
|
|
53
|
+
cleanup();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('check returns null on corrupt entry', () => {
|
|
58
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
59
|
+
try {
|
|
60
|
+
const { path } = write('codebase-intelligence', 'deadbeef', { x: 1 }, {}, { cacheDir });
|
|
61
|
+
writeFileSync(path, '{ this is not json', 'utf8');
|
|
62
|
+
assert.equal(check('codebase-intelligence', 'deadbeef', { cacheDir }), null);
|
|
63
|
+
} finally {
|
|
64
|
+
cleanup();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('getOrCompute serves fresh cache without calling compute', async () => {
|
|
69
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
70
|
+
try {
|
|
71
|
+
write('design-context', 'ds/tok1', { tokens: 12 }, {}, { cacheDir });
|
|
72
|
+
let called = false;
|
|
73
|
+
const v = await getOrCompute({
|
|
74
|
+
cacheDir,
|
|
75
|
+
layer: 'design-context',
|
|
76
|
+
key: 'ds/tok1',
|
|
77
|
+
ttlMs: 60_000,
|
|
78
|
+
compute: () => {
|
|
79
|
+
called = true;
|
|
80
|
+
return { tokens: 999 };
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(v, { tokens: 12 });
|
|
84
|
+
assert.equal(called, false, 'compute must not run on a fresh hit');
|
|
85
|
+
} finally {
|
|
86
|
+
cleanup();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('getOrCompute runs compute on miss and caches the result', async () => {
|
|
91
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
92
|
+
try {
|
|
93
|
+
let calls = 0;
|
|
94
|
+
const opts = {
|
|
95
|
+
cacheDir,
|
|
96
|
+
layer: 'codebase-intelligence',
|
|
97
|
+
key: 'sha-aaa',
|
|
98
|
+
ttlMs: 60_000,
|
|
99
|
+
compute: () => {
|
|
100
|
+
calls += 1;
|
|
101
|
+
return { files: calls };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const first = await getOrCompute(opts);
|
|
105
|
+
const second = await getOrCompute(opts);
|
|
106
|
+
assert.deepEqual(first, { files: 1 });
|
|
107
|
+
assert.deepEqual(second, { files: 1 }, 'second call must read cache, not recompute');
|
|
108
|
+
assert.equal(calls, 1);
|
|
109
|
+
} finally {
|
|
110
|
+
cleanup();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('getOrCompute recomputes past the ttl window', async () => {
|
|
115
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
116
|
+
try {
|
|
117
|
+
const { path } = write('security', 'head1', { audit: 'old' }, {}, { cacheDir });
|
|
118
|
+
// Backdate the stored writtenAt to 2 h ago (past a 1 h ttl).
|
|
119
|
+
const stored = JSON.parse(readFileSync(path, 'utf8'));
|
|
120
|
+
stored.writtenAt = Date.now() - 2 * 60 * 60 * 1000;
|
|
121
|
+
writeFileSync(path, JSON.stringify(stored), 'utf8');
|
|
122
|
+
const v = await getOrCompute({
|
|
123
|
+
cacheDir,
|
|
124
|
+
layer: 'security',
|
|
125
|
+
key: 'head1',
|
|
126
|
+
ttlMs: 60 * 60 * 1000,
|
|
127
|
+
compute: () => ({ audit: 'fresh' }),
|
|
128
|
+
});
|
|
129
|
+
assert.deepEqual(v, { audit: 'fresh' });
|
|
130
|
+
} finally {
|
|
131
|
+
cleanup();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('getOrCompute force bypasses a fresh hit', async () => {
|
|
136
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
137
|
+
try {
|
|
138
|
+
write('research/domain', 'x', { v: 'cached' }, {}, { cacheDir });
|
|
139
|
+
const v = await getOrCompute({
|
|
140
|
+
cacheDir,
|
|
141
|
+
layer: 'research/domain',
|
|
142
|
+
key: 'x',
|
|
143
|
+
ttlMs: 60_000,
|
|
144
|
+
force: true,
|
|
145
|
+
compute: () => ({ v: 'recomputed' }),
|
|
146
|
+
});
|
|
147
|
+
assert.deepEqual(v, { v: 'recomputed' });
|
|
148
|
+
} finally {
|
|
149
|
+
cleanup();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('getOrCompute serves a stale entry when compute throws (within maxAge)', async () => {
|
|
154
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
155
|
+
try {
|
|
156
|
+
const { path } = write('research/domain', 'stale', { v: 'old-but-usable' }, {}, { cacheDir });
|
|
157
|
+
const stored = JSON.parse(readFileSync(path, 'utf8'));
|
|
158
|
+
stored.writtenAt = Date.now() - 10 * 24 * 60 * 60 * 1000; // 10 days old
|
|
159
|
+
writeFileSync(path, JSON.stringify(stored), 'utf8');
|
|
160
|
+
const v = await getOrCompute({
|
|
161
|
+
cacheDir,
|
|
162
|
+
layer: 'research/domain',
|
|
163
|
+
key: 'stale',
|
|
164
|
+
ttlMs: 7 * 24 * 60 * 60 * 1000, // 7 day fresh window → stale
|
|
165
|
+
maxAgeMs: 30 * 24 * 60 * 60 * 1000, // 30 day ceiling → still usable
|
|
166
|
+
compute: () => {
|
|
167
|
+
throw new Error('websearch down');
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
assert.deepEqual(v, { v: 'old-but-usable' });
|
|
171
|
+
} finally {
|
|
172
|
+
cleanup();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('getOrCompute propagates the error when no usable stale entry exists', async () => {
|
|
177
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
178
|
+
try {
|
|
179
|
+
await assert.rejects(
|
|
180
|
+
getOrCompute({
|
|
181
|
+
cacheDir,
|
|
182
|
+
layer: 'research/domain',
|
|
183
|
+
key: 'missing',
|
|
184
|
+
compute: () => {
|
|
185
|
+
throw new Error('boom');
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
/boom/
|
|
189
|
+
);
|
|
190
|
+
} finally {
|
|
191
|
+
cleanup();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('concurrent writes to the same key never corrupt the file', async () => {
|
|
196
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
197
|
+
try {
|
|
198
|
+
await Promise.all(
|
|
199
|
+
Array.from({ length: 25 }, (_, i) =>
|
|
200
|
+
Promise.resolve().then(() =>
|
|
201
|
+
write('codebase-intelligence', 'race', { i }, {}, { cacheDir })
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
);
|
|
205
|
+
const hit = check('codebase-intelligence', 'race', { cacheDir });
|
|
206
|
+
assert.ok(hit, 'entry must be readable after concurrent writes');
|
|
207
|
+
assert.equal(typeof hit.value.i, 'number');
|
|
208
|
+
assert.ok(hit.value.i >= 0 && hit.value.i < 25);
|
|
209
|
+
} finally {
|
|
210
|
+
cleanup();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('path traversal in layer or key is rejected', () => {
|
|
215
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
216
|
+
try {
|
|
217
|
+
assert.throws(() => write('../escape', 'k', {}, {}, { cacheDir }), /escapes cache root/);
|
|
218
|
+
assert.throws(() => check('research', '../../etc/passwd', { cacheDir }), /escapes cache root/);
|
|
219
|
+
} finally {
|
|
220
|
+
cleanup();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('clear(layer) removes only that layer; clear() wipes all but README', () => {
|
|
225
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
226
|
+
try {
|
|
227
|
+
write('research/domain', 'a', { x: 1 }, {}, { cacheDir });
|
|
228
|
+
write('research/domain', 'b', { x: 2 }, {}, { cacheDir });
|
|
229
|
+
write('codebase-intelligence', 'c', { x: 3 }, {}, { cacheDir });
|
|
230
|
+
writeFileSync(join(cacheDir, 'README.md'), '# cache', 'utf8');
|
|
231
|
+
|
|
232
|
+
clear('research/domain', undefined, { cacheDir });
|
|
233
|
+
assert.equal(check('research/domain', 'a', { cacheDir }), null);
|
|
234
|
+
assert.ok(check('codebase-intelligence', 'c', { cacheDir }), 'other layer survives');
|
|
235
|
+
|
|
236
|
+
clear(undefined, undefined, { cacheDir });
|
|
237
|
+
assert.equal(check('codebase-intelligence', 'c', { cacheDir }), null);
|
|
238
|
+
assert.equal(readFileSync(join(cacheDir, 'README.md'), 'utf8'), '# cache', 'README preserved');
|
|
239
|
+
} finally {
|
|
240
|
+
cleanup();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('list reports layers with entry counts and bytes', () => {
|
|
245
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
246
|
+
try {
|
|
247
|
+
write('research/domain', 'a', { x: 1 }, {}, { cacheDir });
|
|
248
|
+
write('research/domain', 'b', { x: 2 }, {}, { cacheDir });
|
|
249
|
+
write('design-context', 'ds/t', { x: 3 }, {}, { cacheDir });
|
|
250
|
+
const layers = list({ cacheDir });
|
|
251
|
+
const byName = Object.fromEntries(layers.map((l) => [l.layer, l]));
|
|
252
|
+
assert.equal(byName.research.entries, 2);
|
|
253
|
+
assert.equal(byName['design-context'].entries, 1);
|
|
254
|
+
assert.ok(byName.research.bytes > 0);
|
|
255
|
+
} finally {
|
|
256
|
+
cleanup();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('stats accumulate hits and misses per layer', async () => {
|
|
261
|
+
const { cacheDir, cleanup } = tmpCache();
|
|
262
|
+
try {
|
|
263
|
+
const base = {
|
|
264
|
+
cacheDir,
|
|
265
|
+
layer: 'research/domain',
|
|
266
|
+
key: 'k',
|
|
267
|
+
ttlMs: 60_000,
|
|
268
|
+
compute: () => ({ v: 1 }),
|
|
269
|
+
};
|
|
270
|
+
await getOrCompute(base); // miss
|
|
271
|
+
await getOrCompute(base); // hit
|
|
272
|
+
await getOrCompute(base); // hit
|
|
273
|
+
const s = stats({ cacheDir });
|
|
274
|
+
assert.equal(s.misses['research/domain'], 1);
|
|
275
|
+
assert.equal(s.hits['research/domain'], 2);
|
|
276
|
+
} finally {
|
|
277
|
+
cleanup();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('resolveCacheRoot honors MAUDE_CACHE_DIR then CLAUDE_PROJECT_DIR', () => {
|
|
282
|
+
const prev = { m: process.env.MAUDE_CACHE_DIR, c: process.env.CLAUDE_PROJECT_DIR };
|
|
283
|
+
try {
|
|
284
|
+
process.env.MAUDE_CACHE_DIR = '/tmp/explicit-cache';
|
|
285
|
+
assert.equal(resolveCacheRoot(), '/tmp/explicit-cache');
|
|
286
|
+
Reflect.deleteProperty(process.env, 'MAUDE_CACHE_DIR');
|
|
287
|
+
process.env.CLAUDE_PROJECT_DIR = '/tmp/proj';
|
|
288
|
+
assert.equal(resolveCacheRoot(), join('/tmp/proj', '.ai/cache'));
|
|
289
|
+
} finally {
|
|
290
|
+
if (prev.m === undefined) Reflect.deleteProperty(process.env, 'MAUDE_CACHE_DIR');
|
|
291
|
+
else process.env.MAUDE_CACHE_DIR = prev.m;
|
|
292
|
+
if (prev.c === undefined) Reflect.deleteProperty(process.env, 'CLAUDE_PROJECT_DIR');
|
|
293
|
+
else process.env.CLAUDE_PROJECT_DIR = prev.c;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('sha8 is stable and 8 hex chars', () => {
|
|
298
|
+
const a = sha8('finance dashboard');
|
|
299
|
+
const b = sha8('finance dashboard');
|
|
300
|
+
assert.equal(a, b);
|
|
301
|
+
assert.match(a, /^[0-9a-f]{8}$/);
|
|
302
|
+
assert.notEqual(sha8('finance dashboard'), sha8('ecommerce checkout'));
|
|
303
|
+
});
|
package/cli/lib/design-link.mjs
CHANGED
|
@@ -10,19 +10,22 @@
|
|
|
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 {
|
|
18
|
+
import { BEGIN_MARKER, writeGitignoreBlock } from './gitignore-block.mjs';
|
|
19
|
+
import { addHub, getHub, isHubTrusted, normalizeUrl, removeHub, trustHub } from './hubs-config.mjs';
|
|
18
20
|
|
|
19
21
|
const DESIGN_CONFIG_PATH = '.design/config.json';
|
|
22
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
20
23
|
|
|
21
24
|
// ---------------------------------------------------------------- link
|
|
22
25
|
|
|
23
26
|
export async function runLink({ args, cwd = process.cwd(), forceAdopt = false }) {
|
|
24
27
|
const tail = args.slice(args.indexOf(forceAdopt ? 'adopt' : 'link') + 1);
|
|
25
|
-
const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force'] });
|
|
28
|
+
const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force', 'yes'] });
|
|
26
29
|
const url = positional[0];
|
|
27
30
|
const token = flags.token;
|
|
28
31
|
|
|
@@ -52,6 +55,46 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
|
|
|
52
55
|
process.exit(2);
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
const cfg = readDesignConfig(designConfigPath);
|
|
59
|
+
|
|
60
|
+
// DDR-054 F2/F4: linking to a non-loopback hub grants a remote actor the same
|
|
61
|
+
// write access to .design/ as the local user (hub-pushed content lands on
|
|
62
|
+
// disk verbatim, like `git pull` from a stranger). Require explicit trust for
|
|
63
|
+
// a new remote hub. Trust is checked PER-MACHINE (`isHubTrusted`), never from
|
|
64
|
+
// a committable repo file — a committed allowlist (or the committed
|
|
65
|
+
// `linkedHub.url` itself) would let a malicious PR pre-seed trust and bypass
|
|
66
|
+
// this gate (trust laundering). Loopback dev hubs are exempt.
|
|
67
|
+
const loopback = isLoopbackUrl(normUrl);
|
|
68
|
+
const alreadyTrusted = loopback || isHubTrusted(normUrl);
|
|
69
|
+
const manifest = adopt ? collectAdoptManifest(cwd) : [];
|
|
70
|
+
|
|
71
|
+
if (!alreadyTrusted) {
|
|
72
|
+
process.stderr.write(trustGateText(normUrl, adopt, manifest));
|
|
73
|
+
if (flags.yes) {
|
|
74
|
+
process.stderr.write(' → confirmed via --yes\n');
|
|
75
|
+
} else if (process.stdin.isTTY) {
|
|
76
|
+
const question = adopt
|
|
77
|
+
? `Link this repo to ${normUrl} AND push local design state up to it? [y/N] `
|
|
78
|
+
: `Link this repo to ${normUrl}? [y/N] `;
|
|
79
|
+
const ok = await promptYesNo(question);
|
|
80
|
+
if (!ok) {
|
|
81
|
+
process.stderr.write('maude design link: aborted — hub not trusted.\n');
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`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`
|
|
87
|
+
);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
// NOTE: trust is recorded AFTER the link succeeds (below), not here — an
|
|
91
|
+
// aborted link (probe fail without --force, config write throws) must not
|
|
92
|
+
// leave a persisted trust that silently skips the gate on a later re-link.
|
|
93
|
+
} else if (adopt && manifest.length > 0) {
|
|
94
|
+
// Trusted (e.g. localhost) adopt — surface the manifest informationally.
|
|
95
|
+
process.stdout.write(adoptManifestText(normUrl, manifest));
|
|
96
|
+
}
|
|
97
|
+
|
|
55
98
|
// Reachability probe — best-effort. Hub auth happens on WS upgrade (Task 4),
|
|
56
99
|
// so a successful /health response only tells us the hub is up + reachable.
|
|
57
100
|
// Token validity is verified when the sync agent connects for real.
|
|
@@ -63,11 +106,10 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
|
|
|
63
106
|
process.exit(1);
|
|
64
107
|
}
|
|
65
108
|
|
|
66
|
-
// Write hub side:
|
|
67
|
-
const hubRecord = addHub(normUrl, token);
|
|
109
|
+
// Write hub side: token (+ adopt attestation) in ~/.config/maude/hubs.json.
|
|
110
|
+
const hubRecord = addHub(normUrl, token, adopt ? { adoptedAt: Date.now() } : {});
|
|
68
111
|
|
|
69
112
|
// Write project side: linkedHub field on .design/config.json.
|
|
70
|
-
const cfg = readDesignConfig(designConfigPath);
|
|
71
113
|
const existing = cfg.linkedHub;
|
|
72
114
|
if (existing && !flags.force) {
|
|
73
115
|
process.stdout.write(
|
|
@@ -81,9 +123,22 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
|
|
|
81
123
|
};
|
|
82
124
|
writeDesignConfig(designConfigPath, cfg);
|
|
83
125
|
|
|
126
|
+
// Record per-machine trust only now that the link fully succeeded, so a
|
|
127
|
+
// later re-link to this hub won't re-prompt. Skipped for loopback + hubs
|
|
128
|
+
// already trusted on this machine.
|
|
129
|
+
if (!alreadyTrusted) trustHub(normUrl);
|
|
130
|
+
|
|
131
|
+
// Phase 9 Task 9 (DDR-056) — solo→linked transition. On --adopt, offer to add
|
|
132
|
+
// the design-runtime .gitignore block if it's missing, so the shared repo
|
|
133
|
+
// ignores per-machine runtime state. Default yes; --yes / non-TTY auto-adds.
|
|
134
|
+
if (adopt) {
|
|
135
|
+
await maybeWriteGitignoreBlock(cwd, !!flags.yes);
|
|
136
|
+
}
|
|
137
|
+
|
|
84
138
|
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
|
|
139
|
+
`[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
140
|
);
|
|
141
|
+
if (!loopback) process.stderr.write(linkedModeBanner());
|
|
87
142
|
}
|
|
88
143
|
|
|
89
144
|
// ---------------------------------------------------------------- adopt
|
|
@@ -153,6 +208,7 @@ export async function runStatus({ args, cwd = process.cwd() }) {
|
|
|
153
208
|
const url = cfg.linkedHub.url;
|
|
154
209
|
const hubRecord = getHub(url);
|
|
155
210
|
const probe = await probeHealth(url);
|
|
211
|
+
const sync = readSyncState(resolve(cwd, '.design', '_sync.json'));
|
|
156
212
|
|
|
157
213
|
const payload = {
|
|
158
214
|
mode: 'linked',
|
|
@@ -169,9 +225,9 @@ export async function runStatus({ args, cwd = process.cwd() }) {
|
|
|
169
225
|
authMode: probe.authMode,
|
|
170
226
|
}
|
|
171
227
|
: { reachable: false, error: probe.error },
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
sync: { agent: 'not
|
|
228
|
+
// Task 8 — the running sync agent writes `.design/_sync.json` with the live
|
|
229
|
+
// offline/online state, queued-op count, last sync, and conflict log.
|
|
230
|
+
sync: sync ?? { agent: 'idle', detail: 'no _sync.json — sync agent not running' },
|
|
175
231
|
};
|
|
176
232
|
|
|
177
233
|
if (flags.json) {
|
|
@@ -180,11 +236,55 @@ export async function runStatus({ args, cwd = process.cwd() }) {
|
|
|
180
236
|
}
|
|
181
237
|
|
|
182
238
|
const uptimeS = Math.round((probe.uptimeMs ?? 0) / 1000);
|
|
239
|
+
const syncLine = sync?.notSyncable
|
|
240
|
+
? `linked but 0 syncable canvases — ${sync.reason}`
|
|
241
|
+
: sync
|
|
242
|
+
? `${sync.state}${sync.sharedDoc ? ' [shared-doc]' : ''}${sync.queuedOps ? ` — ${sync.queuedOps} edit(s) queued` : ''}${
|
|
243
|
+
sync.lastSyncAt ? `, last sync ${new Date(sync.lastSyncAt).toISOString()}` : ''
|
|
244
|
+
}${sync.conflicts?.length ? `, ${sync.conflicts.length} conflict notice(s)` : ''}`
|
|
245
|
+
: 'idle (start `maude design serve` in linked mode)';
|
|
183
246
|
process.stdout.write(
|
|
184
|
-
`Maude design — linked mode\n hub URL: ${url}\n linked at: ${new Date(cfg.linkedHub.linkedAt).toISOString()}\n adopt mode: ${cfg.linkedHub.adopt ? 'yes (push-on-first-sync)' : 'no (hub-wins)'}\n token stored: ${hubRecord ? 'yes (~/.config/maude/hubs.json)' : "NO — re-run 'maude design link'"}\n hub status: ${probe.ok ? `up — v${probe.version}, ${uptimeS}s uptime, ${probe.tokenCount} token(s), ${probe.authMode}` : `UNREACHABLE — ${probe.error}`}\n sync agent:
|
|
247
|
+
`Maude design — linked mode\n hub URL: ${url}\n linked at: ${new Date(cfg.linkedHub.linkedAt).toISOString()}\n adopt mode: ${cfg.linkedHub.adopt ? 'yes (push-on-first-sync)' : 'no (hub-wins)'}\n token stored: ${hubRecord ? 'yes (~/.config/maude/hubs.json)' : "NO — re-run 'maude design link'"}\n hub status: ${probe.ok ? `up — v${probe.version}, ${uptimeS}s uptime, ${probe.tokenCount} token(s), ${probe.authMode}` : `UNREACHABLE — ${probe.error}`}\n sync agent: ${syncLine}\n`
|
|
185
248
|
);
|
|
186
249
|
}
|
|
187
250
|
|
|
251
|
+
/**
|
|
252
|
+
* On --adopt, add the design-runtime .gitignore block if missing. Prompts
|
|
253
|
+
* [Y/n] in a TTY (default yes); auto-adds with --yes or non-interactively.
|
|
254
|
+
* Idempotent — a repo that already has the block is left untouched.
|
|
255
|
+
*/
|
|
256
|
+
async function maybeWriteGitignoreBlock(cwd, assumeYes) {
|
|
257
|
+
const gitignorePath = resolve(cwd, '.gitignore');
|
|
258
|
+
const current = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
|
|
259
|
+
if (current.includes(BEGIN_MARKER)) return; // already present — nothing to do
|
|
260
|
+
|
|
261
|
+
let add = assumeYes || !process.stdin.isTTY;
|
|
262
|
+
if (!add) {
|
|
263
|
+
add = await promptYesNo(
|
|
264
|
+
'Add the Maude design-runtime .gitignore block (ignores per-machine runtime state)? [Y/n] ',
|
|
265
|
+
true
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (!add) {
|
|
269
|
+
process.stdout.write(
|
|
270
|
+
'[design link] skipped .gitignore block — add it later with another `maude design adopt`.\n'
|
|
271
|
+
);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const { action } = writeGitignoreBlock(cwd, { designRel: '.design' });
|
|
275
|
+
process.stdout.write(`[design link] .gitignore: ${action} maude design-runtime block.\n`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Read the sync agent's `_sync.json` runtime state, or null when absent. */
|
|
279
|
+
function readSyncState(p) {
|
|
280
|
+
if (!existsSync(p)) return null;
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
188
288
|
// ---------------------------------------------------------------- helpers
|
|
189
289
|
|
|
190
290
|
function readDesignConfig(path) {
|
|
@@ -214,3 +314,114 @@ async function probeHealth(url) {
|
|
|
214
314
|
return { ok: false, error: err.message };
|
|
215
315
|
}
|
|
216
316
|
}
|
|
317
|
+
|
|
318
|
+
// ----------------------------------------------------- trust gate (DDR-054 F2/F4)
|
|
319
|
+
|
|
320
|
+
/** True when the hub URL points at the local machine (no remote-write risk). */
|
|
321
|
+
function isLoopbackUrl(normUrl) {
|
|
322
|
+
try {
|
|
323
|
+
return LOOPBACK_HOSTS.has(new URL(normUrl).hostname);
|
|
324
|
+
} catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Promise-resolving prompt. Prompt + echo go to stderr (stdout is data).
|
|
331
|
+
* `defaultYes` controls how an empty answer resolves ([Y/n] vs [y/N]).
|
|
332
|
+
*/
|
|
333
|
+
function promptYesNo(question, defaultYes = false) {
|
|
334
|
+
return new Promise((res) => {
|
|
335
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
336
|
+
rl.question(question, (answer) => {
|
|
337
|
+
rl.close();
|
|
338
|
+
const trimmed = answer.trim();
|
|
339
|
+
if (trimmed === '') return res(defaultYes);
|
|
340
|
+
res(/^y(es)?$/i.test(trimmed));
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Files `--adopt` would push up to the hub: top-level canvases + annotation
|
|
347
|
+
* SVGs + comment JSON snapshots (the git-tracked sync surface per Task 9).
|
|
348
|
+
*/
|
|
349
|
+
function collectAdoptManifest(cwd) {
|
|
350
|
+
const base = resolve(cwd, '.design');
|
|
351
|
+
const files = [];
|
|
352
|
+
try {
|
|
353
|
+
for (const f of readdirSync(base)) {
|
|
354
|
+
if (f.endsWith('.html') || f.endsWith('.annotations.svg')) {
|
|
355
|
+
try {
|
|
356
|
+
files.push({ rel: `.design/${f}`, bytes: statSync(resolve(base, f)).size });
|
|
357
|
+
} catch {
|
|
358
|
+
/* skip unreadable */
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch {
|
|
363
|
+
/* no .design — manifest stays empty */
|
|
364
|
+
}
|
|
365
|
+
const commentsDir = resolve(base, '_comments');
|
|
366
|
+
try {
|
|
367
|
+
for (const f of readdirSync(commentsDir)) {
|
|
368
|
+
if (!f.endsWith('.json')) continue;
|
|
369
|
+
try {
|
|
370
|
+
files.push({
|
|
371
|
+
rel: `.design/_comments/${f}`,
|
|
372
|
+
bytes: statSync(resolve(commentsDir, f)).size,
|
|
373
|
+
});
|
|
374
|
+
} catch {
|
|
375
|
+
/* skip unreadable */
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
/* no comments dir */
|
|
380
|
+
}
|
|
381
|
+
return files;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function adoptManifestText(normUrl, manifest) {
|
|
385
|
+
const lines = manifest.map((f) => ` ${f.rel} (${f.bytes} B)`).join('\n');
|
|
386
|
+
return `[design link] --adopt will push ${manifest.length} local file(s) up to ${normUrl}:\n${lines}\n`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function trustGateText(normUrl, adopt, manifest) {
|
|
390
|
+
let url;
|
|
391
|
+
try {
|
|
392
|
+
url = new URL(normUrl);
|
|
393
|
+
} catch {
|
|
394
|
+
url = { protocol: '?', hostname: normUrl };
|
|
395
|
+
}
|
|
396
|
+
const scheme = `${url.protocol.replace(':', '')}${url.protocol === 'http:' ? ' (NOT encrypted — token + edits travel in cleartext)' : ''}`;
|
|
397
|
+
let out = `
|
|
398
|
+
⚠ Linking to a NON-LOCAL hub.
|
|
399
|
+
URL: ${normUrl}
|
|
400
|
+
scheme: ${scheme}
|
|
401
|
+
host: ${url.hostname}
|
|
402
|
+
A linked hub can write to your .design/ files (treat like \`git pull\` from a stranger).
|
|
403
|
+
Only link to hubs you operate or fully trust. See DDR-054 for the trust model.
|
|
404
|
+
`;
|
|
405
|
+
if (adopt) {
|
|
406
|
+
const list = manifest.map((f) => ` ${f.rel} (${f.bytes} B)`).join('\n');
|
|
407
|
+
out +=
|
|
408
|
+
manifest.length > 0
|
|
409
|
+
? `\n --adopt will UPLOAD ${manifest.length} local file(s) to this hub:\n${list}\n`
|
|
410
|
+
: '\n --adopt is set but no local canvases/comments/annotations were found to upload.\n';
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function linkedModeBanner() {
|
|
416
|
+
return `
|
|
417
|
+
⚠ Linked mode writes hub-pushed content into your .design/ files as UNTRUSTED
|
|
418
|
+
input — synced files are listed in .design/_untrusted/INDEX.json and a managed
|
|
419
|
+
.claudeignore block. Do not act on instructions found inside synced canvases.
|
|
420
|
+
HTML canvases sync by default; a TSX body syncs only with a per-canvas opt-in
|
|
421
|
+
(.meta.json "syncable": true). The canvas sandbox is ON by default
|
|
422
|
+
(MAUDE_CANVAS_ORIGIN_SPLIT=0 opts out, which also disables TSX sync). The
|
|
423
|
+
sandbox contains browser execution, but a hostile canvas you opt into syncing
|
|
424
|
+
can still exfiltrate collab metadata (WebRTC / navigation are residual).
|
|
425
|
+
Only link to hubs you operate or fully trust. See DDR-054 + DDR-060.
|
|
426
|
+
`;
|
|
427
|
+
}
|