@1agh/maude 0.19.1 → 0.21.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 (105) 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 +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -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 +17 -9
  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 +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -0,0 +1,185 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const REPO_ROOT = resolve(__dirname, '..', '..');
12
+ const MAUDE_BIN = resolve(REPO_ROOT, 'cli', 'bin', 'maude.mjs');
13
+
14
+ function spawnDoctor(args, cwd) {
15
+ return spawnSync('node', [MAUDE_BIN, 'doctor', ...args], {
16
+ cwd,
17
+ encoding: 'utf8',
18
+ env: { ...process.env, NO_COLOR: '1' },
19
+ });
20
+ }
21
+
22
+ function makeTempRepo() {
23
+ const root = mkdtempSync(join(tmpdir(), 'doctor-'));
24
+ // Symlink the plugin manifests + schema in by writing minimal copies of
25
+ // each — we don't need the full repo, just the files doctor reads.
26
+ // Easier: write a stripped repo with package.json + .ai + plugins/.
27
+ mkdirSync(join(root, '.ai'), { recursive: true });
28
+ mkdirSync(join(root, 'plugins/design'), { recursive: true });
29
+ mkdirSync(join(root, 'plugins/flow/.claude-plugin'), { recursive: true });
30
+ return root;
31
+ }
32
+
33
+ function copyAsset(src, destRel, fixtureRoot) {
34
+ const content = readFileSync(resolve(REPO_ROOT, src), 'utf8');
35
+ const dest = join(fixtureRoot, destRel);
36
+ mkdirSync(dirname(dest), { recursive: true });
37
+ writeFileSync(dest, content);
38
+ }
39
+
40
+ function bootstrap(fixtureRoot) {
41
+ // Drop ALL manifests + schema into the fixture so doctor can resolve them
42
+ // from cwd. The CLI uses `pkgRoot` for plugin manifests (passed via
43
+ // cli/bin/maude.mjs as repo root) AND cwd for .ai/workflows.config.json.
44
+ // Since we spawn with cwd=fixtureRoot and `pkgRoot` is resolved from the
45
+ // CLI's package.json location, doctor will still load manifests from the
46
+ // real repo root. That's intentional: we test the workflow, not a
47
+ // hypothetical "fake plugin manifests".
48
+ // What we DO need in the fixture: a valid `.ai/workflows.config.json` or
49
+ // an absent one (for the "config not found" branch).
50
+ void fixtureRoot;
51
+ void copyAsset;
52
+ }
53
+
54
+ test('healthy minimal config — no schema errors, exit reflects health', () => {
55
+ const root = makeTempRepo();
56
+ bootstrap(root);
57
+ // Drop a minimal valid config.
58
+ writeFileSync(
59
+ join(root, '.ai/workflows.config.json'),
60
+ `${JSON.stringify({ name: 'x' }, null, 2)}\n`
61
+ );
62
+
63
+ const r = spawnDoctor([], root);
64
+ // No drift / additions because there's no package.json in the temp repo.
65
+ assert.match(r.stdout, /Config schema/);
66
+ assert.match(r.stdout, /✓ valid/);
67
+ // exit 0 unless hard dep missing on the dev machine (these tests assume
68
+ // the machine running them has node + git — both checked at the test
69
+ // suite level by tests A7 etc.).
70
+ });
71
+
72
+ test('schema error: unknown top-level property forces exit 1', () => {
73
+ const root = makeTempRepo();
74
+ writeFileSync(
75
+ join(root, '.ai/workflows.config.json'),
76
+ `${JSON.stringify({ name: 'x', futureKnob: true }, null, 2)}\n`
77
+ );
78
+
79
+ const r = spawnDoctor([], root);
80
+ assert.equal(r.status, 1);
81
+ assert.match(r.stdout, /unknown property "futureKnob"/);
82
+ });
83
+
84
+ test('schema error: enum violation surfaces suggestion in output', () => {
85
+ const root = makeTempRepo();
86
+ writeFileSync(
87
+ join(root, '.ai/workflows.config.json'),
88
+ `${JSON.stringify({ name: 'x', conventions: { commits: 'conventionall' } }, null, 2)}\n`
89
+ );
90
+
91
+ const r = spawnDoctor([], root);
92
+ assert.equal(r.status, 1);
93
+ assert.match(r.stdout, /conventions\/commits/);
94
+ assert.match(r.stdout, /suggestion: "conventional"/);
95
+ });
96
+
97
+ test('--json emits structured envelope', () => {
98
+ const root = makeTempRepo();
99
+ writeFileSync(
100
+ join(root, '.ai/workflows.config.json'),
101
+ `${JSON.stringify({ name: 'x' }, null, 2)}\n`
102
+ );
103
+
104
+ const r = spawnDoctor(['--json'], root);
105
+ // Parse — must be valid JSON.
106
+ const env = JSON.parse(r.stdout);
107
+ assert.ok(env.deps);
108
+ assert.ok(env.config);
109
+ assert.ok(env.summary);
110
+ assert.equal(typeof env.summary.healthy, 'boolean');
111
+ assert.equal(typeof env.summary.schemaErrors, 'number');
112
+ });
113
+
114
+ test('--plugin design scopes deps to one plugin (config still global)', () => {
115
+ const root = makeTempRepo();
116
+ writeFileSync(
117
+ join(root, '.ai/workflows.config.json'),
118
+ `${JSON.stringify({ name: 'x' }, null, 2)}\n`
119
+ );
120
+
121
+ const r = spawnDoctor(['--plugin', 'design', '--json'], root);
122
+ const env = JSON.parse(r.stdout);
123
+ assert.ok(env.deps.design);
124
+ assert.equal(env.deps.flow, undefined);
125
+ // Config section is still present.
126
+ assert.equal(env.config.exists, true);
127
+ });
128
+
129
+ test('config missing — section reports not-found, no schema check', () => {
130
+ const root = makeTempRepo();
131
+ // No .ai/workflows.config.json — leave .ai dir but no file.
132
+
133
+ const r = spawnDoctor(['--json'], root);
134
+ const env = JSON.parse(r.stdout);
135
+ assert.equal(env.config.exists, false);
136
+ assert.equal(env.summary.schemaErrors, 0);
137
+ });
138
+
139
+ test('--fix on config with unknown property drops it; existing values preserved', () => {
140
+ const root = makeTempRepo();
141
+ const initial = {
142
+ name: 'x',
143
+ futureKnob: true,
144
+ conventions: { prohibited: ['lodash'] },
145
+ };
146
+ writeFileSync(join(root, '.ai/workflows.config.json'), `${JSON.stringify(initial, null, 2)}\n`);
147
+
148
+ // --fix doesn't prompt for installs because there are no missing deps on
149
+ // the dev machine. Provide empty stdin just in case.
150
+ spawnSync('node', [MAUDE_BIN, 'doctor', '--fix'], {
151
+ cwd: root,
152
+ encoding: 'utf8',
153
+ input: '',
154
+ env: { ...process.env, NO_COLOR: '1' },
155
+ });
156
+
157
+ const after = JSON.parse(readFileSync(join(root, '.ai/workflows.config.json'), 'utf8'));
158
+ assert.equal(after.futureKnob, undefined, 'unknown property should be dropped');
159
+ // User-tuned value preserved.
160
+ assert.deepEqual(after.conventions.prohibited, ['lodash']);
161
+ });
162
+
163
+ test('summary line reflects counts (drift/additions tracked via package.json)', () => {
164
+ const root = makeTempRepo();
165
+ writeFileSync(
166
+ join(root, '.ai/workflows.config.json'),
167
+ `${JSON.stringify({ name: 'x', stack: { language: 'javascript' } }, null, 2)}\n`
168
+ );
169
+ // Drop a tsconfig so the language detector picks up TS.
170
+ writeFileSync(join(root, 'tsconfig.json'), '{}');
171
+ writeFileSync(
172
+ join(root, 'package.json'),
173
+ JSON.stringify({ name: 'x', scripts: { lint: 'echo' } })
174
+ );
175
+
176
+ const r = spawnDoctor(['--json'], root);
177
+ const env = JSON.parse(r.stdout);
178
+ // Drift: language js → typescript.
179
+ const langDrift = env.config.drift.find((d) => d.key === 'language');
180
+ assert.ok(langDrift, `expected language drift, got ${JSON.stringify(env.config.drift)}`);
181
+ assert.equal(langDrift.detected, 'typescript');
182
+ // Quality addition: lint.
183
+ const lintAdd = env.config.qualityAdditions.find((a) => a.gate === 'lint');
184
+ assert.ok(lintAdd);
185
+ });
@@ -31,6 +31,25 @@ COMMANDS
31
31
  full discovery. --no-discovery uses Recommended defaults; --discovery-
