@1agh/maude 0.20.0 → 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.
- package/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +16 -8
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
- package/plugins/design/dev-server/annotations-layer.tsx +52 -12
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +33 -7
- package/plugins/design/dev-server/canvas-shell.tsx +127 -9
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -0
- package/plugins/design/dev-server/comments-overlay.tsx +29 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -3
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +64 -1
- package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +8 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
- package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
- package/plugins/design/dev-server/tool-palette.tsx +7 -7
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +35 -1
- package/plugins/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- 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
|
+
});
|
package/cli/commands/help.mjs
CHANGED
|
@@ -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 };
|