@1agh/maude 0.23.0 → 0.24.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 +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +333 -19
- package/plugins/design/dev-server/client/app.jsx +155 -7
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +126 -13
- package/plugins/design/dev-server/dist/comment-mount.js +78 -11
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +26 -3
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +49 -1
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
package/cli/commands/help.mjs
CHANGED
|
@@ -19,6 +19,16 @@ COMMANDS
|
|
|
19
19
|
against the flow plugin's config.schema.json. Values that parse as
|
|
20
20
|
JSON (numbers, booleans, arrays, objects) are stored typed.
|
|
21
21
|
|
|
22
|
+
cache get <layer> <key> [--ttl-ms N]
|
|
23
|
+
cache put <layer> <key> [file] [--meta JSON]
|
|
24
|
+
cache list | stats | inspect <layer> [key] | clear [layer]
|
|
25
|
+
Inspect and manage the sidecar cache (.ai/cache/, Phase C / DDR-061)
|
|
26
|
+
that reuses expensive cross-session work (domain research, codebase
|
|
27
|
+
scans, design-context parsing, security reviews). 'get'/'put' are the
|
|
28
|
+
programmatic surface plugin commands call (maude on PATH is the reachable
|
|
29
|
+
contract — see DDR-061); 'list' shows layers + sizes; 'stats' shows
|
|
30
|
+
hit-rate; 'inspect' pretty-prints entries; 'clear' wipes a layer or all.
|
|
31
|
+
|
|
22
32
|
design serve [--port N] [--root PATH]
|
|
23
33
|
Start the design plugin's dev server in the current repo. Equivalent
|
|
24
34
|
to invoking 'claude-design-server'. Forwards all remaining args.
|
|
@@ -31,6 +41,20 @@ COMMANDS
|
|
|
31
41
|
full discovery. --no-discovery uses Recommended defaults; --discovery-
|
|
32
42
|
payload reads pre-computed answers from JSON.
|
|
33
43
|
|
|
44
|
+
design <screenshot|server-up|prep|slug|bootstrap-check|runtime-health
|
|
45
|
+
|smoke|canvas-edit|handoff|asset-sweep|visual-sanity> [args]
|
|
46
|
+
Dispatch to the dev-server's bundled bash helper of the same name.
|
|
47
|
+
maude resolves it from its own package root and sets CLAUDE_PLUGIN_ROOT
|
|
48
|
+
for the child; stdout/stderr/exit-code pass straight through. This is
|
|
49
|
+
the contract plugin markdown uses instead of a raw bin path (DDR-062).
|
|
50
|
+
See 'maude design help' for usage.
|
|
51
|
+
|
|
52
|
+
scenario-report <run-dir> [--out <path>]
|
|
53
|
+
Deterministically generate the mechanical sections of a cross-platform
|
|
54
|
+
scenario report.md (TL;DR, counter-delta parity, per-step pivot, path
|
|
55
|
+
listing) from a scenario run directory. The LLM authors only the prose
|
|
56
|
+
sections. Used by /flow:scenario + the scenario-runner agent (DDR-061).
|
|
57
|
+
|
|
34
58
|
hub serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
|
|
35
59
|
Start the self-hostable Yjs sync hub (Phase 9). Defaults to port 1234,
|
|
36
60
|
data dir ./data. --dev mints a mau_dev_<hex> token + prints the
|
|
@@ -43,6 +67,11 @@ COMMANDS
|
|
|
43
67
|
hub status [URL] [--json]
|
|
44
68
|
Probe a hub's /health endpoint. URL defaults to http://localhost:1234.
|
|
45
69
|
|
|
70
|
+
preflight --plugin <design|flow> [--json|--shell-export|--warn-only] [--cache PATH]
|
|
71
|
+
Run a plugin's dependency health check (the engine behind 'doctor').
|
|
72
|
+
Plugin bin scripts + hooks call this via PATH instead of a relative
|
|
73
|
+
cli/lib path that the marketplace never copies beside a plugin (DDR-061).
|
|
74
|
+
|
|
46
75
|
doctor [--plugin <name>] [--fix] [--json]
|
|
47
76
|
Unified workspace health check. Reports missing dependencies, config
|
|
48
77
|
schema errors, stack drift, and missing quality-gate declarations in
|
|
@@ -57,8 +86,13 @@ EXAMPLES
|
|
|
57
86
|
maude init --name acme-app
|
|
58
87
|
maude config set platforms '["web-desktop","web-mobile"]'
|
|
59
88
|
maude config get motion.complex
|
|
89
|
+
maude cache list
|
|
90
|
+
maude cache clear research/domain
|
|
60
91
|
maude design serve --port 4399
|
|
61
92
|
maude design init --no-discovery --name acme-app
|
|
93
|
+
maude design slug "Some Canvas Name"
|
|
94
|
+
maude design smoke --changed-only
|
|
95
|
+
maude scenario-report .ai/device/scenario-runs/signup/2026-05-29-1200
|
|
62
96
|
maude hub serve --dev
|
|
63
97
|
maude hub token generate --label alice
|
|
64
98
|
maude hub status http://localhost:1234
|
package/cli/commands/hub.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// maude hub — self-hostable Yjs sync hub control plane.
|
|
2
2
|
//
|
|
3
|
-
// Phase 9 Task 2
|
|
4
|
-
// Token rotate, deploy fly|docker land in subsequent slices.
|
|
3
|
+
// Phase 9 Task 2 + Task 7: serve + token generate|rotate + status + deploy.
|
|
5
4
|
// See .ai/plans/phase-9-self-hosted-hub-file-sync.md.
|
|
6
5
|
|
|
7
6
|
import { spawn } from 'node:child_process';
|
|
8
|
-
import {
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { resolve } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import { parseArgs } from '../lib/argv.mjs';
|
|
12
12
|
|
|
13
|
-
const SUBCOMMANDS = new Set(['serve', 'token', 'status', 'help']);
|
|
13
|
+
const SUBCOMMANDS = new Set(['serve', 'token', 'status', 'deploy', 'help']);
|
|
14
14
|
|
|
15
15
|
export async function run({ args, pkgRoot }) {
|
|
16
16
|
const { positional } = parseArgs(args);
|
|
@@ -28,50 +28,80 @@ export async function run({ args, pkgRoot }) {
|
|
|
28
28
|
if (sub === 'serve') return runServe({ args, pkgRoot });
|
|
29
29
|
if (sub === 'token') return runToken({ args, pkgRoot });
|
|
30
30
|
if (sub === 'status') return runStatus({ args });
|
|
31
|
+
if (sub === 'deploy') return runDeploy({ args, pkgRoot });
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
function usage() {
|
|
34
|
-
return `maude hub <serve|token|status> [options]
|
|
35
|
+
return `maude hub <serve|token|status|deploy> [options]
|
|
35
36
|
|
|
36
37
|
serve [--port N] [--data PATH] [--secret HEX] [--insecure-http] [--dev]
|
|
37
38
|
Start the self-hostable Yjs sync hub in the current process tree.
|
|
38
39
|
--port listen port (default 1234, env PORT)
|
|
39
|
-
--data hub.db + tokens.
|
|
40
|
+
--data hub.db + tokens.db dir (default ./data, env DATA_DIR)
|
|
40
41
|
--secret HUB_SECRET escape-hatch token (env HUB_SECRET).
|
|
41
42
|
If unset on an empty hub, a one-time bootstrap link is
|
|
42
43
|
printed to logs — open it in a browser to claim admin.
|
|
43
44
|
--insecure-http cosmetic log-only flag for non-TLS dev
|
|
44
45
|
--dev generate a one-shot mau_dev_<hex> token, print the
|
|
45
46
|
connect command, then run the hub. Convenience for
|
|
46
|
-
contributor onboarding —
|
|
47
|
-
|
|
47
|
+
contributor onboarding — token persists in tokens.db
|
|
48
|
+
after exit (clean the --data dir by hand to reset).
|
|
48
49
|
|
|
49
50
|
On boot the hub prints its /admin URL. The admin UI generates invite
|
|
50
51
|
tokens, lists peers, and rotates tokens without shelling into the host.
|
|
51
52
|
|
|
52
|
-
token generate --label NAME [--data PATH] [--dev]
|
|
53
|
-
Generate a new mau_<32hex> token and
|
|
54
|
-
<data>/tokens.
|
|
53
|
+
token generate --label NAME [--data PATH] [--dev] [--scope SCOPE]
|
|
54
|
+
Generate a new mau_<32hex> token and store it (HMAC-hashed) in
|
|
55
|
+
<data>/tokens.db with the given label. Prints the raw token ONCE,
|
|
55
56
|
plus the ready-to-paste 'maude design link' connect command.
|
|
56
57
|
|
|
57
58
|
--dev produces a mau_dev_<hex> token (convention only — same auth).
|
|
59
|
+
--scope '*' mints a hub-wide token (authorizes any canvas). Omitted →
|
|
60
|
+
the scope defaults to the label (DDR-053). Canvas sync uses a
|
|
61
|
+
flat per-canvas slug as the documentName, so a label-scoped
|
|
62
|
+
token will NOT authorize a real canvas — pass --scope '*' for
|
|
63
|
+
a peer that syncs canvases (this is what /admin invites do).
|
|
58
64
|
|
|
59
65
|
Equivalent to the "Generate invite" button in the /admin UI — use
|
|
60
66
|
whichever is more convenient for the deploy.
|
|
61
67
|
|
|
68
|
+
token rotate --label NAME [--data PATH]
|
|
69
|
+
Invalidate the named token and mint a fresh value with the same label
|
|
70
|
+
+ scope. Prints the new raw token ONCE. New connections with the OLD
|
|
71
|
+
token are rejected immediately; ALREADY-CONNECTED peers stay until they
|
|
72
|
+
reconnect — use the /admin UI "Rotate" button (kicks live sessions) or
|
|
73
|
+
restart the hub to force-disconnect them now.
|
|
74
|
+
|
|
62
75
|
status [URL] [--json]
|
|
63
76
|
HTTP GET <url>/health, print uptime/version/token-count/peers. URL
|
|
64
77
|
defaults to http://localhost:1234. --json emits the raw response.
|
|
65
78
|
|
|
79
|
+
deploy <fly|docker> [--name NAME] [--region CODE] [--tag TAG] [--out DIR] [--force]
|
|
80
|
+
Emit the deploy templates for the chosen target into the current
|
|
81
|
+
directory (or --out DIR) with placeholders substituted, then print the
|
|
82
|
+
exact commands to run next. Does NOT execute fly/docker for you —
|
|
83
|
+
review the emitted files first, then run the printed command.
|
|
84
|
+
|
|
85
|
+
fly → fly.toml (+ reuses plugins/design/hub/Dockerfile).
|
|
86
|
+
--name app name (default maude-hub-<rand>)
|
|
87
|
+
--region Fly region code (default iad)
|
|
88
|
+
docker → docker-compose.yml + Caddyfile (universal — Lightsail, EC2,
|
|
89
|
+
Hetzner, DigitalOcean, Coolify, home server).
|
|
90
|
+
--tag ghcr.io/1agh/maude-hub image tag (default latest)
|
|
91
|
+
|
|
66
92
|
NOTES
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
93
|
+
serve / token / deploy run inside a 'maude' source checkout (they read the
|
|
94
|
+
bundled hub workspace at plugins/design/hub). Production hubs run the
|
|
95
|
+
published Docker image (ghcr.io/1agh/maude-hub) — see 'maude hub deploy'.
|
|
96
|
+
status works from anywhere (plain HTTP GET against /health).
|
|
70
97
|
|
|
71
98
|
EXAMPLES
|
|
72
99
|
maude hub serve --port 4400
|
|
73
100
|
maude hub token generate --label alice
|
|
101
|
+
maude hub token rotate --label alice
|
|
74
102
|
maude hub status http://localhost:4400
|
|
103
|
+
maude hub deploy fly --name maude-hub-acme --region fra
|
|
104
|
+
maude hub deploy docker --tag latest --out ./deploy
|
|
75
105
|
|
|
76
106
|
# Recommended flow on a fresh deploy:
|
|
77
107
|
# 1. maude hub serve → copy bootstrap link from logs
|
|
@@ -106,11 +136,12 @@ async function runServe({ args, pkgRoot }) {
|
|
|
106
136
|
|
|
107
137
|
if (flags.dev) {
|
|
108
138
|
const dataDir = env.DATA_DIR ?? resolve(process.cwd(), 'data');
|
|
139
|
+
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
|
|
109
140
|
const { addToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
|
|
110
141
|
const port = env.PORT ?? '1234';
|
|
111
142
|
const record = addToken(dataDir, { label: 'dev', dev: true });
|
|
112
143
|
process.stdout.write(
|
|
113
|
-
`[hub] --dev token written to ${dataDir}/tokens.
|
|
144
|
+
`[hub] --dev token written to ${dataDir}/tokens.db:\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
145
|
);
|
|
115
146
|
}
|
|
116
147
|
|
|
@@ -132,10 +163,9 @@ async function runToken({ args, pkgRoot }) {
|
|
|
132
163
|
const tail = args.slice(args.indexOf('token') + 1);
|
|
133
164
|
const op = tail[0];
|
|
134
165
|
|
|
135
|
-
if (op !== 'generate') {
|
|
166
|
+
if (op !== 'generate' && op !== 'rotate') {
|
|
136
167
|
process.stderr.write(
|
|
137
|
-
`maude hub token:
|
|
138
|
-
`"rotate" lands in a follow-up. Use "generate" with --label to mint a new token.\n`
|
|
168
|
+
`maude hub token: unknown op "${op ?? ''}". Use "generate" or "rotate" with --label.\n`
|
|
139
169
|
);
|
|
140
170
|
process.exit(2);
|
|
141
171
|
}
|
|
@@ -143,21 +173,56 @@ async function runToken({ args, pkgRoot }) {
|
|
|
143
173
|
const { flags } = parseArgs(tail.slice(1), { booleans: ['dev'] });
|
|
144
174
|
const label = flags.label;
|
|
145
175
|
if (!label || typeof label !== 'string') {
|
|
146
|
-
process.stderr.write(
|
|
176
|
+
process.stderr.write(`maude hub token ${op}: --label <name> is required.\n`);
|
|
147
177
|
process.exit(2);
|
|
148
178
|
}
|
|
149
179
|
const dataDir = flags.data ? resolve(String(flags.data)) : resolve(process.cwd(), 'data');
|
|
150
180
|
|
|
151
181
|
const hubRoot = findHubRoot(pkgRoot);
|
|
152
182
|
if (!hubRoot) {
|
|
153
|
-
process.stderr.write(
|
|
183
|
+
process.stderr.write(`maude hub token ${op}: hub workspace not found.\n`);
|
|
154
184
|
process.exit(1);
|
|
155
185
|
}
|
|
156
|
-
const { addToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
|
|
157
|
-
|
|
186
|
+
const { addToken, rotateToken } = await import(resolveHubModule(hubRoot, 'src/tokens.mjs'));
|
|
187
|
+
|
|
188
|
+
// better-sqlite3 won't create the parent dir; the running hub does this in
|
|
189
|
+
// createHub, but a CLI generate against a fresh --data path must too.
|
|
190
|
+
if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
|
|
191
|
+
|
|
192
|
+
let record;
|
|
193
|
+
if (op === 'rotate') {
|
|
194
|
+
try {
|
|
195
|
+
record = rotateToken(dataDir, label);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
process.stderr.write(`maude hub token rotate: ${err.message}\n`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
// --scope '*' mints a hub-wide token (authorizes any documentName); an
|
|
202
|
+
// explicit value scopes it to that prefix. Omitted → addToken defaults the
|
|
203
|
+
// scope to the label (DDR-053 §3). NOTE: canvas sync uses flat per-canvas
|
|
204
|
+
// slugs as the documentName, so a label-scoped token does NOT authorize a
|
|
205
|
+
// canvas unless its label equals the slug — pass --scope '*' for sync use.
|
|
206
|
+
const scope = typeof flags.scope === 'string' ? flags.scope : undefined;
|
|
207
|
+
record = addToken(dataDir, {
|
|
208
|
+
label,
|
|
209
|
+
dev: !!flags.dev,
|
|
210
|
+
...(scope !== undefined ? { scope } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
158
213
|
|
|
214
|
+
const verb = op === 'rotate' ? 'rotated' : 'written';
|
|
215
|
+
const liveNote =
|
|
216
|
+
op === 'rotate'
|
|
217
|
+
? 'Old token is rejected on new connections immediately. Already-connected\npeers persist until they reconnect — use the /admin UI "Rotate" button (kicks\nlive sessions) or restart the hub to force-disconnect them now.\n'
|
|
218
|
+
: 'Restart the hub if it is already running for the new token to take effect.\n';
|
|
219
|
+
const shownScope = record.scope ?? '*';
|
|
220
|
+
const scopeNote =
|
|
221
|
+
shownScope === '*'
|
|
222
|
+
? 'hub-wide — authorizes any canvas'
|
|
223
|
+
: `only authorizes documentName "${shownScope}" or "${shownScope}/…" — canvas sync uses flat slugs, so pass --scope '*' for whole-hub sync`;
|
|
159
224
|
process.stdout.write(
|
|
160
|
-
`[hub] token
|
|
225
|
+
`[hub] token ${verb} in ${dataDir}/tokens.db:\n label: ${record.label}\n value: ${record.value}\n scope: ${shownScope} (${scopeNote})\n created: ${new Date(record.createdAt).toISOString()}\n\nConnect from a peer (replace HOST):\n maude design link https://HOST --token=${record.value}\n\n${liveNote}`
|
|
161
226
|
);
|
|
162
227
|
}
|
|
163
228
|
|
|
@@ -194,17 +259,177 @@ async function runStatus({ args }) {
|
|
|
194
259
|
}
|
|
195
260
|
|
|
196
261
|
const uptimeS = Math.round((payload.uptimeMs ?? 0) / 1000);
|
|
262
|
+
// `dataDir` is omitted from the unauthenticated /health payload (it's a
|
|
263
|
+
// server filesystem path — recon over-share). Only print it if present.
|
|
264
|
+
const dataDirLine = payload.dataDir ? ` dataDir: ${payload.dataDir}\n` : '';
|
|
197
265
|
process.stdout.write(
|
|
198
|
-
`Maude Hub @ ${url}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
266
|
+
`Maude Hub @ ${url}
|
|
267
|
+
ok: ${payload.ok}
|
|
268
|
+
version: ${payload.version}
|
|
269
|
+
uptime: ${formatDuration(uptimeS)}
|
|
270
|
+
port: ${payload.port}
|
|
271
|
+
${dataDirLine} tokens: ${payload.tokenCount} (mode: ${payload.authMode})
|
|
272
|
+
`
|
|
205
273
|
);
|
|
206
274
|
}
|
|
207
275
|
|
|
276
|
+
// ---------------------------------------------------------------- deploy
|
|
277
|
+
|
|
278
|
+
const VALID_TARGETS = new Set(['fly', 'docker']);
|
|
279
|
+
// Fly app names: lowercase alnum + hyphen, must start/end alnum.
|
|
280
|
+
const FLY_NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/;
|
|
281
|
+
const REGION_REGEX = /^[a-z]{3}$/;
|
|
282
|
+
const IMAGE_TAG_REGEX = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
|
|
283
|
+
|
|
284
|
+
function runDeploy({ args, pkgRoot }) {
|
|
285
|
+
const tail = args.slice(args.indexOf('deploy') + 1);
|
|
286
|
+
const { flags, positional } = parseArgs(tail, { booleans: ['force'] });
|
|
287
|
+
const target = positional[0];
|
|
288
|
+
|
|
289
|
+
if (!target || !VALID_TARGETS.has(target)) {
|
|
290
|
+
process.stderr.write(
|
|
291
|
+
`maude hub deploy: target must be one of ${[...VALID_TARGETS].join(' | ')} (got "${target ?? ''}").\n`
|
|
292
|
+
);
|
|
293
|
+
process.exit(2);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const hubRoot = findHubRoot(pkgRoot);
|
|
297
|
+
if (!hubRoot) {
|
|
298
|
+
process.stderr.write(
|
|
299
|
+
'maude hub deploy: hub workspace not found.\n\n' +
|
|
300
|
+
'Deploy templates ship in the maude source tree. Run inside a checkout,\n' +
|
|
301
|
+
'or clone https://github.com/1aGh/maude and run from there.\n'
|
|
302
|
+
);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const outDir = flags.out ? resolve(String(flags.out)) : process.cwd();
|
|
307
|
+
|
|
308
|
+
if (target === 'fly') return deployFly({ hubRoot, outDir, flags });
|
|
309
|
+
return deployDocker({ hubRoot, outDir, flags });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function deployFly({ hubRoot, outDir, flags }) {
|
|
313
|
+
const appName = flags.name ? String(flags.name) : `maude-hub-${randHex(4)}`;
|
|
314
|
+
if (!FLY_NAME_REGEX.test(appName)) {
|
|
315
|
+
process.stderr.write(
|
|
316
|
+
`maude hub deploy fly: invalid --name "${appName}" (lowercase alphanumeric + hyphen, 2-63 chars).\n`
|
|
317
|
+
);
|
|
318
|
+
process.exit(2);
|
|
319
|
+
}
|
|
320
|
+
const region = flags.region ? String(flags.region) : 'iad';
|
|
321
|
+
if (!REGION_REGEX.test(region)) {
|
|
322
|
+
process.stderr.write(
|
|
323
|
+
`maude hub deploy fly: invalid --region "${region}" (3-letter Fly code, e.g. iad, fra, nrt).\n`
|
|
324
|
+
);
|
|
325
|
+
process.exit(2);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const flyToml = renderTemplate(hubRoot, 'fly.toml.template', {
|
|
329
|
+
APP_NAME: appName,
|
|
330
|
+
REGION: region,
|
|
331
|
+
});
|
|
332
|
+
const flyOut = resolve(outDir, 'fly.toml');
|
|
333
|
+
guardOverwrite(flyOut, flags.force, 'fly.toml');
|
|
334
|
+
writeFileSync(flyOut, flyToml, 'utf8');
|
|
335
|
+
|
|
336
|
+
// The Dockerfile is referenced by fly.toml [build]; copy it alongside so a
|
|
337
|
+
// `fly deploy` from outDir finds it (Fly builds remotely from the context).
|
|
338
|
+
const dockerfileSrc = resolve(hubRoot, 'Dockerfile');
|
|
339
|
+
const dockerfileOut = resolve(outDir, 'Dockerfile');
|
|
340
|
+
if (existsSync(dockerfileSrc) && (flags.force || !existsSync(dockerfileOut))) {
|
|
341
|
+
copyFileSync(dockerfileSrc, dockerfileOut);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
process.stdout.write(
|
|
345
|
+
`[hub deploy fly] wrote:
|
|
346
|
+
${flyOut}
|
|
347
|
+
${dockerfileOut} (copied)
|
|
348
|
+
|
|
349
|
+
App: ${appName}
|
|
350
|
+
Region: ${region}
|
|
351
|
+
URL: https://${appName}.fly.dev
|
|
352
|
+
|
|
353
|
+
Next steps (review fly.toml first, then run):
|
|
354
|
+
fly launch --copy-config --no-deploy --name ${appName} --region ${region}
|
|
355
|
+
fly volumes create maude_hub_data --region ${region} --size 3 --yes
|
|
356
|
+
fly deploy
|
|
357
|
+
|
|
358
|
+
After deploy, the boot logs print a single-use /admin bootstrap link.
|
|
359
|
+
Open it → "Generate invite" → copy the \`maude design link …\` command.
|
|
360
|
+
Hub auth uses the token store; HUB_SECRET stays unset unless you want a
|
|
361
|
+
headless escape hatch (\`fly secrets set HUB_SECRET=$(openssl rand -hex 32)\`).
|
|
362
|
+
`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function deployDocker({ hubRoot, outDir, flags }) {
|
|
367
|
+
const tag = flags.tag ? String(flags.tag) : 'latest';
|
|
368
|
+
if (!IMAGE_TAG_REGEX.test(tag)) {
|
|
369
|
+
process.stderr.write(`maude hub deploy docker: invalid --tag "${tag}".\n`);
|
|
370
|
+
process.exit(2);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const compose = renderTemplate(hubRoot, 'docker-compose.yml.template', { IMAGE_TAG: tag });
|
|
374
|
+
const composeOut = resolve(outDir, 'docker-compose.yml');
|
|
375
|
+
guardOverwrite(composeOut, flags.force, 'docker-compose.yml');
|
|
376
|
+
writeFileSync(composeOut, compose, 'utf8');
|
|
377
|
+
|
|
378
|
+
// Caddyfile uses {$ENV} runtime substitution — no placeholders to render.
|
|
379
|
+
const caddySrc = resolve(hubRoot, 'Caddyfile.template');
|
|
380
|
+
const caddyOut = resolve(outDir, 'Caddyfile');
|
|
381
|
+
guardOverwrite(caddyOut, flags.force, 'Caddyfile');
|
|
382
|
+
copyFileSync(caddySrc, caddyOut);
|
|
383
|
+
|
|
384
|
+
process.stdout.write(
|
|
385
|
+
`[hub deploy docker] wrote:
|
|
386
|
+
${composeOut}
|
|
387
|
+
${caddyOut}
|
|
388
|
+
|
|
389
|
+
Image: ghcr.io/1agh/maude-hub:${tag}
|
|
390
|
+
|
|
391
|
+
Next steps (point DNS at this box, then run):
|
|
392
|
+
cat > .env <<EOF
|
|
393
|
+
HUB_SECRET=$(openssl rand -hex 32)
|
|
394
|
+
PUBLIC_DOMAIN=maude-hub.example.com
|
|
395
|
+
ACME_EMAIL=you@example.com
|
|
396
|
+
EOF
|
|
397
|
+
docker compose up -d
|
|
398
|
+
docker compose logs hub # copy the single-use /admin bootstrap link
|
|
399
|
+
|
|
400
|
+
Caddy fetches a Let's Encrypt cert for PUBLIC_DOMAIN automatically and
|
|
401
|
+
proxies WSS to the hub. Works on Lightsail, EC2, Hetzner, DigitalOcean,
|
|
402
|
+
Coolify, or a home server. See the hub-deploy docs for per-provider notes.
|
|
403
|
+
`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function renderTemplate(hubRoot, name, vars) {
|
|
408
|
+
const path = resolve(hubRoot, name);
|
|
409
|
+
if (!existsSync(path)) {
|
|
410
|
+
process.stderr.write(`maude hub deploy: template not found: ${path}\n`);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
let out = readFileSync(path, 'utf8');
|
|
414
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
415
|
+
out = out.replaceAll(`{{${key}}}`, value);
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function guardOverwrite(path, force, label) {
|
|
421
|
+
if (existsSync(path) && !force) {
|
|
422
|
+
process.stderr.write(
|
|
423
|
+
`maude hub deploy: ${label} already exists at ${path}. Pass --force to overwrite.\n`
|
|
424
|
+
);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function randHex(bytes) {
|
|
430
|
+
return randomBytes(bytes).toString('hex');
|
|
431
|
+
}
|
|
432
|
+
|
|
208
433
|
// ---------------------------------------------------------------- helpers
|
|
209
434
|
|
|
210
435
|
function findHubRoot(pkgRoot) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import assert from 'node:assert/strict';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
6
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { dirname, join, resolve } from 'node:path';
|
|
9
9
|
import { test } from 'node:test';
|
|
@@ -32,8 +32,10 @@ function withDataDir(fn) {
|
|
|
32
32
|
test('hub help prints subcommand summary on stdout', () => {
|
|
33
33
|
const res = runCli(['hub', 'help']);
|
|
34
34
|
assert.equal(res.status, 0, res.stderr);
|
|
35
|
-
assert.match(res.stdout, /maude hub <serve\|token\|status>/);
|
|
35
|
+
assert.match(res.stdout, /maude hub <serve\|token\|status\|deploy>/);
|
|
36
36
|
assert.match(res.stdout, /token generate --label NAME/);
|
|
37
|
+
assert.match(res.stdout, /token rotate --label NAME/);
|
|
38
|
+
assert.match(res.stdout, /deploy <fly\|docker>/);
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
test('hub token generate without --label exits 2', () => {
|
|
@@ -75,6 +77,128 @@ test('hub token generate --dev uses mau_dev_ prefix', () => {
|
|
|
75
77
|
});
|
|
76
78
|
});
|
|
77
79
|
|
|
80
|
+
test("hub token generate --scope '*' mints a hub-wide (wildcard) token", () => {
|
|
81
|
+
withDataDir((dataDir) => {
|
|
82
|
+
// Array-arg spawn (no shell) → '*' is passed literally, not glob-expanded.
|
|
83
|
+
const res = runCli([
|
|
84
|
+
'hub',
|
|
85
|
+
'token',
|
|
86
|
+
'generate',
|
|
87
|
+
'--label',
|
|
88
|
+
'peer',
|
|
89
|
+
'--scope',
|
|
90
|
+
'*',
|
|
91
|
+
'--data',
|
|
92
|
+
dataDir,
|
|
93
|
+
]);
|
|
94
|
+
assert.equal(res.status, 0, res.stderr);
|
|
95
|
+
assert.match(res.stdout, /scope:\s+\*/);
|
|
96
|
+
const { tokens } = readTokens(dataDir);
|
|
97
|
+
assert.equal(tokens.length, 1);
|
|
98
|
+
// Wildcard is stored as NULL and surfaced as '*' — authorizes any documentName.
|
|
99
|
+
assert.equal(tokens[0].scope, '*');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('hub token generate --scope <value> binds the token to that documentName prefix', () => {
|
|
104
|
+
withDataDir((dataDir) => {
|
|
105
|
+
const res = runCli([
|
|
106
|
+
'hub',
|
|
107
|
+
'token',
|
|
108
|
+
'generate',
|
|
109
|
+
'--label',
|
|
110
|
+
'peer',
|
|
111
|
+
'--scope',
|
|
112
|
+
'team-x',
|
|
113
|
+
'--data',
|
|
114
|
+
dataDir,
|
|
115
|
+
]);
|
|
116
|
+
assert.equal(res.status, 0, res.stderr);
|
|
117
|
+
assert.match(res.stdout, /scope:\s+team-x/);
|
|
118
|
+
assert.equal(readTokens(dataDir).tokens[0].scope, 'team-x');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('hub token generate without --scope defaults the scope to the label (DDR-053)', () => {
|
|
123
|
+
withDataDir((dataDir) => {
|
|
124
|
+
runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
|
|
125
|
+
assert.equal(readTokens(dataDir).tokens[0].scope, 'alice');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('hub token rotate mints a fresh value for an existing label', () => {
|
|
130
|
+
withDataDir((dataDir) => {
|
|
131
|
+
const gen = runCli(['hub', 'token', 'generate', '--label', 'alice', '--data', dataDir]);
|
|
132
|
+
const firstValue = /value:\s+(mau_[0-9a-f]{32})/.exec(gen.stdout)?.[1];
|
|
133
|
+
assert.ok(firstValue, 'generate printed a value');
|
|
134
|
+
|
|
135
|
+
const rot = runCli(['hub', 'token', 'rotate', '--label', 'alice', '--data', dataDir]);
|
|
136
|
+
assert.equal(rot.status, 0, rot.stderr);
|
|
137
|
+
const rotatedValue = /value:\s+(mau_[0-9a-f]{32})/.exec(rot.stdout)?.[1];
|
|
138
|
+
assert.ok(rotatedValue, 'rotate printed a value');
|
|
139
|
+
assert.notEqual(rotatedValue, firstValue, 'rotate changed the value');
|
|
140
|
+
assert.match(rot.stdout, /rotated in/);
|
|
141
|
+
// Still exactly one token under that label.
|
|
142
|
+
assert.equal(readTokens(dataDir).tokens.length, 1);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('hub token rotate on a missing label exits 1', () => {
|
|
147
|
+
withDataDir((dataDir) => {
|
|
148
|
+
const res = runCli(['hub', 'token', 'rotate', '--label', 'ghost', '--data', dataDir]);
|
|
149
|
+
assert.equal(res.status, 1);
|
|
150
|
+
assert.match(res.stderr, /no token with label "ghost"/);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('hub deploy fly emits fly.toml + Dockerfile with substituted placeholders', () => {
|
|
155
|
+
withDataDir((outDir) => {
|
|
156
|
+
const res = runCli([
|
|
157
|
+
'hub',
|
|
158
|
+
'deploy',
|
|
159
|
+
'fly',
|
|
160
|
+
'--name',
|
|
161
|
+
'maude-hub-test',
|
|
162
|
+
'--region',
|
|
163
|
+
'fra',
|
|
164
|
+
'--out',
|
|
165
|
+
outDir,
|
|
166
|
+
]);
|
|
167
|
+
assert.equal(res.status, 0, res.stderr);
|
|
168
|
+
const flyToml = readFileSync(join(outDir, 'fly.toml'), 'utf8');
|
|
169
|
+
assert.match(flyToml, /app = "maude-hub-test"/);
|
|
170
|
+
assert.match(flyToml, /primary_region = "fra"/);
|
|
171
|
+
assert.doesNotMatch(flyToml, /\{\{/, 'no unsubstituted placeholders');
|
|
172
|
+
assert.ok(existsSync(join(outDir, 'Dockerfile')), 'Dockerfile copied alongside');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('hub deploy docker emits compose + Caddyfile with the image tag', () => {
|
|
177
|
+
withDataDir((outDir) => {
|
|
178
|
+
const res = runCli(['hub', 'deploy', 'docker', '--tag', 'v9.9.9', '--out', outDir]);
|
|
179
|
+
assert.equal(res.status, 0, res.stderr);
|
|
180
|
+
const compose = readFileSync(join(outDir, 'docker-compose.yml'), 'utf8');
|
|
181
|
+
assert.match(compose, /ghcr\.io\/1agh\/maude-hub:v9\.9\.9/);
|
|
182
|
+
assert.doesNotMatch(compose, /\{\{/, 'no unsubstituted placeholders');
|
|
183
|
+
assert.ok(existsSync(join(outDir, 'Caddyfile')), 'Caddyfile emitted');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('hub deploy refuses to overwrite without --force', () => {
|
|
188
|
+
withDataDir((outDir) => {
|
|
189
|
+
runCli(['hub', 'deploy', 'docker', '--out', outDir]);
|
|
190
|
+
const res = runCli(['hub', 'deploy', 'docker', '--out', outDir]);
|
|
191
|
+
assert.equal(res.status, 1);
|
|
192
|
+
assert.match(res.stderr, /already exists.*--force/s);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('hub deploy with an unknown target exits 2', () => {
|
|
197
|
+
const res = runCli(['hub', 'deploy', 'kubernetes']);
|
|
198
|
+
assert.equal(res.status, 2);
|
|
199
|
+
assert.match(res.stderr, /target must be one of/);
|
|
200
|
+
});
|
|
201
|
+
|
|
78
202
|
test('hub status against an unreachable URL exits 1 with diagnostic', () => {
|
|
79
203
|
const res = runCli(['hub', 'status', 'http://127.0.0.1:1']);
|
|
80
204
|
assert.equal(res.status, 1);
|
package/cli/commands/init.mjs
CHANGED
|
@@ -74,6 +74,9 @@ export async function run({ args, pkgRoot }) {
|
|
|
74
74
|
const result = await copyTree(skeleton, aiDir, {
|
|
75
75
|
force: !!flags.force,
|
|
76
76
|
dryRun: !!flags['dry-run'],
|
|
77
|
+
// npm strips `.gitignore` from published tarballs, so the cache-ignore
|
|
78
|
+
// template ships as `gitignore` and is renamed to `.gitignore` on copy.
|
|
79
|
+
rename: (name) => (name === 'gitignore' ? '.gitignore' : name),
|
|
77
80
|
transformMatch: (p) => TEMPLATED.some((t) => p.endsWith(t)),
|
|
78
81
|
transform: ({ srcPath, contents }) => {
|
|
79
82
|
let out = contents.replaceAll(PLACEHOLDER, projectName);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { runPreflight } from '../lib/preflight.mjs';
|
|
2
|
+
|
|
3
|
+
// `maude preflight --plugin <design|flow> [--json|--shell-export|--warn-only|--quiet] [--cache PATH]`
|
|
4
|
+
// Thin command over cli/lib/preflight.mjs. Plugin bin scripts + hooks call this
|
|
5
|
+
// instead of shelling to a relative `$PKG_ROOT/cli/lib/preflight.mjs` — in a
|
|
6
|
+
// marketplace install the plugin is copied alone (no sibling cli/), so the
|
|
7
|
+
// only reachable entry is the on-PATH `maude` binary. See DDR-061.
|
|
8
|
+
|
|
9
|
+
export async function run({ args, pkgRoot }) {
|
|
10
|
+
return runPreflight({ args, pkgRoot });
|
|
11
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `maude preflight` resolves the plugin manifest from the maude PACKAGE root,
|
|
2
|
+
// never the caller's cwd. This is the DDR-061 regression: in a marketplace
|
|
3
|
+
// install the plugin is copied alone (no sibling cli/), and bin scripts reach
|
|
4
|
+
// the check through the on-PATH `maude` binary — invoked from an arbitrary
|
|
5
|
+
// target-repo cwd. Spawning from a temp cwd that has no `plugins/` proves the
|
|
6
|
+
// resolution doesn't depend on cwd.
|
|
7
|
+
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
|
|
17
|
+
|
|
18
|
+
function runFromTmp(args) {
|
|
19
|
+
const cwd = mkdtempSync(join(tmpdir(), 'maude-preflight-'));
|
|
20
|
+
try {
|
|
21
|
+
return spawnSync(process.execPath, [BIN, 'preflight', ...args], { cwd, encoding: 'utf8' });
|
|
22
|
+
} finally {
|
|
23
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('preflight resolves the manifest from pkgRoot, not cwd (marketplace-layout regression)', () => {
|
|
28
|
+
// cwd has no plugins/ — if resolution used cwd this would error "manifest not found".
|
|
29
|
+
const res = runFromTmp(['--plugin', 'design', '--shell-export']);
|
|
30
|
+
assert.equal(res.status === 0 || res.status === 1, true, res.stderr); // 0/1 = ran; 2 = bad args
|
|
31
|
+
assert.match(res.stdout, /DEPS_PLUGIN="design"/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('preflight --json emits a structured envelope for the named plugin', () => {
|
|
35
|
+
const res = runFromTmp(['--plugin', 'flow', '--json']);
|
|
36
|
+
const env = JSON.parse(res.stdout);
|
|
37
|
+
assert.equal(env.plugin, 'flow');
|
|
38
|
+
assert.ok(Array.isArray(env.results));
|
|
39
|
+
assert.ok(env.summary && typeof env.summary.total === 'number');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('preflight without --plugin exits 2', () => {
|
|
43
|
+
const res = runFromTmp([]);
|
|
44
|
+
assert.equal(res.status, 2);
|
|
45
|
+
assert.match(res.stderr, /--plugin/);
|
|
46
|
+
});
|