32
32
  payload reads pre-computed answers from JSON.
33
33
 
34
+ hub serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
35
+ Start the self-hostable Yjs sync hub (Phase 9). Defaults to port 1234,
36
+ data dir ./data. --dev mints a mau_dev_<hex> token + prints the
37
+ connect command before booting. Local-dev-tree only in v1.1 Task 2.
38
+
39
+ hub token generate --label NAME [--data PATH] [--dev]
40
+ Mint a new mau_<32hex> token in <data>/tokens.json. Prints the raw
41
+ token ONCE plus the ready-to-paste 'maude design link' command.
42
+
43
+ hub status [URL] [--json]
44
+ Probe a hub's /health endpoint. URL defaults to http://localhost:1234.
45
+
46
+ doctor [--plugin <name>] [--fix] [--json]
47
+ Unified workspace health check. Reports missing dependencies, config
48
+ schema errors, stack drift, and missing quality-gate declarations in
49
+ one shot. --fix applies safe auto-fixes (prompts per dep install;
50
+ never overwrites existing config values). --json for programmatic
51
+ consumers.
52
+
34
53
  help Print this help.
35
54
  version Print the installed version.
36
55
 
@@ -40,6 +59,11 @@ EXAMPLES
40
59
  maude config get motion.complex
41
60
  maude design serve --port 4399
42
61
  maude design init --no-discovery --name acme-app
62
+ maude hub serve --dev
63
+ maude hub token generate --label alice
64
+ maude hub status http://localhost:1234
65
+ maude doctor
66
+ maude doctor --fix
43
67
 
44
68
  NOTES
45
69
  'maude init' does mechanical scaffolding of .ai/ only.
@@ -0,0 +1,231 @@
1
+ // maude hub — self-hostable Yjs sync hub control plane.
2
+ //
3
+ // Phase 9 Task 2 (minimal subset): serve + token generate + status.
4
+ // Token rotate, deploy fly|docker land in subsequent slices.
5
+ // See .ai/plans/phase-9-self-hosted-hub-file-sync.md.
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+
11
+ import { parseArgs } from '../lib/argv.mjs';
12
+
13
+ const SUBCOMMANDS = new Set(['serve', 'token', 'status', 'help']);
14
+
15
+ export async function run({ args, pkgRoot }) {
16
+ const { positional } = parseArgs(args);
17
+ const sub = positional[0];
18
+
19
+ if (!sub || sub === 'help') {
20
+ process.stdout.write(usage());
21
+ return;
22
+ }
23
+ if (!SUBCOMMANDS.has(sub)) {
24
+ process.stderr.write(`maude hub: unknown subcommand "${sub}"\n${usage()}`);
25
+ process.exit(2);
26
+ }
27
+
28
+ if (sub === 'serve') return runServe({ args, pkgRoot });
29
+ if (sub === 'token') return runToken({ args, pkgRoot });
30
+ if (sub === 'status') return runStatus({ args });
31
+ }
32
+
33
+ function usage() {
34
+ return `maude hub <serve|token|status> [options]
35
+
36
+ serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
37
+ Start the self-hostable Yjs sync hub in the current process tree.
38
+ --port listen port (default 1234, env PORT)
39
+ --data hub.db + tokens.json dir (default ./data, env DATA_DIR)
40
+ --secret HUB_SECRET escape-hatch token (env HUB_SECRET)
41
+ --insecure-http cosmetic log-only flag for non-TLS dev
42
+ --dev generate a one-shot mau_dev_<hex> token, print the
43
+ connect command, then run the hub. Convenience for
44
+ contributor onboarding — drops tokens.json on exit
45
+ is NOT performed (tokens persist; clean by hand).
46
+
47
+ token generate --label NAME [--data PATH] [--dev]
48
+ Generate a new mau_<32hex> token and append it to
49
+ <data>/tokens.json with the given label. Prints the raw token ONCE,
50
+ plus the ready-to-paste 'maude design link' connect command.
51
+
52
+ --dev produces a mau_dev_<hex> token (convention only — same auth).
53
+
54
+ status [URL] [--json]
55
+ HTTP GET <url>/health, print uptime/version/token-count. URL defaults
56
+ to http://localhost:1234. --json emits the raw response.
57
+
58
+ NOTES
59
+ Local dev only in v1.1 Task 2 slice — production-install packaging
60
+ (adding plugins/design/hub to package.json:files + dep hoisting) lands
61
+ in a follow-up slice. For now, run inside a 'maude' source checkout.
62
+
63
+ EXAMPLES
64
+ maude hub serve --port 4400
65
+ maude hub token generate --label alice
66
+ maude hub status http://localhost:4400
67
+ `;
68
+ }
69
+
70
+ // ---------------------------------------------------------------- serve
71
+
72
+ async function runServe({ args, pkgRoot }) {
73
+ const { flags } = parseArgs(args.slice(args.indexOf('serve') + 1), {
74
+ booleans: ['insecure-http', 'dev'],
75
+ });
76
+
77
+ const hubRoot = findHubRoot(pkgRoot);
78
+ if (!hubRoot) {
79
+ process.stderr.write(
80
+ 'maude hub serve: hub workspace not found.\n\n' +
81
+ 'This slice ships in local dev tree only. Production-install packaging\n' +
82
+ 'is a follow-up Task 2 sub-slice. Run inside a maude source checkout.\n'
83
+ );
84
+ process.exit(1);
85
+ }
86
+
87
+ const env = { ...process.env };
88
+ if (flags.port) env.PORT = String(flags.port);
89
+ if (flags.data) env.DATA_DIR = resolve(String(flags.data));
90
+ if (flags.secret) env.HUB_SECRET = String(flags.secret);
91
+ if (flags['insecure-http']) env.HUB_INSECURE_HTTP = '1';
92
+
93
+ if (flags.dev) {
94
+ const dataDir = env.DATA_DIR ?? resolve(process.cwd(), 'data');
95
+ const { addToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
96
+ const port = env.PORT ?? '1234';
97
+ const record = addToken(dataDir, { label: 'dev', dev: true });
98
+ process.stdout.write(
99
+ `[hub] --dev token written to ${dataDir}/tokens.json:\n label: ${record.label}\n value: ${record.value}\n\n Connect from a peer:\n maude design link http://localhost:${port} --token=${record.value}\n\n`
100
+ );
101
+ }
102
+
103
+ const entry = resolveHubEntry(hubRoot);
104
+ const child = spawn(process.execPath, [entry], {
105
+ stdio: 'inherit',
106
+ env,
107
+ });
108
+ child.on('exit', (code) => process.exit(code ?? 0));
109
+ child.on('error', (err) => {
110
+ process.stderr.write(`maude hub serve: ${err.message}\n`);
111
+ process.exit(1);
112
+ });
113
+ }
114
+
115
+ // ---------------------------------------------------------------- token generate
116
+
117
+ async function runToken({ args, pkgRoot }) {
118
+ const tail = args.slice(args.indexOf('token') + 1);
119
+ const op = tail[0];
120
+
121
+ if (op !== 'generate') {
122
+ process.stderr.write(
123
+ `maude hub token: only "generate" is supported in v1.1 Task 2 slice.\n` +
124
+ `"rotate" lands in a follow-up. Use "generate" with --label to mint a new token.\n`
125
+ );
126
+ process.exit(2);
127
+ }
128
+
129
+ const { flags } = parseArgs(tail.slice(1), { booleans: ['dev'] });
130
+ const label = flags.label;
131
+ if (!label || typeof label !== 'string') {
132
+ process.stderr.write('maude hub token generate: --label <name> is required.\n');
133
+ process.exit(2);
134
+ }
135
+ const dataDir = flags.data ? resolve(String(flags.data)) : resolve(process.cwd(), 'data');
136
+
137
+ const hubRoot = findHubRoot(pkgRoot);
138
+ if (!hubRoot) {
139
+ process.stderr.write('maude hub token generate: hub workspace not found.\n');
140
+ process.exit(1);
141
+ }
142
+ const { addToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
143
+ const record = addToken(dataDir, { label, dev: !!flags.dev });
144
+
145
+ process.stdout.write(
146
+ `[hub] token written to ${dataDir}/tokens.json:\n label: ${record.label}\n value: ${record.value}\n created: ${new Date(record.createdAt).toISOString()}\n\nConnect from a peer (replace HOST):\n maude design link https://HOST --token=${record.value}\n\nRestart the hub if it is already running for the new token to take effect.\n`
147
+ );
148
+ }
149
+
150
+ // ---------------------------------------------------------------- status
151
+
152
+ async function runStatus({ args }) {
153
+ const { flags, positional } = parseArgs(args.slice(args.indexOf('status') + 1), {
154
+ booleans: ['json'],
155
+ });
156
+ let url = positional[0] ?? 'http://localhost:1234';
157
+ if (url.startsWith('ws://')) url = `http://${url.slice('ws://'.length)}`;
158
+ if (url.startsWith('wss://')) url = `https://${url.slice('wss://'.length)}`;
159
+ if (url.endsWith('/')) url = url.slice(0, -1);
160
+
161
+ const target = `${url}/health`;
162
+ let payload;
163
+ try {
164
+ const res = await fetch(target);
165
+ if (!res.ok) {
166
+ process.stderr.write(
167
+ `maude hub status: ${target} returned ${res.status} ${res.statusText}\n`
168
+ );
169
+ process.exit(1);
170
+ }
171
+ payload = await res.json();
172
+ } catch (err) {
173
+ process.stderr.write(`maude hub status: cannot reach ${target}: ${err.message}\n`);
174
+ process.exit(1);
175
+ }
176
+
177
+ if (flags.json) {
178
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
179
+ return;
180
+ }
181
+
182
+ const uptimeS = Math.round((payload.uptimeMs ?? 0) / 1000);
183
+ process.stdout.write(
184
+ `Maude Hub @ ${url}\n` +
185
+ ` ok: ${payload.ok}\n` +
186
+ ` version: ${payload.version}\n` +
187
+ ` uptime: ${formatDuration(uptimeS)}\n` +
188
+ ` port: ${payload.port}\n` +
189
+ ` dataDir: ${payload.dataDir}\n` +
190
+ ` tokens: ${payload.tokenCount} (mode: ${payload.authMode})\n`
191
+ );
192
+ }
193
+
194
+ // ---------------------------------------------------------------- helpers
195
+
196
+ function findHubRoot(pkgRoot) {
197
+ const dev = resolve(pkgRoot, 'plugins', 'design', 'hub');
198
+ if (existsSync(resolve(dev, 'package.json'))) {
199
+ try {
200
+ const pkg = JSON.parse(readFileSync(resolve(dev, 'package.json'), 'utf8'));
201
+ if (pkg.name === '@maude/hub') return dev;
202
+ } catch {
203
+ /* fall through */
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ function resolveHubEntry(hubRoot) {
210
+ // Prefer the bundled binary (matches the production-install path); fall back
211
+ // to source for fresh dev trees where bun build hasn't been run yet.
212
+ const bundle = resolve(hubRoot, 'dist', 'hub.bundle.mjs');
213
+ if (existsSync(bundle)) return bundle;
214
+ return resolve(hubRoot, 'src', 'server.mjs');
215
+ }
216
+
217
+ function resolveHubModule(hubRoot, relPath) {
218
+ // Dynamic import expects a URL or absolute path. Bundle does not include
219
+ // standalone module exports (tokens.mjs lives in src/), so we always load
220
+ // from src/ for CLI helpers — independent of whether the bundle exists.
221
+ return `file://${resolve(hubRoot, relPath)}`;
222
+ }
223
+
224
+ function formatDuration(seconds) {
225
+ if (seconds < 60) return `${seconds}s`;
226
+ const m = Math.floor(seconds / 60);
227
+ const s = seconds % 60;
228
+ if (m < 60) return `${m}m${s.toString().padStart(2, '0')}s`;
229
+ const h = Math.floor(m / 60);
230
+ return `${h}h${(m % 60).toString().padStart(2, '0')}m${s.toString().padStart(2, '0')}s`;
231
+ }
@@ -0,0 +1,87 @@
1
+ // `maude hub token generate` CLI end-to-end via spawnSync — verifies stdout
2
+ // shape, file contents, and label-overwrite (rotation) idempotence.
3
+
4
+ import assert from 'node:assert/strict';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { test } from 'node:test';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
13
+
14
+ function runCli(args, { cwd } = {}) {
15
+ return spawnSync(process.execPath, [BIN, ...args], {
16
+ cwd: cwd ?? process.cwd(),
17
+ encoding: 'utf8',
18
+ });
19
+ }
20
+
21
+ function withDataDir(fn) {
22
+ const dataDir = mkdtempSync(join(tmpdir(), 'maude-cli-hub-'));
23
+ try {
24
+ return fn(dataDir);
25
+ } finally {
26
+ rmSync(dataDir, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ test('hub help prints subcommand summary on stdout', () => {
31
+ const res = runCli(['hub', 'help']);
32
+ assert.equal(res.status, 0, res.stderr);
33
+ assert.match(res.stdout, /maude hub <serve\|token\|status>/);
34
+ assert.match(res.stdout, /token generate --label NAME/);
35
+ });
36
+
37
+ test('hub token generate without --label exits 2', () => {
38
+ const res = runCli(['hub', 'token', 'generate']);
39
+ assert.equal(res.status, 2);
40
+ assert.match(res.stderr, /--label <name> is required/);
41
+ });
42
+
43
+ test('hub token generate writes tokens.json + prints the connect command', () => {
44
+ withDataDir((dataDir) => {
45
+ const res = runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
46
+ assert.equal(res.status, 0, res.stderr);
47
+ assert.match(res.stdout, /label:\s+alice/);
48
+ assert.match(res.stdout, /value:\s+mau_[0-9a-f]{32}/);
49
+ assert.match(res.stdout, /maude design link/);
50
+
51
+ const raw = readFileSync(join(dataDir, 'tokens.json'), 'utf8');
52
+ const parsed = JSON.parse(raw);
53
+ assert.equal(parsed.tokens.length, 1);
54
+ assert.equal(parsed.tokens[0].label, 'alice');
55
+ assert.match(parsed.tokens[0].value, /^mau_[0-9a-f]{32}$/);
56
+ });
57
+ });
58
+
59
+ test('hub token generate --label is idempotent (overwrites in place)', () => {
60
+ withDataDir((dataDir) => {
61
+ runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
62
+ runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
63
+
64
+ const parsed = JSON.parse(readFileSync(join(dataDir, 'tokens.json'), 'utf8'));
65
+ assert.equal(parsed.tokens.length, 1);
66
+ });
67
+ });
68
+
69
+ test('hub token generate --dev uses mau_dev_ prefix', () => {
70
+ withDataDir((dataDir) => {
71
+ const res = runCli(['hub', 'token', 'generate', '--label', 'dev', '--data', dataDir, '--dev']);
72
+ assert.equal(res.status, 0, res.stderr);
73
+ assert.match(res.stdout, /value:\s+mau_dev_[0-9a-f]{32}/);
74
+ });
75
+ });
76
+
77
+ test('hub status against an unreachable URL exits 1 with diagnostic', () => {
78
+ const res = runCli(['hub', 'status', 'http://127.0.0.1:1']);
79
+ assert.equal(res.status, 1);
80
+ assert.match(res.stderr, /cannot reach/);
81
+ });
82
+
83
+ test('hub serve|token|status are the only known subcommands', () => {
84
+ const res = runCli(['hub', 'nope']);
85
+ assert.equal(res.status, 2);
86
+ assert.match(res.stderr, /unknown subcommand "nope"/);
87
+ });
@@ -0,0 +1,141 @@
1
+ // Schema-validate a workflows.config.json against the flow plugin's
2
+ // JSON Schema (Draft 2020-12), with a Levenshtein-distance post-pass that
3
+ // turns "enum" errors into actionable suggestions.
4
+ //
5
+ // Pure: no I/O for output, no process.exit. The CLI (cli/commands/doctor.mjs)
6
+ // handles all formatting and termination. Two input shapes accepted:
7
+ //
8
+ // lintConfig({ configPath, schemaPath }) // loads from disk
9
+ // lintConfig({ config, schema }) // already-parsed objects
10
+ //
11
+ // Returns { ok, errors }, where each error is
12
+ // { path: '/quality/lint', message: '...', value, allowed?, suggestion? }
13
+ //
14
+ // `additionalProperties: false` errors in the schema surface as "unknown
15
+ // property X" — actionable on its own.
16
+
17
+ import { readFile } from 'node:fs/promises';
18
+ import { resolve } from 'node:path';
19
+ import addFormats from 'ajv-formats';
20
+ import Ajv2020 from 'ajv/dist/2020.js';
21
+
22
+ function levenshtein(a, b) {
23
+ if (a === b) return 0;
24
+ const m = a.length;
25
+ const n = b.length;
26
+ if (m === 0) return n;
27
+ if (n === 0) return m;
28
+ const prev = new Array(n + 1);
29
+ const curr = new Array(n + 1);
30
+ for (let j = 0; j <= n; j++) prev[j] = j;
31
+ for (let i = 1; i <= m; i++) {
32
+ curr[0] = i;
33
+ for (let j = 1; j <= n; j++) {
34
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
35
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
36
+ }
37
+ for (let j = 0; j <= n; j++) prev[j] = curr[j];
38
+ }
39
+ return prev[n];
40
+ }
41
+
42
+ function suggestEnum(value, allowed) {
43
+ if (typeof value !== 'string') return null;
44
+ let best = null;
45
+ let bestDist = Number.POSITIVE_INFINITY;
46
+ for (const candidate of allowed) {
47
+ if (typeof candidate !== 'string') continue;
48
+ const d = levenshtein(value, candidate);
49
+ if (d < bestDist) {
50
+ bestDist = d;
51
+ best = candidate;
52
+ }
53
+ }
54
+ if (bestDist <= 3) return best;
55
+ return null;
56
+ }
57
+
58
+ function ajvErrorToReport(e) {
59
+ // e.instancePath is JSON Pointer ('' = root). For additionalProperties
60
+ // violations, Ajv puts the offending key in e.params.additionalProperty,
61
+ // and instancePath points at the parent.
62
+ const path = e.instancePath || '/';
63
+ if (e.keyword === 'additionalProperties') {
64
+ const sep = path === '/' || path === '' ? '' : path;
65
+ return {
66
+ path: `${sep}/${e.params.additionalProperty}`,
67
+ message: `unknown property "${e.params.additionalProperty}" (additionalProperties: false)`,
68
+ value: undefined,
69
+ };
70
+ }
71
+ if (e.keyword === 'enum') {
72
+ return {
73
+ path,
74
+ message: `must be one of: ${e.params.allowedValues.join(' | ')}`,
75
+ value: e.data,
76
+ allowed: e.params.allowedValues,
77
+ };
78
+ }
79
+ if (e.keyword === 'required') {
80
+ const sep = path === '/' || path === '' ? '' : path;
81
+ return {
82
+ path: `${sep}/${e.params.missingProperty}`,
83
+ message: `missing required property "${e.params.missingProperty}"`,
84
+ value: undefined,
85
+ };
86
+ }
87
+ if (e.keyword === 'type') {
88
+ return {
89
+ path,
90
+ message: `must be ${Array.isArray(e.params.type) ? e.params.type.join(' | ') : e.params.type}`,
91
+ value: e.data,
92
+ };
93
+ }
94
+ if (e.keyword === 'minLength') {
95
+ return {
96
+ path,
97
+ message: `must have at least ${e.params.limit} character${e.params.limit === 1 ? '' : 's'}`,
98
+ value: e.data,
99
+ };
100
+ }
101
+ return {
102
+ path,
103
+ message: e.message || e.keyword,
104
+ value: e.data,
105
+ };
106
+ }
107
+
108
+ export async function lintConfig(input) {
109
+ let config = input.config;
110
+ let schema = input.schema;
111
+ if (config === undefined) {
112
+ if (!input.configPath) throw new Error('lintConfig: need `config` or `configPath`');
113
+ config = JSON.parse(await readFile(resolve(input.configPath), 'utf8'));
114
+ }
115
+ if (schema === undefined) {
116
+ if (!input.schemaPath) throw new Error('lintConfig: need `schema` or `schemaPath`');
117
+ schema = JSON.parse(await readFile(resolve(input.schemaPath), 'utf8'));
118
+ }
119
+
120
+ // Ajv2020 is the Draft-2020-12 entry. Default `import Ajv from 'ajv'` is
121
+ // Draft-07 and silently accepts schemas with $schema: 2020-12 while
122
+ // skipping new keywords — that would mask bugs.
123
+ const ajv = new Ajv2020({ allErrors: true, strict: false, verbose: true });
124
+ addFormats(ajv);
125
+ const validate = ajv.compile(schema);
126
+ const ok = validate(config);
127
+ if (ok) return { ok: true, errors: [] };
128
+
129
+ const errors = (validate.errors || []).map((e) => {
130
+ const report = ajvErrorToReport(e);
131
+ if (report.allowed && typeof report.value === 'string') {
132
+ const s = suggestEnum(report.value, report.allowed);
133
+ if (s !== null) report.suggestion = s;
134
+ }
135
+ return report;
136
+ });
137
+ return { ok: false, errors };
138
+ }
139
+
140
+ // Exported for the test file.
141
+ export const _internals = { levenshtein, suggestEnum };