@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
@@ -0,0 +1,108 @@
1
+ // Echo prevention for the bidirectional file sync agent (Phase 9 Task 4).
2
+ //
3
+ // Problem: when the sync agent writes a file from a remote Y.Doc update, the
4
+ // local fs.watch fires immediately afterwards. Naively, that watch event would
5
+ // be interpreted as a user edit, get pushed back to the Y.Doc, broadcast back
6
+ // to every peer (including ourselves), and trigger another write — an infinite
7
+ // echo loop.
8
+ //
9
+ // Solution: before writing a file from a remote-origin update, the agent
10
+ // records `sha256(bytes)` in a small TTL map. When the fs.watch fires for that
11
+ // path, the agent computes sha256 of the on-disk bytes and asks the guard
12
+ // `consume(path, hash)`. If the hash matches a pending entry that hasn't
13
+ // expired, the event is dropped (it's our own write echoing back). If no
14
+ // match, the event is treated as a real user/Claude edit and pushed up to the
15
+ // Y.Doc.
16
+ //
17
+ // Inspired by Syncthing's "weak hash + sequence number" approach (plan Task 4
18
+ // "Pattern" bullet). The expiry window is intentionally short — 1500ms covers
19
+ // realistic fs.watch debounce on macOS / Linux / Windows without holding state
20
+ // long enough to falsely silence genuine user edits that happen to produce the
21
+ // same hash (e.g. typing the same character back).
22
+
23
+ import { createHash } from 'node:crypto';
24
+
25
+ export const ECHO_TTL_MS = 1500;
26
+
27
+ /**
28
+ * Compute a hex SHA-256 of the given bytes. Stable across platforms; the agent
29
+ * uses this as the echo-detection fingerprint.
30
+ */
31
+ export function hashBytes(bytes: Uint8Array | string): string {
32
+ const hash = createHash('sha256');
33
+ hash.update(bytes);
34
+ return hash.digest('hex');
35
+ }
36
+
37
+ interface PendingEcho {
38
+ hash: string;
39
+ expiresAt: number;
40
+ }
41
+
42
+ export interface EchoGuard {
43
+ /**
44
+ * Record that we just wrote `hash` to `path` from a remote-origin update.
45
+ * The next fs.watch event for that path within ECHO_TTL_MS with a matching
46
+ * hash will be treated as an echo and dropped.
47
+ *
48
+ * Multiple writes to the same path stack as a small per-path queue — useful
49
+ * when remote updates fire in rapid succession and the fs.watch coalesces
50
+ * them. consume() pops the oldest matching entry.
51
+ */
52
+ record(path: string, hash: string, now?: number): void;
53
+
54
+ /**
55
+ * Returns true if `hash` matches a pending entry for `path` (i.e. this
56
+ * fs.watch event is the echo of our own write). Pops the entry on match so
57
+ * a later, genuine user edit producing the same bytes still goes through.
58
+ */
59
+ consume(path: string, hash: string, now?: number): boolean;
60
+
61
+ /** Drop expired entries. Idempotent. */
62
+ sweep(now?: number): void;
63
+
64
+ /** Test/inspection: count of currently pending entries across all paths. */
65
+ size(): number;
66
+ }
67
+
68
+ export function createEchoGuard(ttlMs: number = ECHO_TTL_MS): EchoGuard {
69
+ const pending = new Map<string, PendingEcho[]>();
70
+
71
+ function sweep(now: number = Date.now()): void {
72
+ for (const [path, queue] of pending) {
73
+ const alive = queue.filter((e) => e.expiresAt > now);
74
+ if (alive.length === 0) pending.delete(path);
75
+ else if (alive.length !== queue.length) pending.set(path, alive);
76
+ }
77
+ }
78
+
79
+ return {
80
+ record(path, hash, now = Date.now()) {
81
+ sweep(now);
82
+ const expiresAt = now + ttlMs;
83
+ const queue = pending.get(path) ?? [];
84
+ queue.push({ hash, expiresAt });
85
+ pending.set(path, queue);
86
+ },
87
+
88
+ consume(path, hash, now = Date.now()) {
89
+ sweep(now);
90
+ const queue = pending.get(path);
91
+ if (!queue || queue.length === 0) return false;
92
+ const idx = queue.findIndex((e) => e.hash === hash);
93
+ if (idx === -1) return false;
94
+ queue.splice(idx, 1);
95
+ if (queue.length === 0) pending.delete(path);
96
+ else pending.set(path, queue);
97
+ return true;
98
+ },
99
+
100
+ sweep,
101
+
102
+ size() {
103
+ let n = 0;
104
+ for (const queue of pending.values()) n += queue.length;
105
+ return n;
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,160 @@
1
+ // Debounced file reader for the bidirectional file sync agent (Phase 9 Task 4).
2
+ //
3
+ // The dev-server already has a recursive fs.watch (fs-watch.ts) firing
4
+ // `fs:html` / `fs:json` / `fs:any` bus events with ~50ms debounce. That's
5
+ // great for HMR (the browser wants the freshest possible reload), but
6
+ // the sync agent needs a stricter quiet-window: when a user edit drops onto
7
+ // disk in a flurry (Vim's atomic save: rename twice; or a codemod hammering
8
+ // 100 writes in 10ms), we want to react ONCE per quiescent path so the
9
+ // resulting Y.Doc op is the final state, not 100 intermediate ones.
10
+ //
11
+ // FsReader is that layer. Subscribe via `notify(path)`; after the configured
12
+ // quiet window (default 250ms per plan Task 4 step 2) the reader reads bytes
13
+ // from disk, computes a SHA-256, and invokes the per-path handler.
14
+ //
15
+ // Stateless beyond per-path timers — no fs.watch of its own. The agent wires
16
+ // `ctx.bus.on('fs:any', (path) => reader.notify(path))` once at boot.
17
+
18
+ import { readFile } from 'node:fs/promises';
19
+ import { isAbsolute, join as nodeJoin, resolve as nodeResolve, sep as pathSep } from 'node:path';
20
+
21
+ import { hashBytes } from './echo-guard.ts';
22
+
23
+ export const DEFAULT_QUIET_MS = 250;
24
+
25
+ export interface FsReadEvent {
26
+ /** Path relative to designRoot — same shape the bus emits. */
27
+ path: string;
28
+ /** Raw bytes on disk after the quiet window. */
29
+ bytes: Uint8Array;
30
+ /** sha256(bytes), hex. Same fingerprint the echo guard records. */
31
+ hash: string;
32
+ }
33
+
34
+ export type FsReaderHandler = (evt: FsReadEvent) => void | Promise<void>;
35
+
36
+ export interface FsReader {
37
+ /**
38
+ * Record an fs event for `relPath`. Resets the per-path quiet timer; the
39
+ * handler fires after `quietMs` of no further notify() calls for the same
40
+ * path. Passing an absolute path is fine — the reader uses it verbatim when
41
+ * resolving against the read root.
42
+ */
43
+ notify(relPath: string): void;
44
+
45
+ /** Number of paths currently waiting on a debounce timer. Test only. */
46
+ pending(): number;
47
+
48
+ /** Force-fire all pending timers immediately. Test only. */
49
+ flush(): Promise<void>;
50
+
51
+ /** Cancel all pending timers and drop subscriptions. */
52
+ stop(): void;
53
+ }
54
+
55
+ export interface FsReaderOptions {
56
+ /** Absolute path the relPath should be resolved against. */
57
+ rootDir: string;
58
+ /** Per-path quiet window. Defaults to DEFAULT_QUIET_MS. */
59
+ quietMs?: number;
60
+ /** Predicate — if false, the path is ignored. Used by the agent to filter
61
+ * to just the file types the sync layer cares about (.html, .json, .svg). */
62
+ accept: (relPath: string) => boolean;
63
+ /** Called once per quiescence with bytes + hash. */
64
+ onRead: FsReaderHandler;
65
+ /** Called when a file disappeared during the quiet window. */
66
+ onDeleted?: (relPath: string) => void;
67
+ /** Path join helper (injected for tests). Defaults to node:path.join. */
68
+ joinPath?: (a: string, b: string) => string;
69
+ }
70
+
71
+ export function createFsReader(opts: FsReaderOptions): FsReader {
72
+ const quiet = opts.quietMs ?? DEFAULT_QUIET_MS;
73
+ const join = opts.joinPath ?? defaultJoin;
74
+
75
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
76
+ let stopped = false;
77
+
78
+ async function fire(relPath: string): Promise<void> {
79
+ timers.delete(relPath);
80
+ if (stopped) return;
81
+ const abs = join(opts.rootDir, relPath);
82
+ // Defense-in-depth — confirm the joined path stays under rootDir. The agent's
83
+ // absolute-path equality check in applyFromFs already constrains the impact,
84
+ // but adding the guard prevents future-refactor regressions from re-opening
85
+ // the surface. DDR-054 §2f (defender M1 + attacker F12).
86
+ const safeRoot = nodeResolve(opts.rootDir);
87
+ const norm = nodeResolve(abs);
88
+ if (norm !== safeRoot && !norm.startsWith(safeRoot + pathSep)) {
89
+ // Silent drop — never read out-of-tree files.
90
+ return;
91
+ }
92
+ let bytes: Buffer;
93
+ try {
94
+ bytes = await readFile(abs);
95
+ } catch (err) {
96
+ if (isNotFound(err)) {
97
+ opts.onDeleted?.(relPath);
98
+ return;
99
+ }
100
+ // Best-effort — log and skip; another fs.watch event will re-fire.
101
+ console.warn('[sync/fs-mirror] read failed for', relPath, '-', describeErr(err));
102
+ return;
103
+ }
104
+ const u8 = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
105
+ const hash = hashBytes(u8);
106
+ try {
107
+ await opts.onRead({ path: relPath, bytes: u8, hash });
108
+ } catch (err) {
109
+ console.error('[sync/fs-mirror] handler threw for', relPath, '-', describeErr(err));
110
+ }
111
+ }
112
+
113
+ return {
114
+ notify(relPath) {
115
+ if (stopped) return;
116
+ // Reject obvious traversal attempts before the timer is even armed.
117
+ // DDR-054 §2f (defender M1 + attacker F12).
118
+ if (relPath.includes('..') || isAbsolute(relPath)) return;
119
+ if (!opts.accept(relPath)) return;
120
+ const prev = timers.get(relPath);
121
+ if (prev) clearTimeout(prev);
122
+ const t = setTimeout(() => {
123
+ void fire(relPath);
124
+ }, quiet);
125
+ timers.set(relPath, t);
126
+ },
127
+
128
+ pending() {
129
+ return timers.size;
130
+ },
131
+
132
+ async flush() {
133
+ const paths = Array.from(timers.keys());
134
+ for (const p of paths) {
135
+ const t = timers.get(p);
136
+ if (t) clearTimeout(t);
137
+ }
138
+ await Promise.all(paths.map((p) => fire(p)));
139
+ },
140
+
141
+ stop() {
142
+ stopped = true;
143
+ for (const t of timers.values()) clearTimeout(t);
144
+ timers.clear();
145
+ },
146
+ };
147
+ }
148
+
149
+ function defaultJoin(a: string, b: string): string {
150
+ return nodeJoin(a, b);
151
+ }
152
+
153
+ function isNotFound(err: unknown): boolean {
154
+ return !!err && typeof err === 'object' && (err as { code?: string }).code === 'ENOENT';
155
+ }
156
+
157
+ function describeErr(err: unknown): string {
158
+ if (err instanceof Error) return err.message;
159
+ return String(err);
160
+ }
@@ -0,0 +1,87 @@
1
+ // Read-only mirror of cli/lib/hubs-config.mjs for the dev-server runtime.
2
+ //
3
+ // The CLI owns writes (`maude design link` adds + removes entries); the
4
+ // dev-server only ever reads. This module deliberately re-implements the
5
+ // reader instead of importing from cli/lib so the Bun-side ts compile chain
6
+ // doesn't pull in the .mjs CLI surface.
7
+
8
+ import { existsSync, readFileSync, statSync } from 'node:fs';
9
+ import { homedir, platform } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ export interface HubRecord {
13
+ token: string;
14
+ linkedAt: number;
15
+ }
16
+
17
+ export interface HubsConfig {
18
+ hubs: Record<string, HubRecord>;
19
+ }
20
+
21
+ /** Resolve the on-disk path to hubs.json (matches cli/lib/hubs-config.mjs). */
22
+ export function hubsConfigPath(): string {
23
+ if (process.env.HUBS_CONFIG_PATH) return process.env.HUBS_CONFIG_PATH;
24
+ const xdg = process.env.XDG_CONFIG_HOME;
25
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
26
+ return join(base, 'maude', 'hubs.json');
27
+ }
28
+
29
+ /** Normalize a hub URL — trim trailing slash, lower-case scheme + host. */
30
+ export function normalizeUrl(url: string): string {
31
+ const u = new URL(url);
32
+ u.protocol = u.protocol.toLowerCase();
33
+ u.hostname = u.hostname.toLowerCase();
34
+ let str = u.toString();
35
+ if (str.endsWith('/') && u.pathname === '/') str = str.slice(0, -1);
36
+ return str;
37
+ }
38
+
39
+ export function loadHubsConfig(): HubsConfig {
40
+ const path = hubsConfigPath();
41
+ if (!existsSync(path)) return { hubs: {} };
42
+ warnIfWorldOrGroupReadable(path);
43
+ try {
44
+ const raw = readFileSync(path, 'utf8');
45
+ const parsed = JSON.parse(raw);
46
+ if (!parsed || typeof parsed.hubs !== 'object' || parsed.hubs === null) {
47
+ return { hubs: {} };
48
+ }
49
+ return parsed;
50
+ } catch {
51
+ return { hubs: {} };
52
+ }
53
+ }
54
+
55
+ // DDR-054 §2h (attacker F15). The CLI writes hubs.json with mode 0600. If a
56
+ // user later opens the file with an editor that resets permissions, or syncs
57
+ // it from a different host, the dev-server warns once on read. Non-blocking —
58
+ // Windows + funky-umask hosts get a polite nudge rather than a hard refusal.
59
+ let _modeWarnedFor: string | null = null;
60
+ function warnIfWorldOrGroupReadable(path: string): void {
61
+ if (platform() === 'win32') return; // POSIX-mode semantics don't apply
62
+ if (_modeWarnedFor === path) return; // warn once per process
63
+ try {
64
+ const stats = statSync(path);
65
+ const mode = stats.mode & 0o777;
66
+ if ((mode & 0o077) !== 0) {
67
+ console.warn(
68
+ `[sync] ${path} is mode ${mode.toString(8)} — recommend 'chmod 600 ${path}' (only owner can read hub tokens).`
69
+ );
70
+ _modeWarnedFor = path;
71
+ }
72
+ } catch {
73
+ /* statSync raced with a delete — next read will retry */
74
+ }
75
+ }
76
+
77
+ /** Look up a token for `url`. Returns null when no entry exists. */
78
+ export function getHubToken(url: string): string | null {
79
+ try {
80
+ const norm = normalizeUrl(url);
81
+ const cfg = loadHubsConfig();
82
+ const record = cfg.hubs[norm];
83
+ return record ? record.token : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }