@1agh/maude 0.20.0 → 0.22.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 (71) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +361 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +245 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +16 -8
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
  23. package/plugins/design/dev-server/annotations-layer.tsx +52 -12
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
  26. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  27. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  28. package/plugins/design/dev-server/canvas-lib.tsx +33 -7
  29. package/plugins/design/dev-server/canvas-shell.tsx +127 -9
  30. package/plugins/design/dev-server/client/app.jsx +72 -0
  31. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  32. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  33. package/plugins/design/dev-server/collab/index.ts +47 -0
  34. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  35. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  36. package/plugins/design/dev-server/collab/registry.ts +110 -0
  37. package/plugins/design/dev-server/collab/room.ts +215 -0
  38. package/plugins/design/dev-server/comments-overlay.tsx +29 -0
  39. package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
  40. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  41. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  42. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +624 -0
  43. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +608 -0
  44. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +380 -0
  45. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +104 -0
  46. package/plugins/design/dev-server/dist/runtime/yjs.js +6691 -0
  47. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  48. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  49. package/plugins/design/dev-server/http.ts +64 -1
  50. package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
  51. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  52. package/plugins/design/dev-server/runtime-bundle.ts +19 -0
  53. package/plugins/design/dev-server/server.ts +78 -11
  54. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  55. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  56. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  57. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  58. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  59. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  60. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  61. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  62. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  63. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  64. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  65. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  66. package/plugins/design/dev-server/use-collab.tsx +478 -0
  67. package/plugins/design/dev-server/ws.ts +123 -7
  68. package/plugins/design/templates/_shell.html +37 -1
  69. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  70. package/plugins/flow/dependencies.json +143 -0
  71. package/plugins/flow/dependencies.schema.json +107 -0
package/README.md CHANGED
@@ -72,6 +72,13 @@ Then inside Claude Code (with `flow@maude` installed):
72
72
  - **Claude Code** — desktop app, CLI, or IDE extension.
73
73
  - Optional: **`agent-browser`** for design screenshot evidence.
74
74
 
75
+ ## Collaboration model
76
+
77
+ Two clean paths, no middle ground ([DDR-047](.ai/decisions/DDR-047-collab-scope-cut-no-lan-mode-hub-admin-ui.md)):
78
+
79
+ - **v1.0 — git handoff OR loopback multi-tab.** Push / pull is the cross-machine story. On a single machine, two browser tabs (or two Claude Code instances editing the same repo) sync cursors, comments, and annotations live over loopback WebSocket. The dev server refuses any non-loopback `host` header on the collab WS endpoint.
80
+ - **v1.1 — deploy a hub** (Phase 9, in-flight). Cross-machine live collab needs a hub binary you deploy yourself. No tunnel mode; no shared cloud.
81
+
75
82
  ## What's where
76
83
 
77
84
  User-facing docs live in two places — the README points you the right way:
package/cli/bin/maude.mjs CHANGED
@@ -14,7 +14,9 @@ const COMMANDS = {
14
14
  init: () => import('../commands/init.mjs'),
15
15
  config: () => import('../commands/config.mjs'),
16
16
  design: () => import('../commands/design.mjs'),
17
+ doctor: () => import('../commands/doctor.mjs'),
17
18
  help: () => import('../commands/help.mjs'),
19
+ hub: () => import('../commands/hub.mjs'),
18
20
  version: () => import('../commands/version.mjs'),
19
21
  };
20
22
 
