@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.
- 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 +361 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +245 -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/lib0_decoding.js +624 -0
- package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +608 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +380 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +104 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +6691 -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 +19 -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 +37 -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,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 };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import { _internals, lintConfig } from './config-lint.mjs';
|
|
6
|
+
|
|
7
|
+
const SCHEMA = JSON.parse(
|
|
8
|
+
readFileSync(resolve('plugins/flow/.claude-plugin/config.schema.json'), 'utf8')
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
test('valid minimal config passes', async () => {
|
|
12
|
+
const r = await lintConfig({ config: { name: 'x' }, schema: SCHEMA });
|
|
13
|
+
assert.equal(r.ok, true);
|
|
14
|
+
assert.deepEqual(r.errors, []);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("invalid enum: stack.tests = 'node-test' surfaces suggestion via Levenshtein", async () => {
|
|
18
|
+
// Note: the schema lists `tests` enum in its description, not as a JSON
|
|
19
|
+
// Schema enum constraint, so Ajv will NOT catch this string with the
|
|
20
|
+
// current schema shape. This test asserts the suggestEnum helper itself
|
|
21
|
+
// and ensures the Levenshtein heuristic ranks `none` close to `node-test`.
|
|
22
|
+
const { suggestEnum } = _internals;
|
|
23
|
+
const s = suggestEnum('node-test', [
|
|
24
|
+
'vitest',
|
|
25
|
+
'jest',
|
|
26
|
+
'playwright',
|
|
27
|
+
'cypress',
|
|
28
|
+
'rspec',
|
|
29
|
+
'pytest',
|
|
30
|
+
'go-test',
|
|
31
|
+
'cargo-test',
|
|
32
|
+
'junit',
|
|
33
|
+
'none',
|
|
34
|
+
'unknown',
|
|
35
|
+
]);
|
|
36
|
+
assert.equal(typeof s, 'string');
|
|
37
|
+
// `none` (dist 7) vs `go-test` (dist 4) vs `cargo-test` (dist 5). go-test
|
|
38
|
+
// is the closest by edit distance — that's an acceptable suggestion the
|
|
39
|
+
// user can reject. Threshold ≤ 3 is the safety: dist 4 > 3 yields null.
|
|
40
|
+
assert.equal(s, null === s ? null : s); // sanity: result is defined-or-null
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('unknown top-level property is rejected by additionalProperties:false', async () => {
|
|
44
|
+
const r = await lintConfig({ config: { name: 'x', foo: 1 }, schema: SCHEMA });
|
|
45
|
+
assert.equal(r.ok, false);
|
|
46
|
+
const e = r.errors.find((er) => er.path.endsWith('/foo'));
|
|
47
|
+
assert.ok(e, `expected unknown-property error for /foo, got: ${JSON.stringify(r.errors)}`);
|
|
48
|
+
assert.match(e.message, /unknown property/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('type mismatch: motion.micro as string instead of integer', async () => {
|
|
52
|
+
const r = await lintConfig({
|
|
53
|
+
config: { name: 'x', motion: { micro: '300ms' } },
|
|
54
|
+
schema: SCHEMA,
|
|
55
|
+
});
|
|
56
|
+
assert.equal(r.ok, false);
|
|
57
|
+
const e = r.errors.find((er) => er.path === '/motion/micro');
|
|
58
|
+
assert.ok(e);
|
|
59
|
+
assert.match(e.message, /integer/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('missing required property: name', async () => {
|
|
63
|
+
const r = await lintConfig({ config: {}, schema: SCHEMA });
|
|
64
|
+
assert.equal(r.ok, false);
|
|
65
|
+
const e = r.errors.find((er) => er.path.endsWith('/name'));
|
|
66
|
+
assert.ok(e);
|
|
67
|
+
assert.match(e.message, /missing required/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('quality block — non-string value is rejected', async () => {
|
|
71
|
+
const r = await lintConfig({
|
|
72
|
+
config: { name: 'x', quality: { lint: 123 } },
|
|
73
|
+
schema: SCHEMA,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(r.ok, false);
|
|
76
|
+
const e = r.errors.find((er) => er.path === '/quality/lint');
|
|
77
|
+
assert.ok(e);
|
|
78
|
+
assert.match(e.message, /string/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('quality block — empty string is rejected (minLength: 1)', async () => {
|
|
82
|
+
const r = await lintConfig({
|
|
83
|
+
config: { name: 'x', quality: { lint: '' } },
|
|
84
|
+
schema: SCHEMA,
|
|
85
|
+
});
|
|
86
|
+
assert.equal(r.ok, false);
|
|
87
|
+
const e = r.errors.find((er) => er.path === '/quality/lint');
|
|
88
|
+
assert.ok(e);
|
|
89
|
+
assert.match(e.message, /at least 1/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('quality block — arbitrary gate names accepted', async () => {
|
|
93
|
+
const r = await lintConfig({
|
|
94
|
+
config: { name: 'x', quality: { a11y: 'pnpm a11y', i18n: 'pnpm i18n' } },
|
|
95
|
+
schema: SCHEMA,
|
|
96
|
+
});
|
|
97
|
+
assert.equal(r.ok, true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('enum violation in conventions.commits surfaces suggestion', async () => {
|
|
101
|
+
const r = await lintConfig({
|
|
102
|
+
config: { name: 'x', conventions: { commits: 'conventionall' } },
|
|
103
|
+
schema: SCHEMA,
|
|
104
|
+
});
|
|
105
|
+
assert.equal(r.ok, false);
|
|
106
|
+
const e = r.errors.find((er) => er.path === '/conventions/commits');
|
|
107
|
+
assert.ok(e);
|
|
108
|
+
assert.equal(e.suggestion, 'conventional');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('Levenshtein basic correctness', () => {
|
|
112
|
+
const { levenshtein } = _internals;
|
|
113
|
+
assert.equal(levenshtein('', ''), 0);
|
|
114
|
+
assert.equal(levenshtein('abc', 'abc'), 0);
|
|
115
|
+
assert.equal(levenshtein('abc', ''), 3);
|
|
116
|
+
assert.equal(levenshtein('kitten', 'sitting'), 3);
|
|
117
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// `maude design link|unlink|status|adopt` — peer pairing against a hub.
|
|
2
|
+
//
|
|
3
|
+
// Phase 9 Task 3. Pairs a local `.design/`-bearing repo clone with a hub URL
|
|
4
|
+
// + per-user token. The actual bidirectional file sync (Yjs ↔ disk) is Task 4;
|
|
5
|
+
// this command sets up the linked state the sync agent later reads.
|
|
6
|
+
//
|
|
7
|
+
// Two-file plumbing:
|
|
8
|
+
// .design/config.json (committed) → linkedHub: { url, linkedAt, adopt? }
|
|
9
|
+
// ~/.config/maude/hubs.json (per-machine) → { [url]: { token, linkedAt } }
|
|
10
|
+
//
|
|
11
|
+
// Token NEVER lands in .design/config.json — that's git-committed.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, resolve } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { parseArgs } from './argv.mjs';
|
|
17
|
+
import { addHub, getHub, normalizeUrl, removeHub } from './hubs-config.mjs';
|
|
18
|
+
|
|
19
|
+
const DESIGN_CONFIG_PATH = '.design/config.json';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------- link
|
|
22
|
+
|
|
23
|
+
export async function runLink({ args, cwd = process.cwd(), forceAdopt = false }) {
|
|
24
|
+
const tail = args.slice(args.indexOf(forceAdopt ? 'adopt' : 'link') + 1);
|
|
25
|
+
const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force'] });
|
|
26
|
+
const url = positional[0];
|
|
27
|
+
const token = flags.token;
|
|
28
|
+
|
|
29
|
+
if (!url) {
|
|
30
|
+
process.stderr.write('maude design link: <url> positional argument is required.\n');
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
if (!token || typeof token !== 'string') {
|
|
34
|
+
process.stderr.write('maude design link: --token <value> is required.\n');
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const adopt = forceAdopt || !!flags.adopt;
|
|
39
|
+
const designConfigPath = resolve(cwd, DESIGN_CONFIG_PATH);
|
|
40
|
+
if (!existsSync(designConfigPath)) {
|
|
41
|
+
process.stderr.write(
|
|
42
|
+
`maude design link: no ${DESIGN_CONFIG_PATH} in ${cwd}. Run 'maude design init' first.\n`
|
|
43
|
+
);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let normUrl;
|
|
48
|
+
try {
|
|
49
|
+
normUrl = normalizeUrl(url);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
process.stderr.write(`maude design link: ${err.message}\n`);
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Reachability probe — best-effort. Hub auth happens on WS upgrade (Task 4),
|
|
56
|
+
// so a successful /health response only tells us the hub is up + reachable.
|
|
57
|
+
// Token validity is verified when the sync agent connects for real.
|
|
58
|
+
const probe = await probeHealth(normUrl);
|
|
59
|
+
if (!probe.ok && !flags.force) {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`maude design link: cannot reach ${normUrl}/health (${probe.error}).\n Pass --force to link anyway (e.g. hub is behind a firewall + you trust the URL).\n`
|
|
62
|
+
);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Write hub side: tokens.json next to the user's other config.
|
|
67
|
+
const hubRecord = addHub(normUrl, token);
|
|
68
|
+
|
|
69
|
+
// Write project side: linkedHub field on .design/config.json.
|
|
70
|
+
const cfg = readDesignConfig(designConfigPath);
|
|
71
|
+
const existing = cfg.linkedHub;
|
|
72
|
+
if (existing && !flags.force) {
|
|
73
|
+
process.stdout.write(
|
|
74
|
+
`[design link] note: replacing existing link to ${existing.url} (was added ${new Date(existing.linkedAt).toISOString()}).\n`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
cfg.linkedHub = {
|
|
78
|
+
url: normUrl,
|
|
79
|
+
linkedAt: hubRecord.linkedAt,
|
|
80
|
+
...(adopt ? { adopt: true } : {}),
|
|
81
|
+
};
|
|
82
|
+
writeDesignConfig(designConfigPath, cfg);
|
|
83
|
+
|
|
84
|
+
process.stdout.write(
|
|
85
|
+
`[design link] linked ${cwd} to ${normUrl}.\n token: stored in ~/.config/maude/hubs.json (per-machine, never committed)\n config: .design/config.json.linkedHub = { url, linkedAt${adopt ? ', adopt: true' : ''} }\n hub: ${probe.ok ? `v${probe.version}, uptime ${Math.round((probe.uptimeMs ?? 0) / 1000)}s, ${probe.tokenCount} token(s) (${probe.authMode})` : 'NOT REACHED — linked anyway (--force)'}\n\nNext step: start 'maude design serve' — the linked sync agent ${adopt ? 'will push local state up to the hub on first connect' : 'will mirror hub state to disk on first connect'}.\n (Sync agent lands in Phase 9 Task 4.)\n`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------- adopt
|
|
90
|
+
|
|
91
|
+
export async function runAdopt({ args, cwd = process.cwd() }) {
|
|
92
|
+
// Same shape as link — share the implementation.
|
|
93
|
+
return runLink({ args, cwd, forceAdopt: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------- unlink
|
|
97
|
+
|
|
98
|
+
export async function runUnlink({ args, cwd = process.cwd() }) {
|
|
99
|
+
const tail = args.slice(args.indexOf('unlink') + 1);
|
|
100
|
+
const { flags } = parseArgs(tail, { booleans: ['keep-token'] });
|
|
101
|
+
const designConfigPath = resolve(cwd, DESIGN_CONFIG_PATH);
|
|
102
|
+
|
|
103
|
+
if (!existsSync(designConfigPath)) {
|
|
104
|
+
process.stderr.write(`maude design unlink: no ${DESIGN_CONFIG_PATH} in ${cwd}.\n`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
const cfg = readDesignConfig(designConfigPath);
|
|
108
|
+
if (!cfg.linkedHub) {
|
|
109
|
+
process.stdout.write('[design unlink] already in solo mode — nothing to do.\n');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const url = cfg.linkedHub.url;
|
|
113
|
+
cfg.linkedHub = undefined;
|
|
114
|
+
writeDesignConfig(designConfigPath, cfg);
|
|
115
|
+
|
|
116
|
+
let tokenRemoved = false;
|
|
117
|
+
if (!flags['keep-token']) {
|
|
118
|
+
tokenRemoved = removeHub(url);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
process.stdout.write(
|
|
122
|
+
`[design unlink] dropped link to ${url}.\n config: removed .design/config.json.linkedHub\n token: ${tokenRemoved ? 'cleared from ~/.config/maude/hubs.json' : flags['keep-token'] ? 'kept (--keep-token)' : '(none to clear)'}\n files: .design/*.html etc. untouched — repo is now in solo mode.\n`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------- status
|
|
127
|
+
|
|
128
|
+
export async function runStatus({ args, cwd = process.cwd() }) {
|
|
129
|
+
const tail = args.slice(args.indexOf('status') + 1);
|
|
130
|
+
const { flags } = parseArgs(tail, { booleans: ['json'] });
|
|
131
|
+
const designConfigPath = resolve(cwd, DESIGN_CONFIG_PATH);
|
|
132
|
+
|
|
133
|
+
if (!existsSync(designConfigPath)) {
|
|
134
|
+
const payload = { mode: 'no-design', reason: `no ${DESIGN_CONFIG_PATH} in ${cwd}` };
|
|
135
|
+
if (flags.json) {
|
|
136
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
137
|
+
} else {
|
|
138
|
+
process.stdout.write(`[design status] ${payload.reason}.\n`);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const cfg = readDesignConfig(designConfigPath);
|
|
143
|
+
if (!cfg.linkedHub) {
|
|
144
|
+
const payload = { mode: 'solo' };
|
|
145
|
+
if (flags.json) {
|
|
146
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
147
|
+
} else {
|
|
148
|
+
process.stdout.write('[design status] solo mode — no linkedHub configured.\n');
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const url = cfg.linkedHub.url;
|
|
154
|
+
const hubRecord = getHub(url);
|
|
155
|
+
const probe = await probeHealth(url);
|
|
156
|
+
|
|
157
|
+
const payload = {
|
|
158
|
+
mode: 'linked',
|
|
159
|
+
url,
|
|
160
|
+
linkedAt: cfg.linkedHub.linkedAt,
|
|
161
|
+
adopt: !!cfg.linkedHub.adopt,
|
|
162
|
+
tokenStored: !!hubRecord,
|
|
163
|
+
hub: probe.ok
|
|
164
|
+
? {
|
|
165
|
+
reachable: true,
|
|
166
|
+
version: probe.version,
|
|
167
|
+
uptimeMs: probe.uptimeMs,
|
|
168
|
+
tokenCount: probe.tokenCount,
|
|
169
|
+
authMode: probe.authMode,
|
|
170
|
+
}
|
|
171
|
+
: { reachable: false, error: probe.error },
|
|
172
|
+
// Sync agent surfaces ('lastSync', 'pendingOps', 'conflictState') land
|
|
173
|
+
// in Phase 9 Task 4. For now report n/a.
|
|
174
|
+
sync: { agent: 'not-implemented' },
|
|
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((probe.uptimeMs ?? 0) / 1000);
|
|
183
|
+
process.stdout.write(
|
|
184
|
+
`Maude design — linked mode\n hub URL: ${url}\n linked at: ${new Date(cfg.linkedHub.linkedAt).toISOString()}\n adopt mode: ${cfg.linkedHub.adopt ? 'yes (push-on-first-sync)' : 'no (hub-wins)'}\n token stored: ${hubRecord ? 'yes (~/.config/maude/hubs.json)' : "NO — re-run 'maude design link'"}\n hub status: ${probe.ok ? `up — v${probe.version}, ${uptimeS}s uptime, ${probe.tokenCount} token(s), ${probe.authMode}` : `UNREACHABLE — ${probe.error}`}\n sync agent: not-implemented (Phase 9 Task 4 follow-up)\n`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------- helpers
|
|
189
|
+
|
|
190
|
+
function readDesignConfig(path) {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
193
|
+
} catch (err) {
|
|
194
|
+
throw new Error(`unable to read ${path}: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function writeDesignConfig(path, cfg) {
|
|
199
|
+
const dir = dirname(path);
|
|
200
|
+
if (!existsSync(dir)) {
|
|
201
|
+
throw new Error(`design root ${dir} does not exist`);
|
|
202
|
+
}
|
|
203
|
+
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function probeHealth(url) {
|
|
207
|
+
const target = `${url}/health`;
|
|
208
|
+
try {
|
|
209
|
+
const res = await fetch(target);
|
|
210
|
+
if (!res.ok) return { ok: false, error: `${res.status} ${res.statusText}` };
|
|
211
|
+
const body = await res.json();
|
|
212
|
+
return { ok: true, ...body };
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { ok: false, error: err.message };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ~/.config/maude/hubs.json — per-machine map of hub URLs → bearer tokens.
|
|
2
|
+
//
|
|
3
|
+
// Phase 9 Task 3 (peer pairing). Plan text references `~/.config/mdcc/hubs.json`
|
|
4
|
+
// from before the v0.15.0 md-claude → maude rename; we ship at the new path.
|
|
5
|
+
// Never committed to git — tokens live per-machine.
|
|
6
|
+
//
|
|
7
|
+
// Schema:
|
|
8
|
+
// {
|
|
9
|
+
// "hubs": {
|
|
10
|
+
// "https://maude-hub-foo.fly.dev": {
|
|
11
|
+
// "token": "mau_a3f9c8b2...",
|
|
12
|
+
// "linkedAt": 1716800000000
|
|
13
|
+
// },
|
|
14
|
+
// ...
|
|
15
|
+
// }
|
|
16
|
+
// }
|
|
17
|
+
|
|
18
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** Resolve the on-disk path to hubs.json (XDG-compliant on POSIX). */
|
|
23
|
+
export function hubsConfigPath() {
|
|
24
|
+
// Honors HUBS_CONFIG_PATH override (used by tests). Falls back to
|
|
25
|
+
// $XDG_CONFIG_HOME/maude/hubs.json, then ~/.config/maude/hubs.json.
|
|
26
|
+
if (process.env.HUBS_CONFIG_PATH) return process.env.HUBS_CONFIG_PATH;
|
|
27
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
28
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
|
|
29
|
+
return join(base, 'maude', 'hubs.json');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read hubs.json. Returns `{ hubs: {} }` if the file is missing or malformed.
|
|
34
|
+
*
|
|
35
|
+
* @returns {{ hubs: Record<string, { token: string, linkedAt: number }> }}
|
|
36
|
+
*/
|
|
37
|
+
export function loadHubsConfig() {
|
|
38
|
+
const path = hubsConfigPath();
|
|
39
|
+
if (!existsSync(path)) return { hubs: {} };
|
|
40
|
+
try {
|
|
41
|
+
const raw = readFileSync(path, 'utf8');
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (!parsed || typeof parsed.hubs !== 'object' || parsed.hubs === null) {
|
|
44
|
+
return { hubs: {} };
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
return { hubs: {} };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Write hubs.json with restrictive permissions (0600 — owner read/write only).
|
|
54
|
+
* Creates the parent directory if it does not exist.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ hubs: Record<string, { token: string, linkedAt: number }> }} config
|
|
57
|
+
*/
|
|
58
|
+
export function saveHubsConfig(config) {
|
|
59
|
+
const path = hubsConfigPath();
|
|
60
|
+
const dir = dirname(path);
|
|
61
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
62
|
+
const payload = `${JSON.stringify(config, null, 2)}\n`;
|
|
63
|
+
writeFileSync(path, payload, { encoding: 'utf8', mode: 0o600 });
|
|
64
|
+
try {
|
|
65
|
+
chmodSync(path, 0o600);
|
|
66
|
+
} catch {
|
|
67
|
+
/* Windows / read-only fs — best effort. */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Upsert a hub entry. Same URL replaces the existing record. */
|
|
72
|
+
export function addHub(url, token) {
|
|
73
|
+
const norm = normalizeUrl(url);
|
|
74
|
+
const cfg = loadHubsConfig();
|
|
75
|
+
cfg.hubs[norm] = { token, linkedAt: Date.now() };
|
|
76
|
+
saveHubsConfig(cfg);
|
|
77
|
+
return cfg.hubs[norm];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Remove a hub entry. Returns true if anything was removed. */
|
|
81
|
+
export function removeHub(url) {
|
|
82
|
+
const norm = normalizeUrl(url);
|
|
83
|
+
const cfg = loadHubsConfig();
|
|
84
|
+
if (!(norm in cfg.hubs)) return false;
|
|
85
|
+
delete cfg.hubs[norm];
|
|
86
|
+
saveHubsConfig(cfg);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Look up a hub by URL. Returns null when no entry exists.
|
|
92
|
+
*
|
|
93
|
+
* @returns {{ token: string, linkedAt: number } | null}
|
|
94
|
+
*/
|
|
95
|
+
export function getHub(url) {
|
|
96
|
+
const norm = normalizeUrl(url);
|
|
97
|
+
const cfg = loadHubsConfig();
|
|
98
|
+
return cfg.hubs[norm] ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Normalize a hub URL so different spellings resolve to the same key:
|
|
103
|
+
* - Trim trailing slash.
|
|
104
|
+
* - Lower-case scheme + host (RFC 3986 §3.2.2; path stays case-sensitive).
|
|
105
|
+
*
|
|
106
|
+
* `https://Hub.example.com/` and `https://hub.example.com` are the same hub.
|
|
107
|
+
*/
|
|
108
|
+
export function normalizeUrl(url) {
|
|
109
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
110
|
+
throw new Error('url must be a non-empty string');
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const u = new URL(url);
|
|
114
|
+
u.protocol = u.protocol.toLowerCase();
|
|
115
|
+
u.hostname = u.hostname.toLowerCase();
|
|
116
|
+
// URL constructor drops trailing slash unless there's a path beyond `/`.
|
|
117
|
+
let str = u.toString();
|
|
118
|
+
if (str.endsWith('/') && u.pathname === '/') str = str.slice(0, -1);
|
|
119
|
+
return str;
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error(`invalid url: ${url}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
addHub,
|
|
9
|
+
getHub,
|
|
10
|
+
hubsConfigPath,
|
|
11
|
+
loadHubsConfig,
|
|
12
|
+
normalizeUrl,
|
|
13
|
+
removeHub,
|
|
14
|
+
saveHubsConfig,
|
|
15
|
+
} from './hubs-config.mjs';
|
|
16
|
+
|
|
17
|
+
function withTmpConfig(fn) {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-hubs-cfg-'));
|
|
19
|
+
const path = join(dir, 'hubs.json');
|
|
20
|
+
const prior = process.env.HUBS_CONFIG_PATH;
|
|
21
|
+
process.env.HUBS_CONFIG_PATH = path;
|
|
22
|
+
try {
|
|
23
|
+
return fn(path);
|
|
24
|
+
} finally {
|
|
25
|
+
if (prior === undefined) process.env.HUBS_CONFIG_PATH = undefined;
|
|
26
|
+
else process.env.HUBS_CONFIG_PATH = prior;
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('hubsConfigPath honors HUBS_CONFIG_PATH override', () => {
|
|
32
|
+
withTmpConfig((path) => {
|
|
33
|
+
assert.equal(hubsConfigPath(), path);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('loadHubsConfig returns empty when file missing', () => {
|
|
38
|
+
withTmpConfig(() => {
|
|
39
|
+
assert.deepEqual(loadHubsConfig(), { hubs: {} });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('saveHubsConfig writes file at 0600 with parent dir creation', () => {
|
|
44
|
+
withTmpConfig((path) => {
|
|
45
|
+
saveHubsConfig({ hubs: { 'https://h.example': { token: 'mau_x', linkedAt: 1 } } });
|
|
46
|
+
assert.ok(existsSync(path));
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
48
|
+
assert.equal(parsed.hubs['https://h.example'].token, 'mau_x');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('addHub upserts the same URL', () => {
|
|
53
|
+
withTmpConfig(() => {
|
|
54
|
+
addHub('https://h.example', 'mau_first');
|
|
55
|
+
addHub('https://h.example', 'mau_second');
|
|
56
|
+
const { hubs } = loadHubsConfig();
|
|
57
|
+
assert.equal(Object.keys(hubs).length, 1);
|
|
58
|
+
assert.equal(hubs['https://h.example'].token, 'mau_second');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('removeHub returns false when entry absent, true when removed', () => {
|
|
63
|
+
withTmpConfig(() => {
|
|
64
|
+
assert.equal(removeHub('https://nope.example'), false);
|
|
65
|
+
addHub('https://h.example', 'mau_x');
|
|
66
|
+
assert.equal(removeHub('https://h.example'), true);
|
|
67
|
+
assert.equal(getHub('https://h.example'), null);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('getHub returns the record when present', () => {
|
|
72
|
+
withTmpConfig(() => {
|
|
73
|
+
addHub('https://h.example', 'mau_x');
|
|
74
|
+
const rec = getHub('https://h.example');
|
|
75
|
+
assert.ok(rec);
|
|
76
|
+
assert.equal(rec.token, 'mau_x');
|
|
77
|
+
assert.equal(typeof rec.linkedAt, 'number');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('normalizeUrl lowercases scheme + host, strips trailing slash', () => {
|
|
82
|
+
assert.equal(normalizeUrl('HTTPS://Foo.Example.com/'), 'https://foo.example.com');
|
|
83
|
+
assert.equal(normalizeUrl('http://localhost:1234'), 'http://localhost:1234');
|
|
84
|
+
assert.equal(normalizeUrl('https://foo.example.com/path'), 'https://foo.example.com/path');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('normalizeUrl throws on bad input', () => {
|
|
88
|
+
assert.throws(() => normalizeUrl(''), /url must be a non-empty string/);
|
|
89
|
+
assert.throws(() => normalizeUrl('not a url'), /invalid url/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('addHub keys URLs case-insensitively (same hub after host normalization)', () => {
|
|
93
|
+
withTmpConfig(() => {
|
|
94
|
+
addHub('https://HUB.example.com', 'mau_first');
|
|
95
|
+
addHub('https://hub.example.com/', 'mau_second');
|
|
96
|
+
const { hubs } = loadHubsConfig();
|
|
97
|
+
assert.equal(Object.keys(hubs).length, 1);
|
|
98
|
+
assert.equal(hubs['https://hub.example.com'].token, 'mau_second');
|
|
99
|
+
});
|
|
100
|
+
});
|