@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
@@ -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,245 @@
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
+ If unset on an empty hub, a one-time bootstrap link is
42
+ printed to logs — open it in a browser to claim admin.
43
+ --insecure-http cosmetic log-only flag for non-TLS dev
44
+ --dev generate a one-shot mau_dev_<hex> token, print the
45
+ connect command, then run the hub. Convenience for
46
+ contributor onboarding — drops tokens.json on exit
47
+ is NOT performed (tokens persist; clean by hand).
48
+
49
+ On boot the hub prints its /admin URL. The admin UI generates invite
50
+ tokens, lists peers, and rotates tokens without shelling into the host.
51
+
52
+ token generate --label NAME [--data PATH] [--dev]
53
+ Generate a new mau_<32hex> token and append it to
54
+ <data>/tokens.json with the given label. Prints the raw token ONCE,
55
+ plus the ready-to-paste 'maude design link' connect command.
56
+
57
+ --dev produces a mau_dev_<hex> token (convention only — same auth).
58
+
59
+ Equivalent to the "Generate invite" button in the /admin UI — use
60
+ whichever is more convenient for the deploy.
61
+
62
+ status [URL] [--json]
63
+ HTTP GET <url>/health, print uptime/version/token-count/peers. URL
64
+ defaults to http://localhost:1234. --json emits the raw response.
65
+
66
+ NOTES
67
+ Local dev only in v1.1 Task 2 slice — production-install packaging
68
+ (adding plugins/design/hub to package.json:files + dep hoisting) lands
69
+ in a follow-up slice. For now, run inside a 'maude' source checkout.
70
+
71
+ EXAMPLES
72
+ maude hub serve --port 4400
73
+ maude hub token generate --label alice
74
+ maude hub status http://localhost:4400
75
+
76
+ # Recommended flow on a fresh deploy:
77
+ # 1. maude hub serve → copy bootstrap link from logs
78
+ # 2. open the link in browser → first-run wizard, mint admin secret
79
+ # 3. click "Generate invite" → copy 'maude design link …' command
80
+ # 4. paste on the peer machine → linked
81
+ `;
82
+ }
83
+
84
+ // ---------------------------------------------------------------- serve
85
+
86
+ async function runServe({ args, pkgRoot }) {
87
+ const { flags } = parseArgs(args.slice(args.indexOf('serve') + 1), {
88
+ booleans: ['insecure-http', 'dev'],
89
+ });
90
+
91
+ const hubRoot = findHubRoot(pkgRoot);
92
+ if (!hubRoot) {
93
+ process.stderr.write(
94
+ 'maude hub serve: hub workspace not found.\n\n' +
95
+ 'This slice ships in local dev tree only. Production-install packaging\n' +
96
+ 'is a follow-up Task 2 sub-slice. Run inside a maude source checkout.\n'
97
+ );
98
+ process.exit(1);
99
+ }
100
+
101
+ const env = { ...process.env };
102
+ if (flags.port) env.PORT = String(flags.port);
103
+ if (flags.data) env.DATA_DIR = resolve(String(flags.data));
104
+ if (flags.secret) env.HUB_SECRET = String(flags.secret);
105
+ if (flags['insecure-http']) env.HUB_INSECURE_HTTP = '1';
106
+
107
+ if (flags.dev) {
108
+ const dataDir = env.DATA_DIR ?? resolve(process.cwd(), 'data');
109
+ const { addToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
110
+ const port = env.PORT ?? '1234';
111
+ const record = addToken(dataDir, { label: 'dev', dev: true });
112
+ process.stdout.write(
113
+ `[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`
114
+ );
115
+ }
116
+
117
+ const entry = resolveHubEntry(hubRoot);
118
+ const child = spawn(process.execPath, [entry], {
119
+ stdio: 'inherit',
120
+ env,
121
+ });
122
+ child.on('exit', (code) => process.exit(code ?? 0));
123
+ child.on('error', (err) => {
124
+ process.stderr.write(`maude hub serve: ${err.message}\n`);
125
+ process.exit(1);
126
+ });
127
+ }
128
+
129
+ // ---------------------------------------------------------------- token generate
130
+
131
+ async function runToken({ args, pkgRoot }) {
132
+ const tail = args.slice(args.indexOf('token') + 1);
133
+ const op = tail[0];
134
+
135
+ if (op !== 'generate') {
136
+ process.stderr.write(
137
+ `maude hub token: only "generate" is supported in v1.1 Task 2 slice.\n` +
138
+ `"rotate" lands in a follow-up. Use "generate" with --label to mint a new token.\n`
139
+ );
140
+ process.exit(2);
141
+ }
142
+
143
+ const { flags } = parseArgs(tail.slice(1), { booleans: ['dev'] });
144
+ const label = flags.label;
145
+ if (!label || typeof label !== 'string') {
146
+ process.stderr.write('maude hub token generate: --label <name> is required.\n');
147
+ process.exit(2);
148
+ }
149
+ const dataDir = flags.data ? resolve(String(flags.data)) : resolve(process.cwd(), 'data');
150
+
151
+ const hubRoot = findHubRoot(pkgRoot);
152
+ if (!hubRoot) {
153
+ process.stderr.write('maude hub token generate: hub workspace not found.\n');
154
+ process.exit(1);
155
+ }
156
+ const { addToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
157
+ const record = addToken(dataDir, { label, dev: !!flags.dev });
158
+
159
+ process.stdout.write(
160
+ `[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`
161
+ );
162
+ }
163
+
164
+ // ---------------------------------------------------------------- status
165
+
166
+ async function runStatus({ args }) {
167
+ const { flags, positional } = parseArgs(args.slice(args.indexOf('status') + 1), {
168
+ booleans: ['json'],
169
+ });
170
+ let url = positional[0] ?? 'http://localhost:1234';
171
+ if (url.startsWith('ws://')) url = `http://${url.slice('ws://'.length)}`;
172
+ if (url.startsWith('wss://')) url = `https://${url.slice('wss://'.length)}`;
173
+ if (url.endsWith('/')) url = url.slice(0, -1);
174
+
175
+ const target = `${url}/health`;
176
+ let payload;
177
+ try {
178
+ const res = await fetch(target);
179
+ if (!res.ok) {
180
+ process.stderr.write(
181
+ `maude hub status: ${target} returned ${res.status} ${res.statusText}\n`
182
+ );
183
+ process.exit(1);
184
+ }
185
+ payload = await res.json();
186
+ } catch (err) {
187
+ process.stderr.write(`maude hub status: cannot reach ${target}: ${err.message}\n`);
188
+ process.exit(1);
189
+ }
190
+
191
+ if (flags.json) {
192
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
193
+ return;
194
+ }
195
+
196
+ const uptimeS = Math.round((payload.uptimeMs ?? 0) / 1000);
197
+ process.stdout.write(
198
+ `Maude Hub @ ${url}\n` +
199
+ ` ok: ${payload.ok}\n` +
200
+ ` version: ${payload.version}\n` +
201
+ ` uptime: ${formatDuration(uptimeS)}\n` +
202
+ ` port: ${payload.port}\n` +
203
+ ` dataDir: ${payload.dataDir}\n` +
204
+ ` tokens: ${payload.tokenCount} (mode: ${payload.authMode})\n`
205
+ );
206
+ }
207
+
208
+ // ---------------------------------------------------------------- helpers
209
+
210
+ function findHubRoot(pkgRoot) {
211
+ const dev = resolve(pkgRoot, 'plugins', 'design', 'hub');
212
+ if (existsSync(resolve(dev, 'package.json'))) {
213
+ try {
214
+ const pkg = JSON.parse(readFileSync(resolve(dev, 'package.json'), 'utf8'));
215
+ if (pkg.name === '@maude/hub') return dev;
216
+ } catch {
217
+ /* fall through */
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+
223
+ function resolveHubEntry(hubRoot) {
224
+ // Prefer the bundled binary (matches the production-install path); fall back
225
+ // to source for fresh dev trees where bun build hasn't been run yet.
226
+ const bundle = resolve(hubRoot, 'dist', 'hub.bundle.mjs');
227
+ if (existsSync(bundle)) return bundle;
228
+ return resolve(hubRoot, 'src', 'server.mjs');
229
+ }
230
+
231
+ function resolveHubModule(hubRoot, relPath) {
232
+ // Dynamic import expects a URL or absolute path. Bundle does not include
233
+ // standalone module exports (tokens.mjs lives in src/), so we always load
234
+ // from src/ for CLI helpers — independent of whether the bundle exists.
235
+ return `file://${resolve(hubRoot, relPath)}`;
236
+ }
237
+
238
+ function formatDuration(seconds) {
239
+ if (seconds < 60) return `${seconds}s`;
240
+ const m = Math.floor(seconds / 60);
241
+ const s = seconds % 60;
242
+ if (m < 60) return `${m}m${s.toString().padStart(2, '0')}s`;
243
+ const h = Math.floor(m / 60);
244
+ return `${h}h${(m % 60).toString().padStart(2, '0')}m${s.toString().padStart(2, '0')}s`;
245
+ }
@@ -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
+ });