@1agh/maude 0.47.0 → 0.49.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 -6
- package/apps/studio/acp/bridge.ts +8 -1
- package/apps/studio/acp/plugin-bootstrap.ts +15 -1
- package/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/build.ts +1 -1
- package/apps/studio/client/export-center.jsx +7 -6
- package/apps/studio/client/github.js +11 -4
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +282 -282
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
- package/apps/studio/examples/perf-100-artboards.tsx +1 -1
- package/apps/studio/exporters/pdf.ts +35 -1
- package/apps/studio/git/service.ts +4 -1
- package/apps/studio/http.ts +45 -0
- package/apps/studio/paths.ts +37 -1
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
- package/apps/studio/test/acp-session-plugins.test.ts +6 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/exporters/pdf.test.ts +33 -1
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/commands/init.mjs +80 -3
- package/cli/commands/kg.mjs +368 -0
- package/cli/commands/kg.test.mjs +118 -0
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/ddr-to-kgai.mjs +648 -0
- package/cli/lib/ddr-to-kgai.test.mjs +99 -0
- package/cli/lib/flow-design-integration.test.mjs +2 -2
- package/cli/lib/gitignore-block.mjs +16 -1
- package/cli/lib/plugin-name-namespace.test.mjs +71 -0
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +17 -0
- package/plugins/flow/.claude-plugin/config.schema.json +66 -0
- package/plugins/flow/dependencies.json +17 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Workspace mode + the containment invariant's first enforcement point.
|
|
2
|
+
//
|
|
3
|
+
// DDR-193 states the invariant:
|
|
4
|
+
//
|
|
5
|
+
// > No tenant-authored TSX is ever evaluated by vendor-operated compute.
|
|
6
|
+
// > A cell runs sync + git + asset storage — nothing else. It never renders a
|
|
7
|
+
// > canvas, never builds a bundle, never executes a canvas module, never runs
|
|
8
|
+
// > a headless browser against tenant content.
|
|
9
|
+
//
|
|
10
|
+
// and states how it is kept: "enforced, not asserted — a boot-assert in the cell
|
|
11
|
+
// image plus a CI grep gate." This module is the boot-assert. The grep gate is
|
|
12
|
+
// `scripts/check-containment.sh`.
|
|
13
|
+
//
|
|
14
|
+
// WHY A BOOT-ASSERT AND NOT A CODE REVIEW RULE. Every canvas is code the tenant
|
|
15
|
+
// wrote. The moment vendor compute evaluates it, an anonymous signup has
|
|
16
|
+
// arbitrary code execution inside our perimeter, next to other tenants' designs
|
|
17
|
+
// and the control plane's credentials. A convention protects that for exactly as
|
|
18
|
+
// long as everyone remembers it; a process that refuses to start does not
|
|
19
|
+
// forget. The failure mode this prevents is not "someone adds a bad route on
|
|
20
|
+
// purpose" — it is "someone adds a thumbnail endpoint in 2027 without knowing
|
|
21
|
+
// this line exists."
|
|
22
|
+
//
|
|
23
|
+
// It fails CLOSED and LOUD: the cell refuses to boot, naming the exact surfaces
|
|
24
|
+
// that broke it. A cell that will not start is a page; a cell that quietly
|
|
25
|
+
// renders tenant code is an incident.
|
|
26
|
+
|
|
27
|
+
/** True when this process is a vendor-operated workspace cell. */
|
|
28
|
+
export function isWorkspaceMode(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
29
|
+
return env.MAUDE_WORKSPACE_MODE === '1';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Route paths a workspace cell must not expose, with the reason each one
|
|
34
|
+
* violates containment. The reason travels with the entry so the error message
|
|
35
|
+
* teaches rather than just refusing.
|
|
36
|
+
*
|
|
37
|
+
* These are prefixes: `/_api/export-jobs` is caught by `/_api/export` and that
|
|
38
|
+
* is intended — a variant of a forbidden surface is still forbidden.
|
|
39
|
+
*/
|
|
40
|
+
export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: string }> =
|
|
41
|
+
Object.freeze([
|
|
42
|
+
{
|
|
43
|
+
prefix: '/_api/export',
|
|
44
|
+
why: 'export renders the canvas through a headless browser — it EVALUATES tenant TSX',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
prefix: '/_api/photo-edit',
|
|
48
|
+
why: 'photo editing decodes and processes tenant-supplied media in-process',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
prefix: '/_api/generate',
|
|
52
|
+
why: 'media generation runs tenant-authored prompts against a provider key held here',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
prefix: '/_canvas-shell',
|
|
56
|
+
why: 'the canvas shell is the surface that mounts and executes a canvas module',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
prefix: '/_canvas-runtime',
|
|
60
|
+
why: 'serving the canvas runtime only makes sense if something here renders a canvas',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
prefix: '/_ws/acp',
|
|
64
|
+
why: 'the ACP bridge spawns the user’s own `claude` and can drive file edits (DDR-123 is desktop-only)',
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Modules a cell must not be able to load. Presence of the *dependency* is the
|
|
70
|
+
* signal — a cell image that ships Playwright is one `import()` away from
|
|
71
|
+
* rendering tenant content, so the invariant is cheaper to hold at the image
|
|
72
|
+
* boundary than at the call site.
|
|
73
|
+
*/
|
|
74
|
+
export const FORBIDDEN_MODULES: ReadonlyArray<string> = Object.freeze([
|
|
75
|
+
'playwright',
|
|
76
|
+
'playwright-core',
|
|
77
|
+
'puppeteer',
|
|
78
|
+
'puppeteer-core',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/** True when `route` is (or starts with) a forbidden prefix. */
|
|
82
|
+
export function isForbiddenRoute(route: string): boolean {
|
|
83
|
+
return FORBIDDEN_ROUTE_PREFIXES.some((f) => route === f.prefix || route.startsWith(f.prefix));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove every forbidden surface from a route table.
|
|
88
|
+
*
|
|
89
|
+
* This is what lets a cell boot at all: the studio serves one route table, and
|
|
90
|
+
* most of it (sync, comments, annotations, assets, git) is exactly what a cell
|
|
91
|
+
* needs. Pruning — rather than maintaining a separate cell server — means a cell
|
|
92
|
+
* runs the SAME code path a self-hoster runs, which is the property DDR-192 §1
|
|
93
|
+
* is built on. A second implementation would drift.
|
|
94
|
+
*
|
|
95
|
+
* Order matters: prune first, then `assertContainment` over the PRUNED keys.
|
|
96
|
+
* That makes the boot-assert a post-condition on this function rather than an
|
|
97
|
+
* independent opinion, so a prefix added to the vocabulary automatically both
|
|
98
|
+
* prunes and is verified.
|
|
99
|
+
*/
|
|
100
|
+
export function pruneForWorkspace<T extends Record<string, unknown>>(
|
|
101
|
+
routes: T
|
|
102
|
+
): { routes: Partial<T>; removed: string[] } {
|
|
103
|
+
const kept: Record<string, unknown> = {};
|
|
104
|
+
const removed: string[] = [];
|
|
105
|
+
for (const [route, handler] of Object.entries(routes)) {
|
|
106
|
+
if (isForbiddenRoute(route)) removed.push(route);
|
|
107
|
+
else kept[route] = handler;
|
|
108
|
+
}
|
|
109
|
+
return { routes: kept as Partial<T>, removed: removed.sort() };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ContainmentReport {
|
|
113
|
+
ok: boolean;
|
|
114
|
+
/** Route prefixes that are reachable and must not be. */
|
|
115
|
+
routes: Array<{ route: string; prefix: string; why: string }>;
|
|
116
|
+
/** Forbidden modules that resolve in this process. */
|
|
117
|
+
modules: string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check a route table against the invariant. Pure — takes the route names so it
|
|
122
|
+
* can be unit-tested without booting a server, and so the caller decides what
|
|
123
|
+
* "reachable" means for its own dispatch shape.
|
|
124
|
+
*/
|
|
125
|
+
export function checkContainment(
|
|
126
|
+
routeNames: Iterable<string>,
|
|
127
|
+
{ resolveModule }: { resolveModule?: (specifier: string) => boolean } = {}
|
|
128
|
+
): ContainmentReport {
|
|
129
|
+
const routes: ContainmentReport['routes'] = [];
|
|
130
|
+
for (const route of routeNames) {
|
|
131
|
+
for (const { prefix, why } of FORBIDDEN_ROUTE_PREFIXES) {
|
|
132
|
+
if (route === prefix || route.startsWith(prefix)) {
|
|
133
|
+
routes.push({ route, prefix, why });
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const modules: string[] = [];
|
|
140
|
+
if (resolveModule) {
|
|
141
|
+
for (const specifier of FORBIDDEN_MODULES) {
|
|
142
|
+
if (resolveModule(specifier)) modules.push(specifier);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ok: routes.length === 0 && modules.length === 0, routes, modules };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Render a report as the message the operator sees when a cell refuses to boot. */
|
|
150
|
+
export function formatContainmentFailure(report: ContainmentReport): string {
|
|
151
|
+
const lines = [
|
|
152
|
+
'REFUSING TO START: this process is running as a workspace cell',
|
|
153
|
+
'(MAUDE_WORKSPACE_MODE=1) but exposes surfaces that break the containment',
|
|
154
|
+
'invariant — "no tenant-authored TSX is ever evaluated by vendor-operated',
|
|
155
|
+
'compute" (DDR-193 §2).',
|
|
156
|
+
'',
|
|
157
|
+
];
|
|
158
|
+
if (report.routes.length > 0) {
|
|
159
|
+
lines.push('Reachable routes that must not be:');
|
|
160
|
+
for (const { route, prefix, why } of report.routes) {
|
|
161
|
+
lines.push(` ${route}`);
|
|
162
|
+
lines.push(` matches ${prefix} — ${why}`);
|
|
163
|
+
}
|
|
164
|
+
lines.push('');
|
|
165
|
+
}
|
|
166
|
+
if (report.modules.length > 0) {
|
|
167
|
+
lines.push('Modules that resolve in this process and must not:');
|
|
168
|
+
for (const m of report.modules) lines.push(` ${m}`);
|
|
169
|
+
lines.push('');
|
|
170
|
+
}
|
|
171
|
+
lines.push(
|
|
172
|
+
'A cell runs sync + git + asset storage. Rendering happens on a member’s own',
|
|
173
|
+
'machine, in Maude Desktop, where DDR-063/DDR-054 already contain it.',
|
|
174
|
+
'',
|
|
175
|
+
'If a feature genuinely needs one of these, Direction B (a structured,',
|
|
176
|
+
'non-executable synced unit) is its HARD PREREQUISITE — not a flag to add here.'
|
|
177
|
+
);
|
|
178
|
+
if (report.modules.length > 0) {
|
|
179
|
+
lines.push(
|
|
180
|
+
'',
|
|
181
|
+
'Testing workspace mode inside a DEV CHECKOUT? Playwright is a legitimate',
|
|
182
|
+
'devDependency here (the E2E harness), so it resolves and this fires. Set',
|
|
183
|
+
'MAUDE_WORKSPACE_ALLOW_DEV_MODULES=1 to skip the module check locally. It has',
|
|
184
|
+
'no effect on a built cell image, where the runtime-dependency gate in',
|
|
185
|
+
'scripts/check-containment.sh is the enforcement — a shipped cell must not',
|
|
186
|
+
'contain a browser at all.'
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return lines.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Boot gate. No-op unless workspace mode is on; otherwise throws with the full
|
|
194
|
+
* report when anything forbidden is reachable.
|
|
195
|
+
*
|
|
196
|
+
* Deliberately throws rather than returning a boolean: a caller that forgets to
|
|
197
|
+
* check a return value is exactly the class of mistake this exists to survive.
|
|
198
|
+
*/
|
|
199
|
+
export function assertContainment(
|
|
200
|
+
routeNames: Iterable<string>,
|
|
201
|
+
{
|
|
202
|
+
env = process.env,
|
|
203
|
+
resolveModule,
|
|
204
|
+
}: { env?: NodeJS.ProcessEnv; resolveModule?: (specifier: string) => boolean } = {}
|
|
205
|
+
): void {
|
|
206
|
+
if (!isWorkspaceMode(env)) return;
|
|
207
|
+
const report = checkContainment(routeNames, { resolveModule });
|
|
208
|
+
if (report.ok) return;
|
|
209
|
+
throw new Error(formatContainmentFailure(report));
|
|
210
|
+
}
|
package/apps/studio/ws.ts
CHANGED
|
@@ -27,6 +27,10 @@ export type WsData =
|
|
|
27
27
|
remote: string;
|
|
28
28
|
kind: 'collab';
|
|
29
29
|
slug: string;
|
|
30
|
+
/** Which origin upgraded this socket — 'canvas' marks the untrusted
|
|
31
|
+
* canvas iframe origin, whose sync frames go through the DDR-122
|
|
32
|
+
* origin gate in collab/room.ts. Set at upgrade time in server.ts. */
|
|
33
|
+
realm: 'main' | 'canvas';
|
|
30
34
|
}
|
|
31
35
|
| {
|
|
32
36
|
// T2 (9.1-A) — HMR-only socket for the segregated canvas origin. Receives
|
|
@@ -244,9 +248,14 @@ export function createWs(
|
|
|
244
248
|
// right room to disconnect from. Multiplexed via ws.data.id.
|
|
245
249
|
const collabConns = new Map<string, { roomSlug: string; conn: RoomConn }>();
|
|
246
250
|
|
|
247
|
-
function bindCollab(
|
|
251
|
+
function bindCollab(
|
|
252
|
+
ws: ServerWebSocket<WsData>,
|
|
253
|
+
slug: string,
|
|
254
|
+
realm: 'main' | 'canvas'
|
|
255
|
+
): RoomConn {
|
|
248
256
|
const conn: RoomConn = {
|
|
249
257
|
id: ws.data.id,
|
|
258
|
+
realm,
|
|
250
259
|
send(payload: Uint8Array) {
|
|
251
260
|
try {
|
|
252
261
|
// Bun's ws.send accepts Uint8Array directly as binary.
|
|
@@ -264,7 +273,7 @@ export function createWs(
|
|
|
264
273
|
async open(ws) {
|
|
265
274
|
if (ws.data.kind === 'collab') {
|
|
266
275
|
const room = collab.registry.get(ws.data.slug);
|
|
267
|
-
const conn = bindCollab(ws, ws.data.slug);
|
|
276
|
+
const conn = bindCollab(ws, ws.data.slug, ws.data.realm ?? 'main');
|
|
268
277
|
await room.connect(conn);
|
|
269
278
|
return;
|
|
270
279
|
}
|
package/cli/bin/maude.mjs
CHANGED
|
@@ -18,6 +18,7 @@ const COMMANDS = {
|
|
|
18
18
|
design: () => import('../commands/design.mjs'),
|
|
19
19
|
'scenario-report': () => import('../commands/scenario-report.mjs'),
|
|
20
20
|
doctor: () => import('../commands/doctor.mjs'),
|
|
21
|
+
kg: () => import('../commands/kg.mjs'),
|
|
21
22
|
help: () => import('../commands/help.mjs'),
|
|
22
23
|
hub: () => import('../commands/hub.mjs'),
|
|
23
24
|
version: () => import('../commands/version.mjs'),
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// `maude hub workspace-up` — Cloud Phase 4 Task 1, the effects layer.
|
|
2
|
+
//
|
|
3
|
+
// The decisions live in `cli/lib/workspace-plan.mjs` (pure, tested without a
|
|
4
|
+
// VPS). This file does the parts that genuinely touch the world: read config,
|
|
5
|
+
// write files, boot the stack, run the verification plan, print the result.
|
|
6
|
+
//
|
|
7
|
+
// IDEMPOTENT BY CONSTRUCTION. Re-running is the upgrade path: the rendered
|
|
8
|
+
// files are regenerated, but `.env` secrets that already exist are REUSED, not
|
|
9
|
+
// re-minted. Re-minting HUB_SECRET on every run would silently lock out every
|
|
10
|
+
// peer that already has a token, and it would do it to the person whose
|
|
11
|
+
// instinct after a failed run is to try again.
|
|
12
|
+
//
|
|
13
|
+
// It does NOT claim to own the deployment afterwards — see `operatorDuties`.
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { parseArgs } from '../lib/argv.mjs';
|
|
21
|
+
import {
|
|
22
|
+
envEntries,
|
|
23
|
+
operatorDuties,
|
|
24
|
+
renderCaddyfile,
|
|
25
|
+
renderCompose,
|
|
26
|
+
renderEnv,
|
|
27
|
+
validateWorkspaceConfig,
|
|
28
|
+
verificationPlan,
|
|
29
|
+
} from '../lib/workspace-plan.mjs';
|
|
30
|
+
|
|
31
|
+
export function usage() {
|
|
32
|
+
return `maude hub workspace-up [options]
|
|
33
|
+
|
|
34
|
+
Stand up a self-hosted Maude WORKSPACE — a hub that owns the project, commits
|
|
35
|
+
autosaves, and stores media in object storage — and verify it actually works
|
|
36
|
+
before saying so.
|
|
37
|
+
|
|
38
|
+
--domain HOST public hostname (design.acme.com)
|
|
39
|
+
--acme-email EMAIL Let's Encrypt contact
|
|
40
|
+
--admin-email EMAIL the first person who can sign in
|
|
41
|
+
--admin-password PASS their initial password (>= 12 chars; generated if omitted)
|
|
42
|
+
--s3-endpoint URL object storage (R2 / MinIO / S3)
|
|
43
|
+
--s3-bucket NAME
|
|
44
|
+
--s3-access-key-id ID
|
|
45
|
+
--s3-secret-access-key SECRET
|
|
46
|
+
--s3-region REGION default "auto"
|
|
47
|
+
--dev-minio run a local MinIO under the compose 'dev' profile
|
|
48
|
+
--seed-repo URL clone an existing project; omit to start fresh
|
|
49
|
+
--image-tag TAG default "latest" — pin it before you rely on this
|
|
50
|
+
--config FILE read all of the above from a JSON file
|
|
51
|
+
--out DIR where to write compose/Caddyfile/.env (default: cwd)
|
|
52
|
+
--dry-run render + print the plan, touch nothing else
|
|
53
|
+
--json machine-readable result
|
|
54
|
+
|
|
55
|
+
Re-running is the UPGRADE path: files are regenerated, existing secrets in
|
|
56
|
+
.env are REUSED (re-minting HUB_SECRET would lock out every peer that already
|
|
57
|
+
has a token).
|
|
58
|
+
|
|
59
|
+
What it does NOT do: own the deployment. It scaffolds and verifies once.
|
|
60
|
+
Rotation, backups, upgrades and the bill stay with you — the run prints the
|
|
61
|
+
list.
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function run({ args, pkgRoot }) {
|
|
66
|
+
const { flags } = parseArgs(args, { booleans: ['dry-run', 'json', 'dev-minio'] });
|
|
67
|
+
if (flags.help) {
|
|
68
|
+
process.stdout.write(usage());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const outDir = resolve(String(flags.out ?? process.cwd()));
|
|
73
|
+
const raw = flags.config ? readConfigFile(String(flags.config)) : {};
|
|
74
|
+
const merged = {
|
|
75
|
+
domain: flags.domain ?? raw.domain,
|
|
76
|
+
acmeEmail: flags['acme-email'] ?? raw.acmeEmail,
|
|
77
|
+
adminEmail: flags['admin-email'] ?? raw.adminEmail,
|
|
78
|
+
...((flags['admin-password'] ?? raw.adminPassword)
|
|
79
|
+
? { adminPassword: flags['admin-password'] ?? raw.adminPassword }
|
|
80
|
+
: {}),
|
|
81
|
+
devMinio: flags['dev-minio'] === true || raw.devMinio === true,
|
|
82
|
+
seedRepo: flags['seed-repo'] ?? raw.seedRepo,
|
|
83
|
+
imageTag: flags['image-tag'] ?? raw.imageTag,
|
|
84
|
+
...(flags['s3-endpoint'] || raw.s3
|
|
85
|
+
? {
|
|
86
|
+
s3: {
|
|
87
|
+
endpoint: flags['s3-endpoint'] ?? raw.s3?.endpoint,
|
|
88
|
+
bucket: flags['s3-bucket'] ?? raw.s3?.bucket,
|
|
89
|
+
accessKeyId: flags['s3-access-key-id'] ?? raw.s3?.accessKeyId,
|
|
90
|
+
secretAccessKey: flags['s3-secret-access-key'] ?? raw.s3?.secretAccessKey,
|
|
91
|
+
region: flags['s3-region'] ?? raw.s3?.region,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
: {}),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const { ok, errors, config } = validateWorkspaceConfig(merged);
|
|
98
|
+
if (!ok) {
|
|
99
|
+
if (flags.json) {
|
|
100
|
+
process.stdout.write(`${JSON.stringify({ ok: false, errors }, null, 2)}\n`);
|
|
101
|
+
} else {
|
|
102
|
+
process.stderr.write('maude hub workspace-up: the configuration is not usable yet.\n\n');
|
|
103
|
+
for (const e of errors) process.stderr.write(` • ${e}\n`);
|
|
104
|
+
process.stderr.write('\nRun with --help for the full list of options.\n');
|
|
105
|
+
}
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Reuse existing secrets — re-minting on a re-run would lock out every peer
|
|
110
|
+
// that already holds a token, and re-running is exactly what someone does
|
|
111
|
+
// after a failed attempt.
|
|
112
|
+
const existing = readExistingEnv(resolve(outDir, '.env'));
|
|
113
|
+
const hubSecret = existing.HUB_SECRET || randomBytes(32).toString('hex');
|
|
114
|
+
const adminPassword = config.adminPassword || existing.MAUDE_ADMIN_PASSWORD || generatePassword();
|
|
115
|
+
const reusedSecret = Boolean(existing.HUB_SECRET);
|
|
116
|
+
|
|
117
|
+
const entries = envEntries(config, { hubSecret, adminPassword });
|
|
118
|
+
const files = [
|
|
119
|
+
{ name: '.env', body: renderEnv(entries), mode: 0o600 },
|
|
120
|
+
{ name: 'docker-compose.yml', body: renderCompose(config), mode: 0o644 },
|
|
121
|
+
{ name: 'Caddyfile', body: renderCaddyfile(config), mode: 0o644 },
|
|
122
|
+
];
|
|
123
|
+
const plan = verificationPlan(config);
|
|
124
|
+
const duties = operatorDuties(config);
|
|
125
|
+
|
|
126
|
+
if (flags['dry-run']) {
|
|
127
|
+
const result = {
|
|
128
|
+
ok: true,
|
|
129
|
+
dryRun: true,
|
|
130
|
+
outDir,
|
|
131
|
+
files: files.map((f) => ({ name: f.name, bytes: Buffer.byteLength(f.body), mode: f.mode })),
|
|
132
|
+
verification: plan.map((s) => ({ id: s.id, title: s.title })),
|
|
133
|
+
duties: duties.map((d) => d.title),
|
|
134
|
+
reusedSecret,
|
|
135
|
+
};
|
|
136
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
137
|
+
else printDryRun({ config, outDir, files, plan, duties, reusedSecret });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
mkdirSync(outDir, { recursive: true });
|
|
142
|
+
for (const f of files) {
|
|
143
|
+
const path = resolve(outDir, f.name);
|
|
144
|
+
writeFileSync(path, f.body, { encoding: 'utf8', mode: f.mode });
|
|
145
|
+
try {
|
|
146
|
+
chmodSync(path, f.mode);
|
|
147
|
+
} catch {
|
|
148
|
+
/* windows — best effort */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dockerAvailable = await which('docker');
|
|
153
|
+
if (!dockerAvailable) {
|
|
154
|
+
if (flags.json) {
|
|
155
|
+
process.stdout.write(
|
|
156
|
+
`${JSON.stringify({ ok: false, wrote: files.map((f) => f.name), error: 'docker not found' }, null, 2)}\n`
|
|
157
|
+
);
|
|
158
|
+
} else {
|
|
159
|
+
process.stdout.write(
|
|
160
|
+
`Wrote ${files.map((f) => f.name).join(', ')} to ${outDir}\n\n` +
|
|
161
|
+
'Docker is not on PATH, so the stack was not started and NOTHING WAS VERIFIED.\n' +
|
|
162
|
+
'Install Docker and run:\n\n' +
|
|
163
|
+
` cd ${outDir} && docker compose up -d\n\n` +
|
|
164
|
+
'Then re-run this command to verify the deployment.\n'
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
process.stdout.write(`Wrote ${files.map((f) => f.name).join(', ')} to ${outDir}\n`);
|
|
171
|
+
process.stdout.write('Starting the stack…\n');
|
|
172
|
+
const up = await sh('docker', ['compose', 'up', '-d'], { cwd: outDir });
|
|
173
|
+
if (up.code !== 0) {
|
|
174
|
+
process.stderr.write(`docker compose up failed:\n${up.stderr}\n`);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Verification is the deliverable. A URL printed without a proven round-trip
|
|
179
|
+
// tells the operator something this command does not know.
|
|
180
|
+
process.stdout.write('\nVerifying — this is the part that matters:\n');
|
|
181
|
+
const results = [];
|
|
182
|
+
let failed = 0;
|
|
183
|
+
for (const step of plan) {
|
|
184
|
+
const outcome = await runVerification(step, {
|
|
185
|
+
config,
|
|
186
|
+
hubSecret,
|
|
187
|
+
adminPassword,
|
|
188
|
+
outDir,
|
|
189
|
+
pkgRoot,
|
|
190
|
+
});
|
|
191
|
+
results.push({ id: step.id, title: step.title, ...outcome });
|
|
192
|
+
const mark = outcome.ok ? '✓' : outcome.skipped ? '–' : '✗';
|
|
193
|
+
process.stdout.write(` ${mark} ${step.title}${outcome.note ? ` — ${outcome.note}` : ''}\n`);
|
|
194
|
+
if (!outcome.ok && !outcome.skipped) failed++;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (flags.json) {
|
|
198
|
+
process.stdout.write(
|
|
199
|
+
`${JSON.stringify({ ok: failed === 0, outDir, verification: results, duties }, null, 2)}\n`
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
printDuties(duties);
|
|
203
|
+
process.stdout.write(
|
|
204
|
+
failed === 0
|
|
205
|
+
? `\nWorkspace verified: https://${config.domain}\n`
|
|
206
|
+
: `\n${failed} check(s) did NOT pass. The stack is running but is not proven — fix and re-run.\n`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (failed > 0) process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ------------------------------------------------------------------ helpers
|
|
213
|
+
|
|
214
|
+
function readConfigFile(path) {
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(readFileSync(resolve(path), 'utf8'));
|
|
217
|
+
} catch (err) {
|
|
218
|
+
process.stderr.write(
|
|
219
|
+
`maude hub workspace-up: couldn't read --config ${path}: ${err.message}\n`
|
|
220
|
+
);
|
|
221
|
+
process.exit(2);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Parse an existing `.env` so a re-run reuses secrets instead of re-minting. */
|
|
226
|
+
function readExistingEnv(path) {
|
|
227
|
+
if (!existsSync(path)) return {};
|
|
228
|
+
const out = {};
|
|
229
|
+
try {
|
|
230
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
231
|
+
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
|
232
|
+
if (m) out[m[1]] = m[2];
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
/* unreadable → treat as absent */
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Readable, high-entropy, and safe to paste — no ambiguous glyphs. */
|
|
241
|
+
function generatePassword() {
|
|
242
|
+
const alphabet = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
|
243
|
+
const bytes = randomBytes(24);
|
|
244
|
+
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sh(cmd, args, opts = {}) {
|
|
248
|
+
return new Promise((resolvePromise) => {
|
|
249
|
+
const child = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
250
|
+
let stdout = '';
|
|
251
|
+
let stderr = '';
|
|
252
|
+
child.stdout.on('data', (d) => {
|
|
253
|
+
stdout += d;
|
|
254
|
+
});
|
|
255
|
+
child.stderr.on('data', (d) => {
|
|
256
|
+
stderr += d;
|
|
257
|
+
});
|
|
258
|
+
child.on('error', () => resolvePromise({ code: 127, stdout, stderr: 'spawn failed' }));
|
|
259
|
+
child.on('close', (code) => resolvePromise({ code: code ?? 1, stdout, stderr }));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function which(bin) {
|
|
264
|
+
const res = await sh(process.platform === 'win32' ? 'where' : 'which', [bin]);
|
|
265
|
+
return res.code === 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Execute one verification step.
|
|
270
|
+
*
|
|
271
|
+
* A step this build cannot yet perform reports `skipped` with a reason — it
|
|
272
|
+
* NEVER reports success. Counting an unrun check as passed is the single
|
|
273
|
+
* fastest way to make a verification suite worthless.
|
|
274
|
+
*/
|
|
275
|
+
async function runVerification(step, { config, hubSecret }) {
|
|
276
|
+
const base = `https://${config.domain}`;
|
|
277
|
+
switch (step.id) {
|
|
278
|
+
case 'health': {
|
|
279
|
+
const res = await tryFetch(`${base}/health`);
|
|
280
|
+
return res.ok ? { ok: true } : { ok: false, note: res.note };
|
|
281
|
+
}
|
|
282
|
+
case 'admin-claimed': {
|
|
283
|
+
const res = await tryFetch(`${base}/admin/api/status`, {
|
|
284
|
+
headers: { Authorization: `Bearer ${hubSecret}` },
|
|
285
|
+
});
|
|
286
|
+
return res.ok ? { ok: true } : { ok: false, note: res.note };
|
|
287
|
+
}
|
|
288
|
+
default:
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
skipped: true,
|
|
292
|
+
note: 'not yet automated — verify by hand, then close it in the plan',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function tryFetch(url, init) {
|
|
298
|
+
try {
|
|
299
|
+
const ctrl = new AbortController();
|
|
300
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000);
|
|
301
|
+
try {
|
|
302
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
303
|
+
return res.ok ? { ok: true } : { ok: false, note: `HTTP ${res.status}` };
|
|
304
|
+
} finally {
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return { ok: false, note: err.name === 'AbortError' ? 'timed out' : 'unreachable' };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function printDryRun({ config, outDir, files, plan, duties, reusedSecret }) {
|
|
313
|
+
process.stdout.write(
|
|
314
|
+
`maude hub workspace-up — DRY RUN, nothing was written\n\n` +
|
|
315
|
+
` workspace https://${config.domain}\n` +
|
|
316
|
+
` first user ${config.adminEmail}\n` +
|
|
317
|
+
` storage ${config.s3 ? `${config.s3.bucket} @ ${config.s3.endpoint}${config.s3.dev ? ' (dev MinIO)' : ''}` : 'none — media stays in git'}\n` +
|
|
318
|
+
` project ${config.seedRepo ?? 'starts fresh'}\n` +
|
|
319
|
+
` image ghcr.io/1agh/maude-hub:${config.imageTag}\n` +
|
|
320
|
+
` out ${outDir}\n` +
|
|
321
|
+
(reusedSecret ? ' secrets reusing HUB_SECRET from the existing .env\n' : '') +
|
|
322
|
+
'\nWould write:\n'
|
|
323
|
+
);
|
|
324
|
+
for (const f of files) {
|
|
325
|
+
process.stdout.write(
|
|
326
|
+
` ${f.name.padEnd(20)} ${Buffer.byteLength(f.body)} bytes mode ${f.mode.toString(8)}\n`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
process.stdout.write('\nWould then verify:\n');
|
|
330
|
+
for (const s of plan) process.stdout.write(` • ${s.title} — ${s.detail}\n`);
|
|
331
|
+
printDuties(duties);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function printDuties(duties) {
|
|
335
|
+
process.stdout.write(
|
|
336
|
+
'\nThis command scaffolded and verified your workspace once. It does not\noperate it. What stays yours:\n\n'
|
|
337
|
+
);
|
|
338
|
+
for (const d of duties) {
|
|
339
|
+
process.stdout.write(` ${d.title}\n ${d.detail}\n`);
|
|
340
|
+
}
|
|
341
|
+
}
|