@@ -47,7 +49,9 @@ async function main(argv) {
47
49
 
48
50
  const loader = COMMANDS[cmd];
49
51
  if (!loader) {
50
- process.stderr.write(`maude: unknown command "${cmd}"\nRun \`maude help\` for usage.\n`);
52
+ process.stderr.write(
53
+ `maude: unknown command "${cmd}"\nKnown: ${Object.keys(COMMANDS).join(', ')}.\nRun \`maude help\` for usage.\n`
54
+ );
51
55
  process.exit(2);
52
56
  }
53
57
  const { run } = await loader();
@@ -0,0 +1,207 @@
1
+ // End-to-end CLI tests for `maude design link|unlink|status|adopt` via async spawn.
2
+ //
3
+ // /health is faked with a plain node:http server — we don't need a real
4
+ // Hocuspocus instance. spawnSync would block the parent's event loop and
5
+ // prevent the mock from responding; async spawn keeps the loop ticking.
6
+
7
+ import assert from 'node:assert/strict';
8
+ import { spawn } from 'node:child_process';
9
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { createServer } from 'node:http';
11
+ import { tmpdir } from 'node:os';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { after, before, beforeEach, test } from 'node:test';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
17
+
18
+ const PORT = Number.parseInt(process.env.HUB_TEST_PORT_DESIGN ?? '14396', 10);
19
+ const URL = `http://127.0.0.1:${PORT}`;
20
+
21
+ let mockServer;
22
+ let workspace;
23
+ let hubsConfigPath;
24
+
25
+ before(async () => {
26
+ mockServer = createServer((req, res) => {
27
+ if (req.method === 'GET' && (req.url === '/health' || req.url.startsWith('/health?'))) {
28
+ const body = JSON.stringify({
29
+ ok: true,
30
+ version: '0.0.0-mock',
31
+ uptimeMs: 1234,
32
+ port: PORT,
33
+ dataDir: '/tmp/mock',
34
+ tokenCount: 0,
35
+ authMode: 'dev',
36
+ });
37
+ res.writeHead(200, {
38
+ 'Content-Type': 'application/json',
39
+ 'Content-Length': Buffer.byteLength(body),
40
+ });
41
+ res.end(body);
42
+ return;
43
+ }
44
+ res.writeHead(404);
45
+ res.end();
46
+ });
47
+ await new Promise((resolveLi) => mockServer.listen(PORT, '127.0.0.1', resolveLi));
48
+ });
49
+
50
+ after(async () => {
51
+ if (mockServer) await new Promise((r) => mockServer.close(r));
52
+ });
53
+
54
+ beforeEach(() => {
55
+ workspace = mkdtempSync(join(tmpdir(), 'maude-design-link-ws-'));
56
+ mkdirSync(join(workspace, '.design'), { recursive: true });
57
+ writeFileSync(
58
+ join(workspace, '.design/config.json'),
59
+ JSON.stringify({ name: 'test', ds: 'default' }, null, 2),
60
+ 'utf8'
61
+ );
62
+ hubsConfigPath = join(workspace, 'hubs.json');
63
+ });
64
+
65
+ function runCli(args) {
66
+ return new Promise((resolveResult) => {
67
+ const child = spawn(process.execPath, [BIN, ...args], {
68
+ cwd: workspace,
69
+ env: { ...process.env, HUBS_CONFIG_PATH: hubsConfigPath },
70
+ });
71
+ let stdout = '';
72
+ let stderr = '';
73
+ child.stdout.on('data', (chunk) => {
74
+ stdout += chunk.toString();
75
+ });
76
+ child.stderr.on('data', (chunk) => {
77
+ stderr += chunk.toString();
78
+ });
79
+ const timer = setTimeout(() => {
80
+ child.kill('SIGKILL');
81
+ }, 8000);
82
+ child.on('exit', (status, signal) => {
83
+ clearTimeout(timer);
84
+ resolveResult({ status, signal, stdout, stderr });
85
+ });
86
+ });
87
+ }
88
+
89
+ function cleanup() {
90
+ if (workspace) rmSync(workspace, { recursive: true, force: true });
91
+ }
92
+
93
+ test('link without --token exits 2', async () => {
94
+ const res = await runCli(['design', 'link', URL]);
95
+ assert.equal(res.status, 2);
96
+ assert.match(res.stderr, /--token <value> is required/);
97
+ cleanup();
98
+ });
99
+
100
+ test('link without positional url exits 2', async () => {
101
+ const res = await runCli(['design', 'link', '--token', 'mau_x']);
102
+ assert.equal(res.status, 2);
103
+ assert.match(res.stderr, /<url> positional argument is required/);
104
+ cleanup();
105
+ });
106
+
107
+ test('link writes linkedHub + hubs.json + prints reachability', async () => {
108
+ const res = await runCli(['design', 'link', URL, '--token', 'mau_test']);
109
+ assert.equal(res.status, 0, res.stderr);
110
+ assert.match(res.stdout, /linked .* to http:\/\/127\.0\.0\.1:/);
111
+ assert.match(res.stdout, /hub:\s+v[0-9.]/);
112
+
113
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
114
+ assert.equal(cfg.linkedHub.url, URL);
115
+ assert.equal(typeof cfg.linkedHub.linkedAt, 'number');
116
+ assert.equal(cfg.linkedHub.adopt, undefined);
117
+
118
+ const hubs = JSON.parse(readFileSync(hubsConfigPath, 'utf8'));
119
+ assert.equal(hubs.hubs[URL].token, 'mau_test');
120
+ cleanup();
121
+ });
122
+
123
+ test('link --adopt records adopt:true in linkedHub', async () => {
124
+ const res = await runCli(['design', 'link', URL, '--token', 'mau_test', '--adopt']);
125
+ assert.equal(res.status, 0, res.stderr);
126
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
127
+ assert.equal(cfg.linkedHub.adopt, true);
128
+ cleanup();
129
+ });
130
+
131
+ test('adopt subcommand is an alias of link --adopt', async () => {
132
+ const res = await runCli(['design', 'adopt', URL, '--token', 'mau_test']);
133
+ assert.equal(res.status, 0, res.stderr);
134
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
135
+ assert.equal(cfg.linkedHub.adopt, true);
136
+ cleanup();
137
+ });
138
+
139
+ test('link against unreachable URL exits 1 unless --force', async () => {
140
+ const dead = 'http://127.0.0.1:1';
141
+ const fail = await runCli(['design', 'link', dead, '--token', 'mau_test']);
142
+ assert.equal(fail.status, 1);
143
+ assert.match(fail.stderr, /cannot reach/);
144
+
145
+ const forced = await runCli(['design', 'link', dead, '--token', 'mau_test', '--force']);
146
+ assert.equal(forced.status, 0, forced.stderr);
147
+ assert.match(forced.stdout, /NOT REACHED/);
148
+ cleanup();
149
+ });
150
+
151
+ test('unlink drops linkedHub + token; idempotent on solo repo', async () => {
152
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
153
+
154
+ const res = await runCli(['design', 'unlink']);
155
+ assert.equal(res.status, 0, res.stderr);
156
+ assert.match(res.stdout, /dropped link/);
157
+
158
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
159
+ assert.equal(cfg.linkedHub, undefined);
160
+
161
+ const hubs = JSON.parse(readFileSync(hubsConfigPath, 'utf8'));
162
+ assert.equal(URL in hubs.hubs, false);
163
+
164
+ const second = await runCli(['design', 'unlink']);
165
+ assert.equal(second.status, 0);
166
+ assert.match(second.stdout, /already in solo mode/);
167
+ cleanup();
168
+ });
169
+
170
+ test('unlink --keep-token leaves hubs.json entry intact', async () => {
171
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
172
+ await runCli(['design', 'unlink', '--keep-token']);
173
+
174
+ const hubs = JSON.parse(readFileSync(hubsConfigPath, 'utf8'));
175
+ assert.equal(hubs.hubs[URL].token, 'mau_test');
176
+ cleanup();
177
+ });
178
+
179
+ test('status on solo repo reports solo mode', async () => {
180
+ const res = await runCli(['design', 'status']);
181
+ assert.equal(res.status, 0, res.stderr);
182
+ assert.match(res.stdout, /solo mode/);
183
+ cleanup();
184
+ });
185
+
186
+ test('status on linked repo reports hub URL + reachability + token presence', async () => {
187
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
188
+ const res = await runCli(['design', 'status']);
189
+ assert.equal(res.status, 0, res.stderr);
190
+ assert.match(res.stdout, /linked mode/);
191
+ assert.match(res.stdout, /token stored:\s+yes/);
192
+ assert.match(res.stdout, /hub status:\s+up/);
193
+ cleanup();
194
+ });
195
+
196
+ test('status --json emits structured payload', async () => {
197
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
198
+ const res = await runCli(['design', 'status', '--json']);
199
+ assert.equal(res.status, 0, res.stderr);
200
+ const payload = JSON.parse(res.stdout);
201
+ assert.equal(payload.mode, 'linked');
202
+ assert.equal(payload.url, URL);
203
+ assert.equal(payload.tokenStored, true);
204
+ assert.equal(payload.hub.reachable, true);
205
+ assert.equal(payload.sync.agent, 'not-implemented');
206
+ cleanup();
207
+ });
@@ -3,8 +3,18 @@ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { parseArgs } from '../lib/argv.mjs';
6
-
7
- const SUBCOMMANDS = new Set(['serve', 'init', 'export', 'help']);
6
+ import { runAdopt, runLink, runStatus, runUnlink } from '../lib/design-link.mjs';
7
+
8
+ const SUBCOMMANDS = new Set([
9
+ 'serve',
10
+ 'init',
11
+ 'export',
12
+ 'link',
13
+ 'unlink',
14
+ 'status',
15
+ 'adopt',
16
+ 'help',
17
+ ]);
8
18
 
9
19
  export async function run({ args, pkgRoot }) {
10
20
  const { positional } = parseArgs(args);
@@ -20,19 +30,17 @@ export async function run({ args, pkgRoot }) {
20
30
  process.exit(2);
21
31
  }
22
32
 
23
- if (sub === 'serve') {
24
- return runServe({ args, pkgRoot });
25
- }
26
- if (sub === 'init') {
27
- return runInit({ args, pkgRoot });
28
- }
29
- if (sub === 'export') {
30
- return runExport({ args });
31
- }
33
+ if (sub === 'serve') return runServe({ args, pkgRoot });
34
+ if (sub === 'init') return runInit({ args, pkgRoot });
35
+ if (sub === 'export') return runExport({ args });
36
+ if (sub === 'link') return runLink({ args });
37
+ if (sub === 'unlink') return runUnlink({ args });
38
+ if (sub === 'status') return runStatus({ args });
39
+ if (sub === 'adopt') return runAdopt({ args });
32
40
  }
33
41
 
34
42
  function usage() {
35
- return `maude design <serve|init|export> [options]
43
+ return `maude design <serve|init|export|link|unlink|status|adopt> [options]
36
44
 
37
45
  serve [--port N] [--root PATH]
38
46
  Start the design plugin's dev server in the current repo. Equivalent
@@ -56,6 +64,28 @@ function usage() {
56
64
  port from .design/_server.json; requires a running dev server. The
57
65
  response body is written to --out (default: current dir, server-
58
66
  supplied filename). Formats: png pdf svg html pptx canva zip.
67
+
68
+ link <url> --token <hex> [--adopt] [--force]
69
+ Pair this clone with a Maude hub. Writes .design/config.json's
70
+ linkedHub field (committed) plus the per-machine token to
71
+ ~/.config/maude/hubs.json (NEVER committed). --adopt signals to the
72
+ sync agent (Phase 9 Task 4) to push local state up unconditionally on
73
+ first connect — use it when bootstrapping a hub from a populated repo.
74
+ --force skips the /health reachability probe (e.g. behind a firewall
75
+ + trusted URL).
76
+
77
+ adopt <url> --token <hex>
78
+ Alias of 'link --adopt'. Shipped as a discoverable subcommand so
79
+ first-time bootstrap reads like 'git remote add' + 'git push -f'.
80
+
81
+ unlink [--keep-token]
82
+ Drop linkedHub from .design/config.json + remove the per-machine
83
+ token (unless --keep-token). Repo returns to solo mode. Local
84
+ .design/*.html etc. are untouched.
85
+
86
+ status [--json]
87
+ Print whether this repo is linked, the configured hub URL, when
88
+ it was linked, and a live /health probe of the hub.
59
89
  `;
60
90
  }
61
91
 
@@ -0,0 +1,361 @@
1
+ // `maude doctor` — unified workspace diagnostic.
2
+ // Combines (1) plugin dependency preflight, (2) workflows.config.json schema
3
+ // validation, (3) stack drift, (4) quality-gate additions into one report.
4
+ //
5
+ // Flags:
6
+ // --plugin <name> Scope deps section to one plugin. Config section is
7
+ // always global.
8
+ // --fix Apply safe auto-fixes: per-dep prompts for autoInstall
9
+ // deps, silent drops of unknown schema keys, silent
10
+ // drift-resync for keys where detector returned a
11
+ // concrete value, silent additive quality merges.
12
+ // NEVER touches a key the user already wrote a non-trivial
13
+ // value into.
14
+ // --json Machine-readable envelope.
15
+ //
16
+ // Exit: 1 if any hard dep missing OR any schema error; 0 otherwise (drift +
17
+ // additions are warnings).
18
+
19
+ import { spawnSync } from 'node:child_process';
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { resolve } from 'node:path';
22
+ import { stdin, stdout } from 'node:process';
23
+ import { createInterface } from 'node:readline/promises';
24
+ import { parseArgs } from '../lib/argv.mjs';
25
+ import { lintConfig } from '../lib/config-lint.mjs';
26
+ import { checkAll } from '../lib/preflight.mjs';
27
+ import { detectQualityGates, detectStack } from '../lib/stack-detect.mjs';
28
+
29
+ const CONFIG_PATH = '.ai/workflows.config.json';
30
+
31
+ // Stack keys we treat as "drift-able". detector emits every one of these;
32
+ // keys outside this list (prohibited, boundaries, motion, …) are NEVER
33
+ // touched by --fix.
34
+ const STACK_KEYS = [
35
+ 'language',
36
+ 'framework',
37
+ 'packageManager',
38
+ 'buildTool',
39
+ 'monorepo',
40
+ 'ci',
41
+ 'tests',
42
+ 'css',
43
+ 'router',
44
+ ];
45
+
46
+ export async function run({ args, pkgRoot }) {
47
+ const { flags, positional } = parseArgs(args, {
48
+ booleans: ['fix', 'json', 'help'],
49
+ });
50
+
51
+ if (positional[0] === 'help' || flags.help) {
52
+ process.stdout.write(usage());
53
+ return;
54
+ }
55
+
56
+ const repoRoot = process.cwd();
57
+ const scope = flags.plugin || null;
58
+ const fix = !!flags.fix;
59
+ const jsonMode = !!flags.json;
60
+
61
+ // ── Dependencies ────────────────────────────────────────────────────────
62
+ const depsByPlugin = {};
63
+ const pluginList = scope ? [scope] : ['design', 'flow'];
64
+ for (const p of pluginList) {
65
+ const manifestPath = resolve(pkgRoot || repoRoot, `plugins/${p}/dependencies.json`);
66
+ if (!existsSync(manifestPath)) {
67
+ depsByPlugin[p] = { error: `manifest not found at ${manifestPath}` };
68
+ continue;
69
+ }
70
+ // eslint-disable-next-line no-await-in-loop
71
+ depsByPlugin[p] = await checkAll(manifestPath);
72
+ }
73
+
74
+ // ── Config: schema, stack drift, quality additions ──────────────────────
75
+ let configReport = { exists: false };
76
+ const configFsPath = resolve(repoRoot, CONFIG_PATH);
77
+ if (existsSync(configFsPath)) {
78
+ const schemaPath = resolve(
79
+ pkgRoot || repoRoot,
80
+ 'plugins/flow/.claude-plugin/config.schema.json'
81
+ );
82
+ const config = JSON.parse(readFileSync(configFsPath, 'utf8'));
83
+ const lint = await lintConfig({ config, schemaPath });
84
+ const detectedStack = await detectStack(repoRoot);
85
+ const detectedQuality = (await detectQualityGates(repoRoot)) || {};
86
+
87
+ const drift = [];
88
+ for (const k of STACK_KEYS) {
89
+ const declared = config.stack?.[k];
90
+ const detected = detectedStack[k];
91
+ if (detected === 'unknown' || detected === null || detected === undefined) continue;
92
+ // Booleans (monorepo) compared directly.
93
+ if (typeof detected === 'boolean') {
94
+ if (declared !== detected) drift.push({ key: k, declared, detected });
95
+ continue;
96
+ }
97
+ if (declared !== detected) {
98
+ drift.push({ key: k, declared: declared ?? '(unset)', detected });
99
+ }
100
+ }
101
+
102
+ const qualityAdditions = [];
103
+ const declaredQuality = config.quality || {};
104
+ for (const [gate, cmd] of Object.entries(detectedQuality)) {
105
+ if (!declaredQuality[gate]) qualityAdditions.push({ gate, command: cmd });
106
+ }
107
+
108
+ configReport = {
109
+ exists: true,
110
+ schema: lint,
111
+ drift,
112
+ qualityAdditions,
113
+ detectedStack,
114
+ detectedQuality,
115
+ };
116
+ }
117
+
118
+ // ── Summary ─────────────────────────────────────────────────────────────
119
+ const hardDepsMissing = Object.values(depsByPlugin)
120
+ .filter((r) => r.summary)
121
+ .reduce((acc, r) => acc + r.summary.hardFailing, 0);
122
+ const schemaErrors = configReport.schema ? configReport.schema.errors.length : 0;
123
+ const driftCount = configReport.drift?.length || 0;
124
+ const additionsCount = configReport.qualityAdditions?.length || 0;
125
+ const summary = {
126
+ hardDepsMissing,
127
+ schemaErrors,
128
+ driftCount,
129
+ qualityAdditions: additionsCount,
130
+ healthy: hardDepsMissing === 0 && schemaErrors === 0,
131
+ };
132
+
133
+ if (jsonMode) {
134
+ process.stdout.write(
135
+ `${JSON.stringify({ deps: depsByPlugin, config: configReport, summary }, null, 2)}\n`
136
+ );
137
+ process.exit(summary.healthy ? 0 : 1);
138
+ }
139
+
140
+ printReport({ depsByPlugin, configReport, summary });
141
+
142
+ // ── Fix path ────────────────────────────────────────────────────────────
143
+ if (fix) {
144
+ await applyFixes({ repoRoot, configFsPath, depsByPlugin, configReport });
145
+ } else if (!summary.healthy) {
146
+ process.stdout.write(
147
+ '\nRun with --fix to: install missing deps (prompt per item), drop unknown keys, apply detected drift, add missing quality gates. Existing user values are NEVER overwritten.\n'
148
+ );
149
+ }
150
+
151
+ process.exit(summary.healthy ? 0 : 1);
152
+ }
153
+
154
+ function usage() {
155
+ return `maude doctor [--plugin <name>] [--fix] [--json]
156
+
157
+ Unified workspace diagnostic. Reports missing dependencies, config schema
158
+ errors, stack drift, and missing quality-gate declarations in one shot.
159
+
160
+ Flags:
161
+ --plugin <name> Scope dependency section to one plugin (design | flow).
162
+ Config section is global.
163
+ --fix Apply safe auto-fixes. Prompts per autoInstall dep,
164
+ drops unknown schema keys, applies drift where the
165
+ detector returned a concrete value, adds missing
166
+ quality gates. Never overwrites existing user values.
167
+ --json Machine-readable envelope.
168
+
169
+ Exit: 1 if any hard dep is missing OR config schema has errors; 0 otherwise.
170
+ `;
171
+ }
172
+
173
+ function printReport({ depsByPlugin, configReport, summary }) {
174
+ process.stdout.write('maude doctor\n\n');
175
+ for (const [name, env] of Object.entries(depsByPlugin)) {
176
+ process.stdout.write(` Dependencies (plugins/${name}):\n`);
177
+ if (env.error) {
178
+ process.stdout.write(` ✗ ${env.error}\n\n`);
179
+ continue;
180
+ }
181
+ for (const r of env.results) {
182
+ const glyph = r.status === 'ok' ? '✓' : r.status === 'unknown' ? '?' : '✗';
183
+ const label = r.id.padEnd(22);
184
+ let rhs;
185
+ if (r.status === 'ok') rhs = r.version || '';
186
+ else if (r.status === 'missing') {
187
+ const hint = (r.install && (r.install[process.platform] || r.install.preferred)) || '';
188
+ rhs = `missing${hint ? ` — ${hint}` : ''}`;
189
+ } else if (r.status === 'outdated') rhs = `outdated (${r.detail})`;
190
+ else rhs = r.detail || '';
191
+ process.stdout.write(` ${glyph} ${label} ${rhs}\n`);
192
+ }
193
+ process.stdout.write('\n');
194
+ }
195
+
196
+ if (!configReport.exists) {
197
+ process.stdout.write(
198
+ ` Config (${CONFIG_PATH}):\n not found — run \`maude init\` first.\n\n`
199
+ );
200
+ } else {
201
+ process.stdout.write(` Config schema (${CONFIG_PATH}):\n`);
202
+ if (configReport.schema.ok) {
203
+ process.stdout.write(' ✓ valid\n');
204
+ } else {
205
+ for (const e of configReport.schema.errors) {
206
+ process.stdout.write(` ✗ ${e.path}\n`);
207
+ if (e.value !== undefined)
208
+ process.stdout.write(` value: ${JSON.stringify(e.value)}\n`);
209
+ process.stdout.write(` ${e.message}\n`);
210
+ if (e.suggestion)
211
+ process.stdout.write(` suggestion: "${e.suggestion}" (closest match)\n`);
212
+ }
213
+ }
214
+ process.stdout.write('\n');
215
+
216
+ if (configReport.drift.length > 0) {
217
+ process.stdout.write(' Stack drift:\n');
218
+ process.stdout.write(' Key Declared Detected\n');
219
+ for (const d of configReport.drift) {
220
+ process.stdout.write(
221
+ ` ${d.key.padEnd(18)} ${String(d.declared).padEnd(20)} ${d.detected}\n`
222
+ );
223
+ }
224
+ process.stdout.write('\n');
225
+ }
226
+
227
+ if (configReport.qualityAdditions.length > 0) {
228
+ process.stdout.write(
229
+ ' Quality gates (additions only — existing values never overwritten):\n'
230
+ );
231
+ for (const a of configReport.qualityAdditions) {
232
+ process.stdout.write(` + quality.${a.gate.padEnd(14)} ${a.command}\n`);
233
+ }
234
+ process.stdout.write('\n');
235
+ }
236
+ }
237
+
238
+ const parts = [];
239
+ if (summary.hardDepsMissing) parts.push(`${summary.hardDepsMissing} hard dep missing`);
240
+ if (summary.schemaErrors)
241
+ parts.push(`${summary.schemaErrors} schema error${summary.schemaErrors === 1 ? '' : 's'}`);
242
+ if (summary.driftCount)
243
+ parts.push(`${summary.driftCount} stack drift${summary.driftCount === 1 ? '' : 's'}`);
244
+ if (summary.qualityAdditions)
245
+ parts.push(
246
+ `${summary.qualityAdditions} quality addition${summary.qualityAdditions === 1 ? '' : 's'}`
247
+ );
248
+ if (parts.length === 0) parts.push('all clear');
249
+ process.stdout.write(` Summary: ${parts.join(', ')}.\n`);
250
+ }
251
+
252
+ async function applyFixes({ repoRoot, configFsPath, depsByPlugin, configReport }) {
253
+ let configChanged = false;
254
+
255
+ // ── Config edits ───────────────────────────────────────────────────────
256
+ // Run FIRST — deterministic (filesystem only) and must not be blocked by
257
+ // an interactive prompt that may never resolve on a non-TTY stdin (CI,
258
+ // scripted spawns with empty input). Dep prompts come AFTER.
259
+ if (configReport.exists) {
260
+ const config = JSON.parse(readFileSync(configFsPath, 'utf8'));
261
+
262
+ // Drop unknown additionalProperties errors.
263
+ for (const e of configReport.schema.errors) {
264
+ if (e.message.startsWith('unknown property')) {
265
+ const parts = e.path.split('/').filter(Boolean);
266
+ let cur = config;
267
+ for (let i = 0; i < parts.length - 1; i++) cur = cur?.[parts[i]];
268
+ if (cur && parts.length > 0) {
269
+ delete cur[parts[parts.length - 1]];
270
+ configChanged = true;
271
+ }
272
+ }
273
+ }
274
+
275
+ // Apply drift — silent (detector returned a concrete value, so we know
276
+ // declared is wrong or stale).
277
+ if (configReport.drift.length > 0) {
278
+ if (!config.stack) config.stack = {};
279
+ for (const d of configReport.drift) {
280
+ config.stack[d.key] = d.detected;
281
+ configChanged = true;
282
+ }
283
+ }
284
+
285
+ // Add missing quality gates — silent additive merge.
286
+ if (configReport.qualityAdditions.length > 0) {
287
+ if (!config.quality) config.quality = {};
288
+ for (const a of configReport.qualityAdditions) {
289
+ if (!config.quality[a.gate]) {
290
+ config.quality[a.gate] = a.command;
291
+ configChanged = true;
292
+ }
293
+ }
294
+ }
295
+
296
+ if (configChanged) {
297
+ writeFileSync(configFsPath, `${JSON.stringify(config, null, 2)}\n`);
298
+ process.stdout.write(`\n ✓ wrote ${CONFIG_PATH}\n`);
299
+ } else {
300
+ process.stdout.write('\n (no config edits applied)\n');
301
+ }
302
+
303
+ // Note: invalid-enum errors (e.g. tests: "node-test") are NEVER auto-
304
+ // fixed — the user picks the migration target. Surface the leftover.
305
+ const remaining = configReport.schema.errors.filter(
306
+ (e) => !e.message.startsWith('unknown property')
307
+ );
308
+ if (remaining.length > 0) {
309
+ process.stdout.write(
310
+ `\n ⚠ ${remaining.length} schema error${remaining.length === 1 ? '' : 's'} not auto-fixed — user decision required:\n`
311
+ );
312
+ for (const e of remaining) {
313
+ process.stdout.write(` ${e.path}: ${e.message}\n`);
314
+ }
315
+ }
316
+ }
317
+
318
+ // ── Dependency installs ────────────────────────────────────────────────
319
+ const installable = [];
320
+ for (const env of Object.values(depsByPlugin)) {
321
+ if (!env.results) continue;
322
+ for (const r of env.results) {
323
+ if (r.status !== 'missing') continue;
324
+ installable.push(r);
325
+ }
326
+ }
327
+ // Per-dep prompt — no silent installs. Skip when stdin is not a TTY
328
+ // (CI runners, scripted spawns) so config edits above are never bypassed
329
+ // by a never-resolving readline prompt against closed stdin.
330
+ if (installable.length > 0) {
331
+ if (!stdin.isTTY) {
332
+ process.stdout.write(
333
+ `\n--- Dependency installs skipped (non-interactive stdin; ${installable.length} missing) ---\n`
334
+ );
335
+ } else {
336
+ process.stdout.write('\n--- Dependency installs (per-dep prompt) ---\n');
337
+ const rl = createInterface({ input: stdin, output: stdout });
338
+ for (const r of installable) {
339
+ const cmd = (r.install && (r.install[process.platform] || r.install.preferred)) || null;
340
+ if (!cmd) {
341
+ process.stdout.write(` skip ${r.id} — no install command declared\n`);
342
+ continue;
343
+ }
344
+ // eslint-disable-next-line no-await-in-loop
345
+ const ans = await rl.question(` Install ${r.id} via \`${cmd}\`? [y/N] `);
346
+ const norm = (ans || '').trim().toLowerCase();
347
+ if (norm === 'y' || norm === 'yes') {
348
+ process.stdout.write(` running: ${cmd}\n`);
349
+ const res = spawnSync('bash', ['-c', cmd], { stdio: 'inherit' });
350
+ if (res.status !== 0) process.stdout.write(` ✗ install failed (exit ${res.status})\n`);
351
+ } else {
352
+ process.stdout.write(` skipped ${r.id}\n`);
353
+ }
354
+ }
355
+ rl.close();
356
+ }
357
+ }
358
+
359
+ // Use the actually-changed state to decide on re-run hint.
360
+ void repoRoot;
361
+ }