@1agh/maude 0.27.0 → 0.28.1

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 (143) hide show
  1. package/cli/commands/design-link.test.mjs +46 -0
  2. package/cli/commands/design.mjs +69 -3
  3. package/cli/commands/design.test.mjs +97 -1
  4. package/cli/commands/doctor.mjs +110 -5
  5. package/cli/commands/doctor.test.mjs +52 -0
  6. package/cli/lib/cache.mjs +1 -1
  7. package/cli/lib/config-lint.mjs +1 -1
  8. package/cli/lib/copy-tree.mjs +1 -1
  9. package/cli/lib/design-link.mjs +39 -9
  10. package/cli/lib/flow-design-integration.test.mjs +1 -1
  11. package/cli/lib/gitignore-block.test.mjs +2 -2
  12. package/cli/lib/stack-detect.mjs +1 -1
  13. package/package.json +9 -9
  14. package/plugins/design/dev-server/activity.ts +256 -0
  15. package/plugins/design/dev-server/ai-banner.tsx +4 -0
  16. package/plugins/design/dev-server/annotations-context-toolbar.tsx +1 -1
  17. package/plugins/design/dev-server/annotations-layer.tsx +3 -3
  18. package/plugins/design/dev-server/api.ts +1 -1
  19. package/plugins/design/dev-server/artboard-activity-overlay.tsx +67 -0
  20. package/plugins/design/dev-server/bin/scenario-report.mjs +1 -1
  21. package/plugins/design/dev-server/bin/server-up.sh +102 -9
  22. package/plugins/design/dev-server/bin/visual-sanity.sh +19 -3
  23. package/plugins/design/dev-server/canvas-comment-mount.tsx +165 -10
  24. package/plugins/design/dev-server/canvas-cursors.ts +1 -1
  25. package/plugins/design/dev-server/canvas-lib.tsx +72 -36
  26. package/plugins/design/dev-server/canvas-shell.tsx +1 -1
  27. package/plugins/design/dev-server/collab/awareness-bridge.ts +1 -1
  28. package/plugins/design/dev-server/collab/index.ts +4 -4
  29. package/plugins/design/dev-server/collab/persistence.ts +1 -1
  30. package/plugins/design/dev-server/config.schema.json +2 -1
  31. package/plugins/design/dev-server/context-menu.tsx +1 -1
  32. package/plugins/design/dev-server/dist/client.bundle.js +19 -19
  33. package/plugins/design/dev-server/dist/comment-mount.js +84 -6
  34. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +1 -620
  35. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +1 -604
  36. package/plugins/design/dev-server/dist/runtime/motion.js +4 -4944
  37. package/plugins/design/dev-server/dist/runtime/motion_react.js +10 -10054
  38. package/plugins/design/dev-server/dist/runtime/pixi-js.js +2775 -52482
  39. package/plugins/design/dev-server/dist/runtime/react-dom.js +1 -231
  40. package/plugins/design/dev-server/dist/runtime/react-dom_client.js +9 -10357
  41. package/plugins/design/dev-server/dist/runtime/react.js +1 -535
  42. package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +1 -60
  43. package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +1 -85
  44. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +1 -376
  45. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +1 -100
  46. package/plugins/design/dev-server/dist/runtime/yjs.js +5 -6687
  47. package/plugins/design/dev-server/draw/brush.ts +3 -3
  48. package/plugins/design/dev-server/draw/index.ts +5 -5
  49. package/plugins/design/dev-server/draw/layout.ts +1 -1
  50. package/plugins/design/dev-server/draw/test/geometry.test.ts +1 -1
  51. package/plugins/design/dev-server/draw/test/layout.test.ts +1 -1
  52. package/plugins/design/dev-server/draw/test/primitives.test.ts +1 -1
  53. package/plugins/design/dev-server/draw/test/serialize.test.ts +1 -1
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  55. package/plugins/design/dev-server/examples/perf-100-artboards.tsx +1 -2
  56. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  57. package/plugins/design/dev-server/exporters/html.ts +1 -1
  58. package/plugins/design/dev-server/exporters/index.ts +2 -2
  59. package/plugins/design/dev-server/exporters/pdf.ts +1 -1
  60. package/plugins/design/dev-server/exporters/png.ts +1 -1
  61. package/plugins/design/dev-server/exporters/pptx.ts +1 -1
  62. package/plugins/design/dev-server/exporters/svg.ts +1 -1
  63. package/plugins/design/dev-server/http.ts +1 -1
  64. package/plugins/design/dev-server/inspect.ts +31 -1
  65. package/plugins/design/dev-server/marquee-overlay.tsx +1 -1
  66. package/plugins/design/dev-server/participants-chrome.tsx +86 -1
  67. package/plugins/design/dev-server/server.ts +11 -2
  68. package/plugins/design/dev-server/sync/agent.ts +1 -1
  69. package/plugins/design/dev-server/sync/index.ts +29 -24
  70. package/plugins/design/dev-server/sync/materialize.ts +2 -2
  71. package/plugins/design/dev-server/sync/migrate-seed.ts +1 -1
  72. package/plugins/design/dev-server/sync/projection.ts +3 -3
  73. package/plugins/design/dev-server/test/activity.test.ts +195 -0
  74. package/plugins/design/dev-server/test/ai-activity.test.ts +1 -1
  75. package/plugins/design/dev-server/test/annotations-api.test.ts +1 -2
  76. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +2 -2
  77. package/plugins/design/dev-server/test/annotations-layer.test.ts +6 -6
  78. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +1 -1
  79. package/plugins/design/dev-server/test/artboard-activity-overlay.test.tsx +56 -0
  80. package/plugins/design/dev-server/test/boot-self-heal.test.ts +1 -1
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +1 -1
  82. package/plugins/design/dev-server/test/canvas-edit.test.ts +1 -1
  83. package/plugins/design/dev-server/test/canvas-header.test.ts +1 -1
  84. package/plugins/design/dev-server/test/canvas-hmr-runtime.test.tsx +114 -0
  85. package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +1 -1
  86. package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +1 -2
  87. package/plugins/design/dev-server/test/canvas-meta-api.test.ts +1 -2
  88. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +1 -2
  89. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +1 -1
  90. package/plugins/design/dev-server/test/collab-bridge.test.ts +1 -1
  91. package/plugins/design/dev-server/test/collab-protocol.test.ts +2 -2
  92. package/plugins/design/dev-server/test/collab-room.test.ts +1 -1
  93. package/plugins/design/dev-server/test/collab-stress.test.ts +1 -1
  94. package/plugins/design/dev-server/test/comments-api.test.ts +1 -2
  95. package/plugins/design/dev-server/test/compile-entry.test.ts +1 -2
  96. package/plugins/design/dev-server/test/exporters/canva.test.ts +1 -2
  97. package/plugins/design/dev-server/test/exporters/history.test.ts +1 -2
  98. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +1 -1
  99. package/plugins/design/dev-server/test/exporters/scope.test.ts +1 -2
  100. package/plugins/design/dev-server/test/exporters/zip.test.ts +1 -2
  101. package/plugins/design/dev-server/test/git-lifecycle.test.ts +1 -2
  102. package/plugins/design/dev-server/test/handoff-static-frames.test.ts +1 -1
  103. package/plugins/design/dev-server/test/handoff.test.ts +1 -1
  104. package/plugins/design/dev-server/test/hmr-broadcast.test.ts +1 -1
  105. package/plugins/design/dev-server/test/input-router.test.ts +2 -2
  106. package/plugins/design/dev-server/test/locator.test.ts +1 -1
  107. package/plugins/design/dev-server/test/runtime-bundle.test.ts +1 -1
  108. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +1 -1
  109. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +2 -3
  110. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +1 -1
  111. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +1 -1
  112. package/plugins/design/dev-server/test/sync-agent.test.ts +2 -3
  113. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +2 -3
  114. package/plugins/design/dev-server/test/sync-codec.test.ts +1 -1
  115. package/plugins/design/dev-server/test/sync-connection-state.test.ts +1 -1
  116. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +1 -1
  117. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +1 -2
  118. package/plugins/design/dev-server/test/sync-hardening.test.ts +4 -5
  119. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +1 -2
  120. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +1 -1
  121. package/plugins/design/dev-server/test/sync-runtime.test.ts +37 -14
  122. package/plugins/design/dev-server/test/sync-status.test.ts +1 -1
  123. package/plugins/design/dev-server/test/sync-untrusted.test.ts +1 -2
  124. package/plugins/design/dev-server/test/undo-stack.test.ts +2 -2
  125. package/plugins/design/dev-server/test/use-agent-presence.test.tsx +117 -0
  126. package/plugins/design/dev-server/test/use-artboard-drag.test.ts +3 -3
  127. package/plugins/design/dev-server/test/use-canvas-activity.test.tsx +206 -0
  128. package/plugins/design/dev-server/test/use-selection-set.test.tsx +1 -1
  129. package/plugins/design/dev-server/test/use-snap-guides.test.ts +1 -1
  130. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +2 -2
  131. package/plugins/design/dev-server/use-agent-presence.tsx +244 -0
  132. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -5
  133. package/plugins/design/dev-server/use-annotation-selection.tsx +2 -6
  134. package/plugins/design/dev-server/use-annotations-visibility.tsx +1 -1
  135. package/plugins/design/dev-server/use-artboard-drag.tsx +1 -1
  136. package/plugins/design/dev-server/use-canvas-activity.tsx +252 -0
  137. package/plugins/design/dev-server/use-collab.tsx +3 -4
  138. package/plugins/design/dev-server/use-selection-set.tsx +1 -1
  139. package/plugins/design/dev-server/use-tool-mode.tsx +1 -1
  140. package/plugins/design/dev-server/use-undo-stack.tsx +4 -5
  141. package/plugins/design/dev-server/ws.ts +21 -2
  142. package/plugins/design/templates/_shell.html +108 -3
  143. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +18 -3
