@1agh/maude 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/cli/commands/design-link.test.mjs +53 -1
  2. package/cli/commands/hub.test.mjs +10 -9
  3. package/cli/lib/design-link.mjs +154 -7
  4. package/cli/lib/hubs-config.mjs +42 -4
  5. package/package.json +9 -9
  6. package/plugins/design/dev-server/bin/check-runtime-bundles.sh +125 -0
  7. package/plugins/design/dev-server/bin/runtime-health.sh +47 -6
  8. package/plugins/design/dev-server/build.ts +87 -7
  9. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  10. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  11. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  12. package/plugins/design/dev-server/client/app.jsx +37 -15
  13. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  14. package/plugins/design/dev-server/collab/registry.ts +51 -0
  15. package/plugins/design/dev-server/config.schema.json +20 -0
  16. package/plugins/design/dev-server/context.ts +7 -0
  17. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  18. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  19. package/plugins/design/dev-server/dist/runtime/.min-sizes.json +16 -0
  20. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +2 -6
  21. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +2 -6
  22. package/plugins/design/dev-server/dist/runtime/motion.js +4787 -8
  23. package/plugins/design/dev-server/dist/runtime/motion_react.js +9654 -7
  24. package/plugins/design/dev-server/dist/runtime/pixi-js.js +5865 -5469
  25. package/plugins/design/dev-server/dist/runtime/react-dom.js +4 -22
  26. package/plugins/design/dev-server/dist/runtime/react-dom_client.js +5 -23
  27. package/plugins/design/dev-server/dist/runtime/react.js +4 -22
  28. package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +4 -22
  29. package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +4 -22
  30. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +2 -6
  31. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +2 -6
  32. package/plugins/design/dev-server/dist/runtime/yjs.js +2 -6
  33. package/plugins/design/dev-server/dom-selection.ts +156 -0
  34. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  35. package/plugins/design/dev-server/input-router.tsx +99 -61
  36. package/plugins/design/dev-server/server.ts +18 -0
  37. package/plugins/design/dev-server/sync/agent.ts +323 -0
  38. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  39. package/plugins/design/dev-server/sync/codec.ts +169 -0
  40. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  41. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  42. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  43. package/plugins/design/dev-server/sync/index.ts +474 -0
  44. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  45. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  46. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  47. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  48. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  49. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  50. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  51. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  52. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  53. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  54. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  55. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  56. package/plugins/design/dev-server/use-collab.tsx +157 -13
  57. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  58. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  59. package/plugins/design/templates/_shell.html +15 -5
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
@@ -0,0 +1,169 @@
1
+ // Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
2
+ //
3
+ // The agent shuttles three classes of files between disk and the Y.Doc the
4
+ // HocuspocusProvider holds:
5
+ //
6
+ // `.design/<canvas>.html` -> Y.Text (Y_SYNC_TYPES.html)
7
+ // `.design/_comments/<slug>.json` -> Y.Array (Y_TYPES.comments — Phase 6)
8
+ // `.design/<slug>.annotations.svg` -> Y.Map.svg (Y_TYPES.annotations — Phase 5)
9
+ //
10
+ // v1.1 design decision (plan §"Key insight"): HTML body is treated as opaque
11
+ // Y.Text rather than structured Y.XmlFragment. Round-trip drift would
12
+ // otherwise cause infinite sync churn — every time we serialize the structured
13
+ // CRDT back to HTML the formatting whitespace would shift and re-enter the
14
+ // system as a new mutation. Structured CRDT (true element-level co-editing) is
15
+ // Phase 10 / v1.2.
16
+ //
17
+ // The "diff-aware" applyHtmlToDoc replaces the Y.Text contents using a
18
+ // longest-common-prefix + suffix elimination so other peers see a minimal
19
+ // op (e.g. "user changed character 42 only"). This isn't a true textual diff
20
+ // (no LCS), but it's drastically cheaper for typical edits than `delete-all +
21
+ // insert-all`, and crucially preserves cursor positions other peers may have
22
+ // in the unchanged regions.
23
+
24
+ import type * as Y from 'yjs';
25
+
26
+ import { Y_TYPES } from '../collab/persistence.ts';
27
+
28
+ /**
29
+ * Y.Doc shared-type names introduced by Task 4. Distinct namespace from
30
+ * Y_TYPES so existing comments / annotations stay untouched; new fields
31
+ * land here.
32
+ */
33
+ export const Y_SYNC_TYPES = {
34
+ /** The canvas HTML body, as opaque Y.Text. */
35
+ html: 'html',
36
+ } as const;
37
+
38
+ /**
39
+ * Hard caps on hub-pushed content (DDR-054 §2d — closes attacker F7). yjs
40
+ * has no upstream-enforced size cap; the codec is the consumer's guard.
41
+ * Mirrors the existing /_api/annotations 1 MB cap (api.ts) so the sync path
42
+ * doesn't bypass the HTTP-layer guard.
43
+ */
44
+ export const MAX_HTML_BYTES = 4 * 1024 * 1024;
45
+ export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
46
+ export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
47
+
48
+ function byteLengthUtf8(s: string): number {
49
+ return Buffer.byteLength(s, 'utf8');
50
+ }
51
+
52
+ /* ---------------------------------------------------------------- HTML */
53
+
54
+ export function htmlFromDoc(doc: Y.Doc): string {
55
+ return doc.getText(Y_SYNC_TYPES.html).toString();
56
+ }
57
+
58
+ /**
59
+ * Apply `next` to the Y.Text inside `doc`. Uses a minimal common-prefix /
60
+ * common-suffix replace so peers see a small op rather than a full replace.
61
+ *
62
+ * Pass `origin` as the transaction origin so a downstream observer can
63
+ * distinguish self-originated updates from peer/remote ones.
64
+ */
65
+ export function applyHtmlToDoc(doc: Y.Doc, next: string, origin?: unknown): boolean {
66
+ if (byteLengthUtf8(next) > MAX_HTML_BYTES) {
67
+ console.warn(
68
+ `[sync/codec] refusing HTML apply > ${MAX_HTML_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
69
+ );
70
+ return false;
71
+ }
72
+ const yText = doc.getText(Y_SYNC_TYPES.html);
73
+ const current = yText.toString();
74
+ if (current === next) return false;
75
+
76
+ // Find longest common prefix.
77
+ let prefix = 0;
78
+ const maxPrefix = Math.min(current.length, next.length);
79
+ while (prefix < maxPrefix && current.charCodeAt(prefix) === next.charCodeAt(prefix)) {
80
+ prefix++;
81
+ }
82
+ // Find longest common suffix that doesn't overlap the prefix.
83
+ let suffix = 0;
84
+ const maxSuffix = Math.min(current.length - prefix, next.length - prefix);
85
+ while (
86
+ suffix < maxSuffix &&
87
+ current.charCodeAt(current.length - 1 - suffix) === next.charCodeAt(next.length - 1 - suffix)
88
+ ) {
89
+ suffix++;
90
+ }
91
+
92
+ const deleteLen = current.length - prefix - suffix;
93
+ const insertStr = next.slice(prefix, next.length - suffix);
94
+
95
+ doc.transact(() => {
96
+ if (deleteLen > 0) yText.delete(prefix, deleteLen);
97
+ if (insertStr.length > 0) yText.insert(prefix, insertStr);
98
+ }, origin);
99
+
100
+ return true;
101
+ }
102
+
103
+ /* ---------------------------------------------------------------- comments */
104
+
105
+ /**
106
+ * Comments JSON payload — opaque to the codec, just the array of objects the
107
+ * Y.Array holds.
108
+ */
109
+ export type CommentsSnapshot = unknown[];
110
+
111
+ export function commentsFromDoc(doc: Y.Doc): CommentsSnapshot {
112
+ const arr = doc.getArray(Y_TYPES.comments);
113
+ return arr.toArray();
114
+ }
115
+
116
+ export function applyCommentsToDoc(doc: Y.Doc, next: CommentsSnapshot, origin?: unknown): boolean {
117
+ const arr = doc.getArray(Y_TYPES.comments);
118
+ // Comments are LWW on the JSON file (the snapshot is the source of truth);
119
+ // collapse Y.Array to the new state. For v1.1 we just replace wholesale —
120
+ // structural comment-level merge is deferred along with structured HTML.
121
+ // Check whether anything actually changed to avoid no-op transactions
122
+ // (transactions still fire `update` events, which would re-enter the loop).
123
+ const before = JSON.stringify(arr.toArray());
124
+ const after = JSON.stringify(next);
125
+ if (byteLengthUtf8(after) > MAX_COMMENTS_BYTES) {
126
+ console.warn(
127
+ `[sync/codec] refusing comments apply > ${MAX_COMMENTS_BYTES} bytes (got ${byteLengthUtf8(after)}). DDR-054 §2d.`
128
+ );
129
+ return false;
130
+ }
131
+ if (before === after) return false;
132
+
133
+ doc.transact(() => {
134
+ if (arr.length > 0) arr.delete(0, arr.length);
135
+ if (next.length > 0) arr.push(next);
136
+ }, origin);
137
+ return true;
138
+ }
139
+
140
+ /* ---------------------------------------------------------------- annotations */
141
+
142
+ /** Returns the annotations SVG string, or null if unset. */
143
+ export function annotationsFromDoc(doc: Y.Doc): string | null {
144
+ const map = doc.getMap<unknown>(Y_TYPES.annotations);
145
+ const svg = map.get('svg');
146
+ return typeof svg === 'string' ? svg : null;
147
+ }
148
+
149
+ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
150
+ if (next !== null && byteLengthUtf8(next) > MAX_ANNOTATIONS_BYTES) {
151
+ console.warn(
152
+ `[sync/codec] refusing annotations apply > ${MAX_ANNOTATIONS_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
153
+ );
154
+ return false;
155
+ }
156
+ const map = doc.getMap<unknown>(Y_TYPES.annotations);
157
+ const current = map.get('svg');
158
+ const currentStr = typeof current === 'string' ? current : null;
159
+ if (currentStr === next) return false;
160
+
161
+ doc.transact(() => {
162
+ if (next === null || next === '') {
163
+ map.delete('svg');
164
+ } else {
165
+ map.set('svg', next);
166
+ }
167
+ }, origin);
168
+ return true;
169
+ }
@@ -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
+ }