@@ -136,6 +136,52 @@ test('adopt subcommand is an alias of link --adopt', async () => {
136
136
  cleanup();
137
137
  });
138
138
 
139
+ test('DDR-079: link without a flag omits syncTsx (= default ON, restore-proof)', async () => {
140
+ const res = await runCli(['design', 'link', URL, '--token', 'mau_test']);
141
+ assert.equal(res.status, 0, res.stderr);
142
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
143
+ // We never write syncTsx:true to encode the default — absence means on.
144
+ assert.equal(cfg.linkedHub.syncTsx, undefined);
145
+ assert.match(res.stdout, /TSX sync: on by default/);
146
+ cleanup();
147
+ });
148
+
149
+ test('DDR-079: link --no-sync-tsx records syncTsx:false (project opt-out)', async () => {
150
+ const res = await runCli(['design', 'link', URL, '--token', 'mau_test', '--no-sync-tsx']);
151
+ assert.equal(res.status, 0, res.stderr);
152
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
153
+ assert.equal(cfg.linkedHub.syncTsx, false);
154
+ assert.match(res.stdout, /TSX sync: off \(opted out/);
155
+ cleanup();
156
+ });
157
+
158
+ test('DDR-079: link --sync-tsx pins syncTsx:true explicitly', async () => {
159
+ const res = await runCli(['design', 'link', URL, '--token', 'mau_test', '--sync-tsx']);
160
+ assert.equal(res.status, 0, res.stderr);
161
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
162
+ assert.equal(cfg.linkedHub.syncTsx, true);
163
+ cleanup();
164
+ });
165
+
166
+ test('DDR-079: status surfaces the migration advisory when syncTsx is unset', async () => {
167
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
168
+ const res = await runCli(['design', 'status']);
169
+ assert.equal(res.status, 0, res.stderr);
170
+ assert.match(res.stdout, /TSX sync:\s+on \(default/);
171
+ assert.match(res.stdout, /syncTsx is not set/);
172
+ cleanup();
173
+ });
174
+
175
+ test('DDR-079: status --json reports effective + explicit syncTsx', async () => {
176
+ await runCli(['design', 'link', URL, '--token', 'mau_test', '--no-sync-tsx']);
177
+ const res = await runCli(['design', 'status', '--json']);
178
+ assert.equal(res.status, 0, res.stderr);
179
+ const payload = JSON.parse(res.stdout);
180
+ assert.equal(payload.syncTsx, false);
181
+ assert.equal(payload.syncTsxExplicit, false);
182
+ cleanup();
183
+ });
184
+
139
185
  test('link against unreachable URL exits 1 unless --force', async () => {
140
186
  const dead = 'http://127.0.0.1:1';
141
187
  const fail = await runCli(['design', 'link', dead, '--token', 'mau_test']);
@@ -1,6 +1,6 @@
1
1
  import { execSync, spawn, spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
2
+ import { chmodSync, existsSync, readFileSync } from 'node:fs';
3
+ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { parseArgs } from '../lib/argv.mjs';
@@ -42,6 +42,11 @@ const BIN_VERBS = new Set([
42
42
  'svg-optimize',
43
43
  ]);
44
44
 
45
+ // Bin verbs that boot the dev-server (directly, or by shelling into server-up.sh).
46
+ // For these we resolve the compiled platform binary and hand its path to the
47
+ // helper via MAUDE_DEV_SERVER_BIN — see runBinDispatch + DDR-084.
48
+ const BOOT_VERBS = new Set(['server-up', 'visual-sanity', 'smoke']);
49
+
45
50
  export async function run({ args, pkgRoot }) {
46
51
  const { positional } = parseArgs(args);
47
52
  const sub = positional[0];
@@ -90,9 +95,21 @@ function runBinDispatch(verb, { args, pkgRoot }) {
90
95
  process.exit(1);
91
96
  }
92
97
  const rest = args.slice(args.indexOf(verb) + 1); // everything after the verb token
98
+ const childEnv = { ...process.env, CLAUDE_PLUGIN_ROOT: pluginRoot };
99
+ // For verbs that boot the dev-server, resolve the compiled platform binary the
100
+ // SAME way `maude design serve` does and hand the path down. The binary embeds
101
+ // yjs + every runtime dep; `bun server.ts` from source does NOT (the deps live
102
+ // in a nested package.json the npm tarball excludes), so on a production install
103
+ // server.ts crashes at boot on `yjs`. server-up.sh prefers MAUDE_DEV_SERVER_BIN
104
+ // when set; absent ⇒ dev tree ⇒ it falls back to `bun server.ts`. Helpers that
105
+ // shell into server-up.sh (visual-sanity, smoke) inherit it. DDR-084.
106
+ if (BOOT_VERBS.has(verb) && !process.env.MAUDE_DEV_SERVER_BIN) {
107
+ const bin = resolveServerBinary({ pkgRoot });
108
+ if (bin) childEnv.MAUDE_DEV_SERVER_BIN = bin;
109
+ }
93
110
  const child = spawnSync('bash', [script, ...rest], {
94
111
  stdio: 'inherit',
95
- env: { ...process.env, CLAUDE_PLUGIN_ROOT: pluginRoot },
112
+ env: childEnv,
96
113
  });
97
114
  if (child.error) {
98
115
  process.stderr.write(`maude design ${verb}: ${child.error.message}\n`);
@@ -295,6 +312,55 @@ function detectPlatformSlug() {
295
312
  return null;
296
313
  }
297
314
 
315
+ // Read-only resolution of the compiled platform binary the dev-server should
316
+ // boot as in a production install (it embeds yjs + every runtime dep). Mirrors
317
+ // runServe's order — side-channel cache (postinstall) → lazy resolve of the
318
+ // @1agh/maude-<slug> sibling/nested package — but performs NO caching and NEVER
319
+ // hard-fails: returns the path, or null when none is found. A null result means
320
+ // "fall back to `bun server.ts` from source" (correct in the local dev tree).
321
+ // Honors MAUDE_FORCE_SOURCE=1 so maintainers hacking on the dev-server source
322
+ // still boot their working copy. Used by runBinDispatch to hand the path to
323
+ // server-up.sh; runServe keeps its own inline copy (it also caches + hard-fails).
324
+ // Structural allowlist for a path we're about to spawn (DDR-084 hardening). The
325
+ // compiled binary is ALWAYS named `maude` / `maude.exe` inside a `maude-<slug>/`
326
+ // dir (the @1agh/maude-<slug> package, or the dev-tree `packages/maude-<slug>/`).
327
+ // The side-channel file's content is the one attacker-influenceable input in a
328
+ // shared-prefix / poisoned-clone layout — a path outside this shape is ignored
329
+ // (caller falls back to source), so the file can only DENY the binary, never
330
+ // redirect the spawn to an arbitrary executable. Paths `lazyResolveBinary`
331
+ // constructs always conform, so this never rejects a legit resolve.
332
+ export function isPlausiblePlatformBinary(p) {
333
+ if (!p) return false;
334
+ const b = basename(p);
335
+ if (b !== 'maude' && b !== 'maude.exe') return false;
336
+ return /^maude-[a-z0-9-]+$/.test(basename(dirname(p)));
337
+ }
338
+
339
+ export function resolveServerBinary({ pkgRoot }) {
340
+ if (process.env.MAUDE_FORCE_SOURCE === '1') return null;
341
+ // In the local source checkout, return null so server-up.sh boots
342
+ // `bun server.ts` from source — the maintainer is editing the dev-server and
343
+ // needs their working copy live, not a stale compiled binary that happens to
344
+ // be installed via pnpm. A production install has no `packages/` dir, so this
345
+ // never short-circuits there. (Preserves server-up's historic dev-tree
346
+ // behavior — it always ran source before DDR-084.)
347
+ if (isLocalDevTree(pkgRoot, { existsSync })) return null;
348
+ const sideChannel = resolve(pkgRoot, 'cli', '.platform-binary-path');
349
+ try {
350
+ if (existsSync(sideChannel)) {
351
+ const candidate = readFileSync(sideChannel, 'utf8').trim();
352
+ // Existence AND the structural allowlist — a poisoned side-channel pointing
353
+ // at an arbitrary executable is ignored (falls through to lazy resolve).
354
+ if (candidate && existsSync(candidate) && isPlausiblePlatformBinary(candidate)) {
355
+ return candidate;
356
+ }
357
+ }
358
+ } catch {
359
+ /* fall through to lazy resolve */
360
+ }
361
+ return lazyResolveBinary({ pkgRoot, fs: { existsSync, chmodSync } }).binPath;
362
+ }
363
+
298
364
  function lazyResolveBinary({ pkgRoot, fs }) {
299
365
  const slug = detectPlatformSlug();
300
366
  if (!slug) return { binPath: null, slug: null };
@@ -8,11 +8,12 @@
8
8
 
9
9
  import assert from 'node:assert/strict';
10
10
  import { spawnSync } from 'node:child_process';
11
- import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { test } from 'node:test';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { isPlausiblePlatformBinary, resolveServerBinary } from './design.mjs';
16
17
 
17
18
  const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
18
19
 
@@ -54,3 +55,98 @@ test('design help lists the dev-tooling verbs', () => {
54
55
  assert.match(res.stdout, new RegExp(verb), `usage should mention ${verb}`);
55
56
  }
56
57
  });
58
+
59
+ // resolveServerBinary — the dev-server boot-runtime resolver (DDR-084). server-up
60
+ // must boot the compiled platform binary on a production install (it embeds yjs +
61
+ // every dep; `bun server.ts` from source can't resolve them — the historic
62
+ // yjs-at-boot crash), but keep using SOURCE in the local dev tree so a maintainer's
63
+ // edits are live.
64
+ function fakePkgRoot({ devTree = false, sideChannelBin = null } = {}) {
65
+ const root = mkdtempSync(join(tmpdir(), 'maude-pkgroot-'));
66
+ if (devTree) {
67
+ mkdirSync(join(root, 'packages', 'maude-darwin-arm64'), { recursive: true });
68
+ writeFileSync(join(root, 'packages', 'maude-darwin-arm64', 'package.json'), '{}');
69
+ }
70
+ if (sideChannelBin) {
71
+ mkdirSync(join(root, 'cli'), { recursive: true });
72
+ writeFileSync(join(root, 'cli', '.platform-binary-path'), sideChannelBin);
73
+ }
74
+ return root;
75
+ }
76
+
77
+ test('resolveServerBinary → null in the local dev tree (packages/ present) — boots source', () => {
78
+ const root = fakePkgRoot({ devTree: true });
79
+ try {
80
+ delete process.env.MAUDE_FORCE_SOURCE;
81
+ assert.equal(resolveServerBinary({ pkgRoot: root }), null);
82
+ } finally {
83
+ rmSync(root, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ // Build an executable in a layout the allowlist accepts (`maude-<slug>/maude`)
88
+ // or rejects (anything else), under a throwaway dir.
89
+ function fakeBinary({ conforming = true } = {}) {
90
+ const home = mkdtempSync(join(tmpdir(), 'maude-bin-'));
91
+ const dir = join(home, conforming ? 'maude-darwin-arm64' : 'evil');
92
+ mkdirSync(dir, { recursive: true });
93
+ const bin = join(dir, 'maude');
94
+ writeFileSync(bin, '#!/bin/sh\n');
95
+ chmodSync(bin, 0o755);
96
+ return { home, bin };
97
+ }
98
+
99
+ test('resolveServerBinary → null when MAUDE_FORCE_SOURCE=1 (maintainer override)', () => {
100
+ const { home, bin } = fakeBinary();
101
+ const root = fakePkgRoot({ sideChannelBin: bin }); // NOT a dev tree
102
+ const prev = process.env.MAUDE_FORCE_SOURCE;
103
+ try {
104
+ process.env.MAUDE_FORCE_SOURCE = '1';
105
+ assert.equal(resolveServerBinary({ pkgRoot: root }), null);
106
+ } finally {
107
+ if (prev === undefined) delete process.env.MAUDE_FORCE_SOURCE;
108
+ else process.env.MAUDE_FORCE_SOURCE = prev;
109
+ rmSync(root, { recursive: true, force: true });
110
+ rmSync(home, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ test('resolveServerBinary → side-channel path on a production install (no packages/)', () => {
115
+ const { home, bin } = fakeBinary(); // conforming maude-<slug>/maude layout
116
+ const root = fakePkgRoot({ sideChannelBin: bin }); // production shape
117
+ const prev = process.env.MAUDE_FORCE_SOURCE;
118
+ try {
119
+ delete process.env.MAUDE_FORCE_SOURCE;
120
+ assert.equal(resolveServerBinary({ pkgRoot: root }), bin);
121
+ } finally {
122
+ if (prev !== undefined) process.env.MAUDE_FORCE_SOURCE = prev;
123
+ rmSync(root, { recursive: true, force: true });
124
+ rmSync(home, { recursive: true, force: true });
125
+ }
126
+ });
127
+
128
+ test('resolveServerBinary → REJECTS a side-channel pointing outside the maude-<slug>/ layout (DDR-084 allowlist)', () => {
129
+ const { home, bin } = fakeBinary({ conforming: false }); // <tmp>/evil/maude
130
+ const root = fakePkgRoot({ sideChannelBin: bin }); // production shape, no real platform pkg
131
+ const prev = process.env.MAUDE_FORCE_SOURCE;
132
+ try {
133
+ delete process.env.MAUDE_FORCE_SOURCE;
134
+ // Poisoned side-channel is ignored → falls through to lazyResolve, which finds
135
+ // nothing in the fake root → null (server-up then boots source, not the planted bin).
136
+ assert.equal(resolveServerBinary({ pkgRoot: root }), null);
137
+ } finally {
138
+ if (prev !== undefined) process.env.MAUDE_FORCE_SOURCE = prev;
139
+ rmSync(root, { recursive: true, force: true });
140
+ rmSync(home, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test('isPlausiblePlatformBinary — accepts maude-<slug>/maude, rejects everything else', () => {
145
+ assert.equal(isPlausiblePlatformBinary('/x/node_modules/@1agh/maude-darwin-arm64/maude'), true);
146
+ assert.equal(isPlausiblePlatformBinary('/x/packages/maude-linux-x64-musl/maude'), true);
147
+ assert.equal(isPlausiblePlatformBinary('/x/maude-win32-x64/maude.exe'), true);
148
+ assert.equal(isPlausiblePlatformBinary('/tmp/evil/maude'), false); // parent not maude-<slug>
149
+ assert.equal(isPlausiblePlatformBinary('/x/maude-darwin-arm64/evil'), false); // basename not maude
150
+ assert.equal(isPlausiblePlatformBinary(''), false);
151
+ assert.equal(isPlausiblePlatformBinary(null), false);
152
+ });
@@ -1,6 +1,8 @@
1
1
  // `maude doctor` — unified workspace diagnostic.
2
2
  // Combines (1) plugin dependency preflight, (2) workflows.config.json schema
3
- // validation, (3) stack drift, (4) quality-gate additions into one report.
3
+ // validation, (3) stack drift, (4) quality-gate additions, and (5) linked
4
+ // design-hub / TSX-sync health (report-only, local signals — no network) into
5
+ // one report.
4
6
  //
5
7
  // Flags:
6
8
  // --plugin <name> Scope deps section to one plugin. Config section is
@@ -23,6 +25,7 @@ import { stdin, stdout } from 'node:process';
23
25
  import { createInterface } from 'node:readline/promises';
24
26
  import { parseArgs } from '../lib/argv.mjs';
25
27
  import { lintConfig } from '../lib/config-lint.mjs';
28
+ import { getHub } from '../lib/hubs-config.mjs';
26
29
  import { checkAll } from '../lib/preflight.mjs';
27
30
  import { detectQualityGates, detectStack } from '../lib/stack-detect.mjs';
28
31
 
@@ -115,6 +118,60 @@ export async function run({ args, pkgRoot }) {
115
118
  };
116
119
  }
117
120
 
121
+ // ── Design config: linked-hub + TSX-sync health (report-only) ────────────
122
+ // doctor's primary domain is .ai/workflows.config.json, but a linked design
123
+ // project's .design/config.json carries the one knob that has bitten users
124
+ // repeatedly — `linkedHub.syncTsx`. Surface it here so doctor is the one-stop
125
+ // health check. Report-only: under DDR-079 (default ON) every state is valid,
126
+ // so there is nothing to --fix and we NEVER impose an opt-out.
127
+ let designReport = { exists: false };
128
+ const designFsPath = resolve(repoRoot, '.design/config.json');
129
+ if (existsSync(designFsPath)) {
130
+ try {
131
+ const dcfg = JSON.parse(readFileSync(designFsPath, 'utf8'));
132
+ const linkedHub = dcfg.linkedHub;
133
+ if (!linkedHub) {
134
+ designReport = { exists: true, linked: false };
135
+ } else {
136
+ const syncTsx = linkedHub.syncTsx;
137
+ const tsxSync =
138
+ syncTsx === false
139
+ ? 'off (opted out)'
140
+ : syncTsx === true
141
+ ? 'on (explicit)'
142
+ : 'on (default)';
143
+ // Local-only signals — no network. `getHub` reads ~/.config/maude/
144
+ // hubs.json; the sync-agent state comes from the serve-written
145
+ // `_sync.json`. We deliberately DO NOT probe the hub's /health here:
146
+ // doctor must stay fast + offline (it runs in the release pre-flight),
147
+ // so live hub reachability stays in `maude design status`.
148
+ let tokenStored = false;
149
+ try {
150
+ tokenStored = !!getHub(linkedHub.url);
151
+ } catch {
152
+ /* hubs.json absent/unreadable on this machine → no token */
153
+ }
154
+ designReport = {
155
+ exists: true,
156
+ linked: true,
157
+ hubUrl: linkedHub.url,
158
+ adopt: !!linkedHub.adopt,
159
+ tsxSync,
160
+ tokenStored,
161
+ syncAgent: readSyncAgentState(resolve(repoRoot, '.design', '_sync.json')),
162
+ // DDR-079 migration advisory: a linked config with no explicit
163
+ // syncTsx rides the default, which flipped off→on in maude 0.27→0.28.
164
+ advisory:
165
+ syncTsx === undefined
166
+ ? 'TSX sync defaults ON (DDR-079) — every .tsx syncs to this hub. Opt out with linkedHub.syncTsx:false or `maude design link … --no-sync-tsx`.'
167
+ : null,
168
+ };
169
+ }
170
+ } catch (err) {
171
+ designReport = { exists: true, error: `invalid JSON: ${err.message}` };
172
+ }
173
+ }
174
+
118
175
  // ── Summary ─────────────────────────────────────────────────────────────
119
176
  const hardDepsMissing = Object.values(depsByPlugin)
120
177
  .filter((r) => r.summary)
@@ -132,12 +189,12 @@ export async function run({ args, pkgRoot }) {
132
189
 
133
190
  if (jsonMode) {
134
191
  process.stdout.write(
135
- `${JSON.stringify({ deps: depsByPlugin, config: configReport, summary }, null, 2)}\n`
192
+ `${JSON.stringify({ deps: depsByPlugin, config: configReport, design: designReport, summary }, null, 2)}\n`
136
193
  );
137
194
  process.exit(summary.healthy ? 0 : 1);
138
195
  }
139
196
 
140
- printReport({ depsByPlugin, configReport, summary });
197
+ printReport({ depsByPlugin, configReport, designReport, summary });
141
198
 
142
199
  // ── Fix path ────────────────────────────────────────────────────────────
143
200
  if (fix) {
@@ -151,11 +208,33 @@ export async function run({ args, pkgRoot }) {
151
208
  process.exit(summary.healthy ? 0 : 1);
152
209
  }
153
210
 
211
+ // Read the serve-written `.design/_sync.json` and condense it to one line.
212
+ // Local file only — never network. Returns null when the agent hasn't run.
213
+ function readSyncAgentState(p) {
214
+ if (!existsSync(p)) return null;
215
+ try {
216
+ const s = JSON.parse(readFileSync(p, 'utf8'));
217
+ if (s.notSyncable) return `0 syncable — ${s.reason || 'see `maude design status`'}`;
218
+ if (s.state) {
219
+ const bits = [String(s.state), `${s.canvases ?? 0} canvas(es)`];
220
+ if (s.queuedOps) bits.push(`${s.queuedOps} queued`);
221
+ if (s.conflicts?.length) bits.push(`${s.conflicts.length} conflict notice(s)`);
222
+ return bits.join(', ');
223
+ }
224
+ return null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
154
230
  function usage() {
155
231
  return `maude doctor [--plugin <name>] [--fix] [--json]
156
232
 
157
233
  Unified workspace diagnostic. Reports missing dependencies, config schema
158
- errors, stack drift, and missing quality-gate declarations in one shot.
234
+ errors, stack drift, missing quality-gate declarations, and when a
235
+ .design/config.json is linked — design-hub + TSX-sync health, in one shot.
236
+ (The design section is report-only and local; live hub reachability is
237
+ \`maude design status\`.)
159
238
 
160
239
  Flags:
161
240
  --plugin <name> Scope dependency section to one plugin (design | flow).
@@ -170,7 +249,7 @@ function usage() {
170
249
  `;
171
250
  }
172
251
 
173
- function printReport({ depsByPlugin, configReport, summary }) {
252
+ function printReport({ depsByPlugin, configReport, designReport, summary }) {
174
253
  process.stdout.write('maude doctor\n\n');
175
254
  for (const [name, env] of Object.entries(depsByPlugin)) {
176
255
  process.stdout.write(` Dependencies (plugins/${name}):\n`);
@@ -235,6 +314,32 @@ function printReport({ depsByPlugin, configReport, summary }) {
235
314
  }
236
315
  }
237
316
 
317
+ // Design / linked-hub health — report-only, local signals only (no network).
318
+ if (designReport?.exists) {
319
+ process.stdout.write(' Design hub (.design/config.json):\n');
320
+ if (designReport.error) {
321
+ process.stdout.write(` ✗ ${designReport.error}\n`);
322
+ } else if (!designReport.linked) {
323
+ process.stdout.write(' solo — not linked to a hub.\n');
324
+ } else {
325
+ process.stdout.write(` ✓ linked: ${designReport.hubUrl}\n`);
326
+ process.stdout.write(
327
+ ` token stored: ${designReport.tokenStored ? 'yes' : 'NO — run `maude design link`'}\n`
328
+ );
329
+ process.stdout.write(` TSX sync: ${designReport.tsxSync}\n`);
330
+ if (designReport.adopt)
331
+ process.stdout.write(' adopt mode: yes (push-on-first-sync)\n');
332
+ process.stdout.write(
333
+ ` sync agent: ${designReport.syncAgent || 'idle (start `maude design serve`)'}\n`
334
+ );
335
+ if (designReport.advisory) process.stdout.write(` ℹ ${designReport.advisory}\n`);
336
+ process.stdout.write(
337
+ ' (live hub reachability + full sync detail: `maude design status`)\n'
338
+ );
339
+ }
340
+ process.stdout.write('\n');
341
+ }
342
+
238
343
  const parts = [];
239
344
  if (summary.hardDepsMissing) parts.push(`${summary.hardDepsMissing} hard dep missing`);
240
345
  if (summary.schemaErrors)
@@ -183,3 +183,55 @@ test('summary line reflects counts (drift/additions tracked via package.json)',
183
183
  const lintAdd = env.config.qualityAdditions.find((a) => a.gate === 'lint');
184
184
  assert.ok(lintAdd);
185
185
  });
186
+
187
+ // ── DDR-079: design-hub / TSX-sync section (report-only, local) ──────────────
188
+ function writeDesign(root, linkedHub) {
189
+ mkdirSync(join(root, '.design'), { recursive: true });
190
+ writeFileSync(
191
+ join(root, '.design/config.json'),
192
+ JSON.stringify({ name: 't', designRoot: '.design', ...(linkedHub ? { linkedHub } : {}) })
193
+ );
194
+ }
195
+
196
+ test('DDR-079: linked design config with no syncTsx → on (default) + advisory', () => {
197
+ const root = makeTempRepo();
198
+ writeFileSync(join(root, '.ai/workflows.config.json'), `${JSON.stringify({ name: 'x' })}\n`);
199
+ writeDesign(root, { url: 'https://hub.example.com', linkedAt: 1 });
200
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
201
+ assert.equal(env.design.linked, true);
202
+ assert.equal(env.design.hubUrl, 'https://hub.example.com');
203
+ assert.equal(env.design.tsxSync, 'on (default)');
204
+ assert.match(env.design.advisory, /defaults ON/);
205
+ });
206
+
207
+ test('DDR-079: syncTsx:false → off (opted out), no advisory', () => {
208
+ const root = makeTempRepo();
209
+ writeFileSync(join(root, '.ai/workflows.config.json'), `${JSON.stringify({ name: 'x' })}\n`);
210
+ writeDesign(root, { url: 'https://hub.example.com', linkedAt: 1, syncTsx: false });
211
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
212
+ assert.equal(env.design.tsxSync, 'off (opted out)');
213
+ assert.equal(env.design.advisory, null);
214
+ });
215
+
216
+ test('DDR-079: syncTsx:true → on (explicit), no advisory', () => {
217
+ const root = makeTempRepo();
218
+ writeFileSync(join(root, '.ai/workflows.config.json'), `${JSON.stringify({ name: 'x' })}\n`);
219
+ writeDesign(root, { url: 'https://hub.example.com', linkedAt: 1, syncTsx: true });
220
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
221
+ assert.equal(env.design.tsxSync, 'on (explicit)');
222
+ assert.equal(env.design.advisory, null);
223
+ });
224
+
225
+ test('design section reports solo when .design/config.json has no linkedHub', () => {
226
+ const root = makeTempRepo();
227
+ writeDesign(root, null);
228
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
229
+ assert.equal(env.design.exists, true);
230
+ assert.equal(env.design.linked, false);
231
+ });
232
+
233
+ test('design section absent when no .design/config.json', () => {
234
+ const root = makeTempRepo();
235
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
236
+ assert.equal(env.design.exists, false);
237
+ });
package/cli/lib/cache.mjs CHANGED
@@ -49,8 +49,8 @@ import { spawnSync } from 'node:child_process';
49
49
  import {
50
50
  existsSync,
51
51
  mkdirSync,
52
- readFileSync,
53
52
  readdirSync,
53
+ readFileSync,
54
54
  renameSync,
55
55
  rmSync,
56
56
  statSync,
@@ -16,8 +16,8 @@
16
16
 
17
17
  import { readFile } from 'node:fs/promises';
18
18
  import { resolve } from 'node:path';
19
- import addFormats from 'ajv-formats';
20
19
  import Ajv2020 from 'ajv/dist/2020.js';
20
+ import addFormats from 'ajv-formats';
21
21
 
22
22
  function levenshtein(a, b) {
23
23
  if (a === b) return 0;
@@ -1,4 +1,4 @@
1
- import { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
1
+ import { copyFile, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
2
2
  import { basename, join, relative } from 'node:path';
3
3
 
4
4
  // Recursively copy `src` → `dest`. Returns { created, skipped, replaced }.
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // Token NEVER lands in .design/config.json — that's git-committed.
12
12
 
13
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { dirname, resolve } from 'node:path';
15
15
  import { createInterface } from 'node:readline';
16
16
 
@@ -25,7 +25,9 @@ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
25
25
 
26
26
  export async function runLink({ args, cwd = process.cwd(), forceAdopt = false }) {
27
27
  const tail = args.slice(args.indexOf(forceAdopt ? 'adopt' : 'link') + 1);
28
- const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force', 'yes'] });
28
+ const { flags, positional } = parseArgs(tail, {
29
+ booleans: ['adopt', 'force', 'yes', 'sync-tsx', 'no-sync-tsx'],
30
+ });
29
31
  const url = positional[0];
30
32
  const token = flags.token;
31
33
 
@@ -116,15 +118,27 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
116
118
  `[design link] note: replacing existing link to ${existing.url} (was added ${new Date(existing.linkedAt).toISOString()}).\n`
117
119
  );
118
120
  }
119
- // DDR-072preserve the project-level TSX opt-in only when re-linking to the
120
- // SAME hub. Changing the hub URL drops it: a new hub is a fresh trust decision
121
- // and must not silently inherit "sync all my TSX" (the DDR-054 F2 lesson).
122
- const keepSyncTsx = existing?.syncTsx === true && existing.url === normUrl;
121
+ // DDR-079TSX sync defaults ON, so we only PERSIST `syncTsx` when it's an
122
+ // explicit choice; absence means "the default", and we never write
123
+ // `syncTsx: true` just to encode the default (that's what bit us a value
124
+ // that git-restore could wipe to silently flip behavior). Precedence:
125
+ // --no-sync-tsx → false (project-wide opt-out)
126
+ // --sync-tsx → true (explicit, == default; useful to pin against a future
127
+ // default change or to self-document)
128
+ // else, re-link to the SAME hub → carry the existing explicit value forward
129
+ // (a NEW hub URL is a fresh trust decision — don't inherit, DDR-054 F2)
130
+ // else → omit (= on by default)
131
+ let syncTsx;
132
+ if (flags['no-sync-tsx']) syncTsx = false;
133
+ else if (flags['sync-tsx']) syncTsx = true;
134
+ else if (existing && existing.url === normUrl && typeof existing.syncTsx === 'boolean') {
135
+ syncTsx = existing.syncTsx;
136
+ }
123
137
  cfg.linkedHub = {
124
138
  url: normUrl,
125
139
  linkedAt: hubRecord.linkedAt,
126
140
  ...(adopt ? { adopt: true } : {}),
127
- ...(keepSyncTsx ? { syncTsx: true } : {}),
141
+ ...(syncTsx !== undefined ? { syncTsx } : {}),
128
142
  };
129
143
  writeDesignConfig(designConfigPath, cfg);
130
144
 
@@ -140,8 +154,12 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
140
154
  await maybeWriteGitignoreBlock(cwd, !!flags.yes);
141
155
  }
142
156
 
157
+ const tsxSyncLine =
158
+ syncTsx === false
159
+ ? 'off (opted out — linkedHub.syncTsx: false)'
160
+ : 'on by default (DDR-079) — every .tsx body syncs; opt out with --no-sync-tsx or a canvas .meta.json "syncable": false';
143
161
  process.stdout.write(
144
- `[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`
162
+ `[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' : ''}${syncTsx !== undefined ? `, syncTsx: ${syncTsx}` : ''} }\n TSX sync: ${tsxSyncLine}\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`
145
163
  );
146
164
  if (!loopback) process.stderr.write(linkedModeBanner());
147
165
  }
@@ -220,6 +238,9 @@ export async function runStatus({ args, cwd = process.cwd() }) {
220
238
  url,
221
239
  linkedAt: cfg.linkedHub.linkedAt,
222
240
  adopt: !!cfg.linkedHub.adopt,
241
+ // DDR-079 — effective TSX-sync state: on unless explicitly opted out.
242
+ syncTsx: cfg.linkedHub.syncTsx !== false,
243
+ syncTsxExplicit: typeof cfg.linkedHub.syncTsx === 'boolean' ? cfg.linkedHub.syncTsx : null,
223
244
  tokenStored: !!hubRecord,
224
245
  hub: probe.ok
225
246
  ? {
@@ -249,8 +270,17 @@ export async function runStatus({ args, cwd = process.cwd() }) {
249
270
  }${sync.conflicts?.length ? `, ${sync.conflicts.length} conflict notice(s)` : ''}`
250
271
  : 'idle (start `maude design serve` in linked mode)';
251
272
  process.stdout.write(
252
- `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`
273
+ `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 TSX sync: ${cfg.linkedHub.syncTsx === false ? 'off (opted out — linkedHub.syncTsx: false)' : 'on (default — DDR-079)'}\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`
253
274
  );
275
+ // DDR-079 migration advisory: a config with no explicit `syncTsx` rides the
276
+ // default, which FLIPPED from off→on in maude 0.27. Surface it so an upgrader
277
+ // who relied on the old "TSX never syncs" behavior isn't surprised that every
278
+ // .tsx now goes to the hub.
279
+ if (cfg.linkedHub.syncTsx === undefined) {
280
+ process.stdout.write(
281
+ '\n ℹ syncTsx is not set, so it uses the DEFAULT — which is now ON (DDR-079, maude ≥ 0.27): every .tsx syncs to this hub.\n Upgraded from an older maude and want the pre-0.27 behavior (no TSX sync)? Set .design/config.json → linkedHub.syncTsx: false (or maude design link <url> --token … --no-sync-tsx).\n'
282
+ );
283
+ }
254
284
  }
255
285
 
256
286
  /**
@@ -19,8 +19,8 @@ import assert from 'node:assert/strict';
19
19
  import { readFileSync } from 'node:fs';
20
20
  import { resolve } from 'node:path';
21
21
  import { test } from 'node:test';
22
- import addFormats from 'ajv-formats';
23
22
  import Ajv2020 from 'ajv/dist/2020.js';
23
+ import addFormats from 'ajv-formats';
24
24
  import { lintConfig } from './config-lint.mjs';
25
25
 
26
26
  const read = (p) => readFileSync(resolve(p), 'utf8');
@@ -7,10 +7,10 @@ import { join } from 'node:path';
7
7
  import { test } from 'node:test';
8
8
 
9
9
  import {
10
- BEGIN_MARKER,
11
- END_MARKER,
12
10
  applyBlock,
11
+ BEGIN_MARKER,
13
12
  buildBlock,
13
+ END_MARKER,
14
14
  hasBlock,
15
15
  writeGitignoreBlock,
16
16
  } from './gitignore-block.mjs';
@@ -10,7 +10,7 @@
10
10
  //
11
11
  // Consumers: cli/commands/doctor.mjs and the drift-aware /flow:init re-run.
12
12
 
13
- import { readFile, readdir, stat } from 'node:fs/promises';
13
+ import { readdir, readFile, stat } from 'node:fs/promises';
14
14
  import { join } from 'node:path';
15
15
 
16
16
  async function fileExists(path) {