@1agh/maude 0.49.2 → 0.50.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/apps/studio/api.ts +751 -52
- package/apps/studio/canvas-artifacts.ts +153 -0
- package/apps/studio/canvas-create.ts +23 -0
- package/apps/studio/canvas-slug.ts +30 -0
- package/apps/studio/client/app.jsx +547 -90
- package/apps/studio/client/panels/CloudBar.jsx +284 -0
- package/apps/studio/client/styles/3-shell-maude.css +26 -0
- package/apps/studio/client/tree-row-menu.jsx +132 -0
- package/apps/studio/client/use-tree-drag.js +120 -0
- package/apps/studio/cloud/endpoints.ts +236 -0
- package/apps/studio/collab/registry.ts +23 -0
- package/apps/studio/dist/client.bundle.js +960 -958
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +113 -0
- package/apps/studio/inspect.ts +49 -0
- package/apps/studio/server.ts +15 -0
- package/apps/studio/test/canvas-artifacts.test.ts +125 -0
- package/apps/studio/test/canvas-create-api.test.ts +159 -1
- package/apps/studio/test/canvas-move-api.test.ts +556 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
- package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +294 -7
- package/cli/commands/init.mjs +33 -2
- package/cli/commands/kg.mjs +129 -4
- package/cli/commands/share.mjs +184 -0
- package/cli/lib/ddr-to-kgai.mjs +310 -82
- package/cli/lib/ddr-to-kgai.test.mjs +20 -0
- package/cli/lib/share-plan.mjs +96 -0
- package/cli/lib/share-plan.test.mjs +57 -0
- package/cli/lib/workspace-plan.mjs +130 -12
- package/cli/lib/workspace-plan.test.mjs +160 -0
- package/package.json +8 -8
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// /_api/fs-mkdir — feature-file-tree-drag-drop-folders (Task 4). Folder-name
|
|
2
|
+
// validation, containment, `.gitkeep`, collision.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { validateFolderName } from '../canvas-create.ts';
|
|
9
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
10
|
+
|
|
11
|
+
describe('validateFolderName', () => {
|
|
12
|
+
test('accepts ordinary names (incl. spaces + accents)', () => {
|
|
13
|
+
for (const n of ['Marketing', 'checkout-flow', 'Krok_1', 'Přihlášení']) {
|
|
14
|
+
const v = validateFolderName(n);
|
|
15
|
+
expect(v.ok).toBe(true);
|
|
16
|
+
expect(v.name).toBe(n);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const REJECT: Array<[string, unknown]> = [
|
|
21
|
+
['empty', ''],
|
|
22
|
+
['non-string', 123],
|
|
23
|
+
['parent traversal', '../evil'],
|
|
24
|
+
['forward slash', 'a/b'],
|
|
25
|
+
['back slash', 'a\\b'],
|
|
26
|
+
['leading dot', '.hidden'],
|
|
27
|
+
['JSX break-out chars', 'a<b>'],
|
|
28
|
+
['double quote', 'a"b'],
|
|
29
|
+
['too long (61)', 'x'.repeat(61)],
|
|
30
|
+
];
|
|
31
|
+
for (const [label, value] of REJECT) {
|
|
32
|
+
test(`rejects ${label}`, () => {
|
|
33
|
+
expect(validateFolderName(value).ok).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function mkdirReq(port: number, parent: string | undefined, name: string) {
|
|
39
|
+
return fetch(`http://localhost:${port}/_api/fs-mkdir`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({ parent, name }),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('/_api/fs-mkdir — POST round-trip', () => {
|
|
47
|
+
test('creates an empty folder with a .gitkeep', async () => {
|
|
48
|
+
const { root, designRoot } = makeSandbox();
|
|
49
|
+
const port = nextPort();
|
|
50
|
+
const proc = await bootServer(root, port);
|
|
51
|
+
try {
|
|
52
|
+
const r = await mkdirReq(port, 'ui', 'New Folder');
|
|
53
|
+
expect(r.status).toBe(201);
|
|
54
|
+
const j = (await r.json()) as { ok: boolean; dir: string };
|
|
55
|
+
expect(j.ok).toBe(true);
|
|
56
|
+
expect(j.dir).toBe('.design/ui/New Folder');
|
|
57
|
+
expect(existsSync(join(designRoot, 'ui', 'New Folder'))).toBe(true);
|
|
58
|
+
expect(existsSync(join(designRoot, 'ui', 'New Folder', '.gitkeep'))).toBe(true);
|
|
59
|
+
expect(readFileSync(join(designRoot, 'ui', 'New Folder', '.gitkeep'), 'utf8')).toBe('');
|
|
60
|
+
} finally {
|
|
61
|
+
await killProc(proc);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('defaults parent to the design root when omitted', async () => {
|
|
66
|
+
const { root } = makeSandbox();
|
|
67
|
+
const port = nextPort();
|
|
68
|
+
const proc = await bootServer(root, port);
|
|
69
|
+
try {
|
|
70
|
+
// "ui" IS a managed canvas group, so a folder directly under it works;
|
|
71
|
+
// an omitted parent (designRoot itself) is NOT inside any canvas group
|
|
72
|
+
// and must be refused — mirrors moveCanvas's group-membership rule.
|
|
73
|
+
const r = await mkdirReq(port, undefined, 'Root Folder');
|
|
74
|
+
expect(r.status).toBe(400);
|
|
75
|
+
} finally {
|
|
76
|
+
await killProc(proc);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('supports nested parents', async () => {
|
|
81
|
+
const { root, designRoot } = makeSandbox();
|
|
82
|
+
const port = nextPort();
|
|
83
|
+
const proc = await bootServer(root, port);
|
|
84
|
+
try {
|
|
85
|
+
await mkdirReq(port, 'ui', 'Parent');
|
|
86
|
+
const r = await mkdirReq(port, 'ui/Parent', 'Child');
|
|
87
|
+
expect(r.status).toBe(201);
|
|
88
|
+
expect(existsSync(join(designRoot, 'ui', 'Parent', 'Child', '.gitkeep'))).toBe(true);
|
|
89
|
+
} finally {
|
|
90
|
+
await killProc(proc);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('refuses a parent that escapes the design root (400)', async () => {
|
|
95
|
+
const { root } = makeSandbox();
|
|
96
|
+
const port = nextPort();
|
|
97
|
+
const proc = await bootServer(root, port);
|
|
98
|
+
try {
|
|
99
|
+
const r = await mkdirReq(port, '../../../../tmp', 'Escape');
|
|
100
|
+
expect(r.status).toBe(400);
|
|
101
|
+
} finally {
|
|
102
|
+
await killProc(proc);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('refuses a parent inside the design-system group (400)', async () => {
|
|
107
|
+
const { root, designRoot } = makeSandbox();
|
|
108
|
+
const port = nextPort();
|
|
109
|
+
const proc = await bootServer(root, port);
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(join(designRoot, 'system'), { recursive: true });
|
|
112
|
+
const r = await mkdirReq(port, 'system', 'NotAllowed');
|
|
113
|
+
expect(r.status).toBe(400);
|
|
114
|
+
} finally {
|
|
115
|
+
await killProc(proc);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('rejects an invalid name (400)', async () => {
|
|
120
|
+
const { root } = makeSandbox();
|
|
121
|
+
const port = nextPort();
|
|
122
|
+
const proc = await bootServer(root, port);
|
|
123
|
+
try {
|
|
124
|
+
const r = await mkdirReq(port, 'ui', '../evil');
|
|
125
|
+
expect(r.status).toBe(400);
|
|
126
|
+
} finally {
|
|
127
|
+
await killProc(proc);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a collision with an existing folder returns 409', async () => {
|
|
132
|
+
const { root, designRoot } = makeSandbox();
|
|
133
|
+
const port = nextPort();
|
|
134
|
+
const proc = await bootServer(root, port);
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(join(designRoot, 'ui', 'Already'), { recursive: true });
|
|
137
|
+
const r = await mkdirReq(port, 'ui', 'Already');
|
|
138
|
+
expect(r.status).toBe(409);
|
|
139
|
+
} finally {
|
|
140
|
+
await killProc(proc);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a collision with an existing FILE (not just a dir) also returns 409', async () => {
|
|
145
|
+
const { root, designRoot } = makeSandbox();
|
|
146
|
+
const port = nextPort();
|
|
147
|
+
const proc = await bootServer(root, port);
|
|
148
|
+
try {
|
|
149
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
150
|
+
// NAME_RE forbids '.', so an extension-less file is the only way a
|
|
151
|
+
// FILE can collide with a valid folder name — statp() must catch it
|
|
152
|
+
// the same as a directory collision above.
|
|
153
|
+
writeFileSync(join(designRoot, 'ui', 'Taken'), 'not a folder');
|
|
154
|
+
const r = await mkdirReq(port, 'ui', 'Taken');
|
|
155
|
+
expect(r.status).toBe(409);
|
|
156
|
+
} finally {
|
|
157
|
+
await killProc(proc);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('emits a canvas-list-update "mkdir" action over the inspector WS', async () => {
|
|
162
|
+
const { root } = makeSandbox();
|
|
163
|
+
const port = nextPort();
|
|
164
|
+
const proc = await bootServer(root, port);
|
|
165
|
+
let socket: WebSocket | null = null;
|
|
166
|
+
try {
|
|
167
|
+
const messages: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
168
|
+
const sock = new WebSocket(`ws://localhost:${port}/_ws`);
|
|
169
|
+
socket = sock;
|
|
170
|
+
await new Promise<void>((res, rej) => {
|
|
171
|
+
sock.addEventListener('open', () => res());
|
|
172
|
+
sock.addEventListener('error', () => rej(new Error('ws error')));
|
|
173
|
+
setTimeout(() => rej(new Error('ws open timeout')), 2000);
|
|
174
|
+
});
|
|
175
|
+
sock.addEventListener('message', (ev) => {
|
|
176
|
+
try {
|
|
177
|
+
messages.push(JSON.parse(String(ev.data)));
|
|
178
|
+
} catch {
|
|
179
|
+
/* ignore */
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const r = await mkdirReq(port, 'ui', 'Live Folder');
|
|
183
|
+
expect(r.status).toBe(201);
|
|
184
|
+
|
|
185
|
+
const deadline = Date.now() + 2000;
|
|
186
|
+
let hit: (typeof messages)[number] | undefined;
|
|
187
|
+
while (Date.now() < deadline && !hit) {
|
|
188
|
+
hit = messages.find(
|
|
189
|
+
(m) => m.type === 'canvas-list-update' && m.payload?.action === 'mkdir'
|
|
190
|
+
);
|
|
191
|
+
if (!hit) await Bun.sleep(25);
|
|
192
|
+
}
|
|
193
|
+
expect(hit?.payload?.dir).toBe('.design/ui/Live Folder');
|
|
194
|
+
} finally {
|
|
195
|
+
try {
|
|
196
|
+
socket?.close();
|
|
197
|
+
} catch {
|
|
198
|
+
/* ignore */
|
|
199
|
+
}
|
|
200
|
+
await killProc(proc);
|
|
201
|
+
}
|
|
202
|
+
}, 10_000);
|
|
203
|
+
|
|
204
|
+
test('unknown method (GET) returns 405', async () => {
|
|
205
|
+
const { root } = makeSandbox();
|
|
206
|
+
const port = nextPort();
|
|
207
|
+
const proc = await bootServer(root, port);
|
|
208
|
+
try {
|
|
209
|
+
const r = await fetch(`http://localhost:${port}/_api/fs-mkdir`, { method: 'GET' });
|
|
210
|
+
expect(r.status).toBe(405);
|
|
211
|
+
} finally {
|
|
212
|
+
await killProc(proc);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('rejects a cross-origin mkdir (CSRF guard)', async () => {
|
|
217
|
+
const { root } = makeSandbox();
|
|
218
|
+
const port = nextPort();
|
|
219
|
+
const proc = await bootServer(root, port);
|
|
220
|
+
try {
|
|
221
|
+
const r = await fetch(`http://localhost:${port}/_api/fs-mkdir`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
|
|
224
|
+
body: JSON.stringify({ parent: 'ui', name: 'Forged' }),
|
|
225
|
+
});
|
|
226
|
+
expect(r.status).toBe(403);
|
|
227
|
+
} finally {
|
|
228
|
+
await killProc(proc);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Security review finding — see the matching test in canvas-move-api.test.ts.
|
|
233
|
+
test('refuses creating a folder under a symlinked parent', async () => {
|
|
234
|
+
const { root, designRoot } = makeSandbox();
|
|
235
|
+
const outside = join(root, 'OUTSIDE');
|
|
236
|
+
mkdirSync(outside, { recursive: true });
|
|
237
|
+
const port = nextPort();
|
|
238
|
+
const proc = await bootServer(root, port);
|
|
239
|
+
try {
|
|
240
|
+
symlinkSync(outside, join(designRoot, 'ui', 'link'));
|
|
241
|
+
const r = await mkdirReq(port, 'ui/link', 'Nested');
|
|
242
|
+
expect(r.status).toBe(400);
|
|
243
|
+
expect(existsSync(join(outside, 'Nested'))).toBe(false);
|
|
244
|
+
} finally {
|
|
245
|
+
await killProc(proc);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "maude-cloud-signin-attach",
|
|
6
|
+
"version": "0.50.0",
|
|
7
|
+
"date": "2026-07-30",
|
|
8
|
+
"kind": "feature",
|
|
9
|
+
"title": "Sign in to Maude Cloud from the studio",
|
|
10
|
+
"summary": "The sidebar footer now signs you into Maude Cloud with a short code — no pasted addresses or keys — and connects this project to your cloud workspace in one click.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "file-tree-move-and-folders",
|
|
15
|
+
"version": "0.50.0",
|
|
16
|
+
"date": "2026-07-30",
|
|
17
|
+
"kind": "feature",
|
|
18
|
+
"title": "Organize your files — folders, drag & drop, move",
|
|
19
|
+
"summary": "The file tree is a real file explorer now. Create folders from the header or right-click a canvas or folder for Move to… / New folder here / Delete folder — or just drag a canvas (or a whole folder) onto another folder, including back out to the top level. Every history, comment, annotation, and camera position moves with it, and every move shows an Undo toast.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
4
22
|
{
|
|
5
23
|
"id": "figma-smart-select",
|
|
6
24
|
"version": "0.47.0",
|
package/cli/bin/maude.mjs
CHANGED
|
@@ -21,6 +21,7 @@ const COMMANDS = {
|
|
|
21
21
|
kg: () => import('../commands/kg.mjs'),
|
|
22
22
|
help: () => import('../commands/help.mjs'),
|
|
23
23
|
hub: () => import('../commands/hub.mjs'),
|
|
24
|
+
share: () => import('../commands/share.mjs'),
|
|
24
25
|
version: () => import('../commands/version.mjs'),
|
|
25
26
|
};
|
|
26
27
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// It does NOT claim to own the deployment afterwards — see `operatorDuties`.
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
|
-
import { randomBytes } from 'node:crypto';
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
17
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { resolve } from 'node:path';
|
|
19
19
|
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
renderEnv,
|
|
27
27
|
validateWorkspaceConfig,
|
|
28
28
|
verificationPlan,
|
|
29
|
+
workspaceBaseUrl,
|
|
29
30
|
} from '../lib/workspace-plan.mjs';
|
|
30
31
|
|
|
31
32
|
export function usage() {
|
|
@@ -45,6 +46,13 @@ export function usage() {
|
|
|
45
46
|
--s3-secret-access-key SECRET
|
|
46
47
|
--s3-region REGION default "auto"
|
|
47
48
|
--dev-minio run a local MinIO under the compose 'dev' profile
|
|
49
|
+
--local TESTING: serve plain HTTP on localhost, no
|
|
50
|
+
certificate, no ACME. --domain defaults to
|
|
51
|
+
"localhost" and must stay a loopback NAME.
|
|
52
|
+
Lets you exercise the whole stack — and every
|
|
53
|
+
verification step — on a laptop, with no domain and
|
|
54
|
+
no paid account. Never serve a real workspace this
|
|
55
|
+
way: sign-in passwords would travel in the clear.
|
|
48
56
|
--seed-repo URL clone an existing project; omit to start fresh
|
|
49
57
|
--image-tag TAG default "latest" — pin it before you rely on this
|
|
50
58
|
--config FILE read all of the above from a JSON file
|
|
@@ -63,7 +71,9 @@ export function usage() {
|
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
export async function run({ args, pkgRoot }) {
|
|
66
|
-
const { flags } = parseArgs(args, {
|
|
74
|
+
const { flags } = parseArgs(args, {
|
|
75
|
+
booleans: ['help', 'dry-run', 'json', 'dev-minio', 'local'],
|
|
76
|
+
});
|
|
67
77
|
if (flags.help) {
|
|
68
78
|
process.stdout.write(usage());
|
|
69
79
|
return;
|
|
@@ -79,6 +89,7 @@ export async function run({ args, pkgRoot }) {
|
|
|
79
89
|
? { adminPassword: flags['admin-password'] ?? raw.adminPassword }
|
|
80
90
|
: {}),
|
|
81
91
|
devMinio: flags['dev-minio'] === true || raw.devMinio === true,
|
|
92
|
+
local: flags.local === true || raw.local === true,
|
|
82
93
|
seedRepo: flags['seed-repo'] ?? raw.seedRepo,
|
|
83
94
|
imageTag: flags['image-tag'] ?? raw.imageTag,
|
|
84
95
|
...(flags['s3-endpoint'] || raw.s3
|
|
@@ -169,12 +180,27 @@ export async function run({ args, pkgRoot }) {
|
|
|
169
180
|
|
|
170
181
|
process.stdout.write(`Wrote ${files.map((f) => f.name).join(', ')} to ${outDir}\n`);
|
|
171
182
|
process.stdout.write('Starting the stack…\n');
|
|
172
|
-
|
|
183
|
+
// `--dev-minio` renders MinIO behind the `dev` compose profile, and a profile
|
|
184
|
+
// service does NOT start on a plain `compose up`. Rendering the bucket into
|
|
185
|
+
// the hub's config while never starting the bucket is the shape of failure
|
|
186
|
+
// that reports "storage configured" and then cannot store anything.
|
|
187
|
+
const composeArgs = config.s3?.dev
|
|
188
|
+
? ['compose', '--profile', 'dev', 'up', '-d']
|
|
189
|
+
: ['compose', 'up', '-d'];
|
|
190
|
+
const up = await sh('docker', composeArgs, { cwd: outDir });
|
|
173
191
|
if (up.code !== 0) {
|
|
174
192
|
process.stderr.write(`docker compose up failed:\n${up.stderr}\n`);
|
|
175
193
|
process.exit(1);
|
|
176
194
|
}
|
|
177
195
|
|
|
196
|
+
// `compose up -d` returns when the containers are STARTED, not when they are
|
|
197
|
+
// SERVING. Verifying immediately reported "the workspace answers — ✗
|
|
198
|
+
// unreachable" against a stack that was healthy three seconds later: a false
|
|
199
|
+
// failure, which corrodes trust in the suite exactly as fast as a false pass.
|
|
200
|
+
// Bounded, and it gives up rather than waiting forever — a stack that never
|
|
201
|
+
// comes up must still be reported.
|
|
202
|
+
await waitForHealth(workspaceBaseUrl(config));
|
|
203
|
+
|
|
178
204
|
// Verification is the deliverable. A URL printed without a proven round-trip
|
|
179
205
|
// tells the operator something this command does not know.
|
|
180
206
|
process.stdout.write('\nVerifying — this is the part that matters:\n');
|
|
@@ -202,7 +228,7 @@ export async function run({ args, pkgRoot }) {
|
|
|
202
228
|
printDuties(duties);
|
|
203
229
|
process.stdout.write(
|
|
204
230
|
failed === 0
|
|
205
|
-
? `\nWorkspace verified:
|
|
231
|
+
? `\nWorkspace verified: ${workspaceBaseUrl(config)}\n`
|
|
206
232
|
: `\n${failed} check(s) did NOT pass. The stack is running but is not proven — fix and re-run.\n`
|
|
207
233
|
);
|
|
208
234
|
}
|
|
@@ -260,6 +286,29 @@ function sh(cmd, args, opts = {}) {
|
|
|
260
286
|
});
|
|
261
287
|
}
|
|
262
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Poll `/health` until the stack is serving, or give up.
|
|
291
|
+
*
|
|
292
|
+
* Deliberately silent on success and NEVER fatal: if the wait expires, the
|
|
293
|
+
* verification steps run anyway and report the real failure. This function
|
|
294
|
+
* removes a timing artefact; it must not become a second place that decides
|
|
295
|
+
* whether the deployment is good.
|
|
296
|
+
*/
|
|
297
|
+
async function waitForHealth(base, { timeoutMs = 45_000, intervalMs = 1_000 } = {}) {
|
|
298
|
+
const deadline = Date.now() + timeoutMs;
|
|
299
|
+
let announced = false;
|
|
300
|
+
while (Date.now() < deadline) {
|
|
301
|
+
const res = await tryFetch(`${base}/health`);
|
|
302
|
+
if (res.ok) return true;
|
|
303
|
+
if (!announced) {
|
|
304
|
+
process.stdout.write('Waiting for the stack to answer…\n');
|
|
305
|
+
announced = true;
|
|
306
|
+
}
|
|
307
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
263
312
|
async function which(bin) {
|
|
264
313
|
const res = await sh(process.platform === 'win32' ? 'where' : 'which', [bin]);
|
|
265
314
|
return res.code === 0;
|
|
@@ -272,8 +321,8 @@ async function which(bin) {
|
|
|
272
321
|
* NEVER reports success. Counting an unrun check as passed is the single
|
|
273
322
|
* fastest way to make a verification suite worthless.
|
|
274
323
|
*/
|
|
275
|
-
async function runVerification(step, { config, hubSecret }) {
|
|
276
|
-
const base =
|
|
324
|
+
async function runVerification(step, { config, hubSecret, adminPassword, outDir, pkgRoot }) {
|
|
325
|
+
const base = workspaceBaseUrl(config);
|
|
277
326
|
switch (step.id) {
|
|
278
327
|
case 'health': {
|
|
279
328
|
const res = await tryFetch(`${base}/health`);
|
|
@@ -285,6 +334,16 @@ async function runVerification(step, { config, hubSecret }) {
|
|
|
285
334
|
});
|
|
286
335
|
return res.ok ? { ok: true } : { ok: false, note: res.note };
|
|
287
336
|
}
|
|
337
|
+
case 'user-signin':
|
|
338
|
+
return verifySignin(base, config.adminEmail, adminPassword);
|
|
339
|
+
case 'git-commit':
|
|
340
|
+
return verifyGitHistory(outDir);
|
|
341
|
+
case 's3-object':
|
|
342
|
+
return verifyBucketRoundTrip(config, pkgRoot);
|
|
343
|
+
case 's3-no-expiry':
|
|
344
|
+
return verifyNoLifecycle(config, pkgRoot);
|
|
345
|
+
case 'restore-drill':
|
|
346
|
+
return verifyRestoreDrill(config);
|
|
288
347
|
default:
|
|
289
348
|
return {
|
|
290
349
|
ok: false,
|
|
@@ -294,6 +353,234 @@ async function runVerification(step, { config, hubSecret }) {
|
|
|
294
353
|
}
|
|
295
354
|
}
|
|
296
355
|
|
|
356
|
+
/**
|
|
357
|
+
* The credential the operator is about to be handed actually works.
|
|
358
|
+
*
|
|
359
|
+
* This is the check that would have caught the shipped bug where a provisioned
|
|
360
|
+
* workspace had no users at all: every other check passed, the URL printed,
|
|
361
|
+
* and the first person to try the login was the one who found out.
|
|
362
|
+
*/
|
|
363
|
+
async function verifySignin(base, email, password) {
|
|
364
|
+
if (!password) return { ok: false, note: 'no admin password was generated' };
|
|
365
|
+
try {
|
|
366
|
+
const res = await fetch(`${base}/auth/login`, {
|
|
367
|
+
method: 'POST',
|
|
368
|
+
headers: { 'Content-Type': 'application/json' },
|
|
369
|
+
body: JSON.stringify({ email, password }),
|
|
370
|
+
signal: AbortSignal.timeout(10_000),
|
|
371
|
+
});
|
|
372
|
+
if (!res.ok) return { ok: false, note: `HTTP ${res.status} — the first user cannot sign in` };
|
|
373
|
+
const body = await res.json().catch(() => null);
|
|
374
|
+
return body?.token ? { ok: true } : { ok: false, note: 'login returned no session token' };
|
|
375
|
+
} catch (err) {
|
|
376
|
+
return { ok: false, note: err.name === 'TimeoutError' ? 'timed out' : 'unreachable' };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The server-side checkout has real commits (Cloud Phase 16).
|
|
382
|
+
*
|
|
383
|
+
* Read from INSIDE the container, because that is where the history lives.
|
|
384
|
+
* Checking a path on the operator's laptop would pass on a machine that
|
|
385
|
+
* happens to have a repo and tell us nothing about the deployment.
|
|
386
|
+
*/
|
|
387
|
+
async function verifyGitHistory(outDir) {
|
|
388
|
+
const probe = await sh(
|
|
389
|
+
'docker',
|
|
390
|
+
['compose', 'exec', '-T', 'hub', 'git', '-C', '/repo', 'log', '-1', '--format=%H %an'],
|
|
391
|
+
{ cwd: outDir }
|
|
392
|
+
);
|
|
393
|
+
if (probe.code !== 0) {
|
|
394
|
+
const err = `${probe.stderr}`.toLowerCase();
|
|
395
|
+
// A fresh workspace has no commits because nobody has edited anything yet.
|
|
396
|
+
// That is the NORMAL state five seconds after provisioning, and reporting
|
|
397
|
+
// it as a failure trains the operator to ignore a red mark — which is
|
|
398
|
+
// precisely what makes a real one invisible later. Skipped says the truth:
|
|
399
|
+
// this was not proven, and here is what would prove it.
|
|
400
|
+
if (err.includes('does not have any commits') || err.includes('bad default revision')) {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
skipped: true,
|
|
404
|
+
note: 'the checkout is ready but empty — edit a canvas, then re-run to prove autosave commits',
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (err.includes('not a git repository')) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
note: 'the workspace has no checkout — server-side history is not running',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
if (err.includes('executable file not found') || err.includes('not found')) {
|
|
414
|
+
return { ok: false, note: 'git is missing from the hub image — history cannot be kept' };
|
|
415
|
+
}
|
|
416
|
+
return { ok: false, note: `git log failed: ${probe.stderr.trim().slice(0, 120)}` };
|
|
417
|
+
}
|
|
418
|
+
const line = probe.stdout.trim();
|
|
419
|
+
if (!line) {
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
skipped: true,
|
|
423
|
+
note: 'the checkout is ready but empty — edit a canvas, then re-run',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
return { ok: true, note: `HEAD by ${line.split(' ').slice(1).join(' ') || 'unknown'}` };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Load the hub's S3 client from the installed package. */
|
|
430
|
+
async function loadS3(pkgRoot) {
|
|
431
|
+
for (const candidate of [
|
|
432
|
+
resolve(pkgRoot, 'apps/hub/src/s3.mjs'),
|
|
433
|
+
resolve(pkgRoot, '../apps/hub/src/s3.mjs'),
|
|
434
|
+
]) {
|
|
435
|
+
const mod = await import(`file://${candidate}`).catch(() => null);
|
|
436
|
+
if (mod) return mod;
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function s3ConfigFrom(config) {
|
|
442
|
+
const s = config.s3;
|
|
443
|
+
// The dev MinIO endpoint (`http://minio:9000`) is a compose-network name.
|
|
444
|
+
// These checks run on the OPERATOR's machine, which cannot resolve it — but
|
|
445
|
+
// the compose file publishes the port, so loopback is the same bucket.
|
|
446
|
+
const endpoint = s.dev ? s.endpoint.replace('//minio:', '//127.0.0.1:') : s.endpoint;
|
|
447
|
+
return {
|
|
448
|
+
endpoint,
|
|
449
|
+
bucket: s.bucket,
|
|
450
|
+
accessKeyId: s.accessKeyId,
|
|
451
|
+
secretAccessKey: s.secretAccessKey,
|
|
452
|
+
region: s.region ?? 'auto',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* A real object goes into the bucket and comes back out.
|
|
458
|
+
*
|
|
459
|
+
* Content-addressed, so the sentinel is indistinguishable from a genuine
|
|
460
|
+
* asset — and it is removed afterwards, because a verification step that
|
|
461
|
+
* litters a customer's bucket is a verification step people turn off.
|
|
462
|
+
*/
|
|
463
|
+
async function verifyBucketRoundTrip(config, pkgRoot) {
|
|
464
|
+
if (!config.s3) return { ok: false, skipped: true, note: 'no object storage configured' };
|
|
465
|
+
const s3 = await loadS3(pkgRoot);
|
|
466
|
+
if (!s3)
|
|
467
|
+
return { ok: false, skipped: true, note: 'the hub S3 client was not found in this install' };
|
|
468
|
+
const cfg = s3ConfigFrom(config);
|
|
469
|
+
const bytes = Buffer.from(`maude workspace-up sentinel\n`);
|
|
470
|
+
const key = `assets/${createHash('sha256').update(bytes).digest('hex').slice(0, 8)}.bin`;
|
|
471
|
+
try {
|
|
472
|
+
// The stack was declared healthy by the HUB's health check; object storage
|
|
473
|
+
// is a different container and may still be starting. A one-shot attempt
|
|
474
|
+
// here reported "fetch failed" against a MinIO that was serving four
|
|
475
|
+
// seconds later — a false failure, which corrodes the suite as fast as a
|
|
476
|
+
// false pass.
|
|
477
|
+
await retry(() => s3.putObject(cfg, key, bytes), { attempts: 6, delayMs: 2000 });
|
|
478
|
+
const head = await s3.headObject(cfg, key);
|
|
479
|
+
if (!head) return { ok: false, note: 'the object was written but could not be read back' };
|
|
480
|
+
if (head.size !== bytes.length) {
|
|
481
|
+
return { ok: false, note: `read back ${head.size} bytes, wrote ${bytes.length}` };
|
|
482
|
+
}
|
|
483
|
+
return { ok: true };
|
|
484
|
+
} catch (err) {
|
|
485
|
+
if (/NoSuchBucket/i.test(err.message)) {
|
|
486
|
+
return {
|
|
487
|
+
ok: false,
|
|
488
|
+
note: `the bucket "${cfg.bucket}" does not exist at ${cfg.endpoint} — create it, then re-run`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (/InvalidAccessKeyId|SignatureDoesNotMatch/i.test(err.message)) {
|
|
492
|
+
return { ok: false, note: 'object storage rejected the credentials' };
|
|
493
|
+
}
|
|
494
|
+
return { ok: false, note: `bucket rejected the round trip: ${err.message.slice(0, 120)}` };
|
|
495
|
+
} finally {
|
|
496
|
+
await s3.deleteObject(s3ConfigFrom(config), key).catch(() => {});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* No lifecycle rule can expire the media.
|
|
502
|
+
*
|
|
503
|
+
* The quiet catastrophe this guards: assets are content-addressed and
|
|
504
|
+
* referenced from git history forever, so an expiry rule deletes objects that
|
|
505
|
+
* canvases still point at, with no recovery path and no error at the time.
|
|
506
|
+
*
|
|
507
|
+
* A bucket with NO lifecycle configuration answers 404 — that is the pass.
|
|
508
|
+
*/
|
|
509
|
+
async function verifyNoLifecycle(config, pkgRoot) {
|
|
510
|
+
if (!config.s3) return { ok: false, skipped: true, note: 'no object storage configured' };
|
|
511
|
+
const s3 = await loadS3(pkgRoot);
|
|
512
|
+
if (!s3?.signRequest) {
|
|
513
|
+
return { ok: false, skipped: true, note: 'the hub S3 client was not found in this install' };
|
|
514
|
+
}
|
|
515
|
+
const cfg = s3ConfigFrom(config);
|
|
516
|
+
try {
|
|
517
|
+
const signed = s3.signRequest(cfg, { method: 'GET', key: '', query: { lifecycle: '' } });
|
|
518
|
+
const res = await fetch(signed.url, {
|
|
519
|
+
method: 'GET',
|
|
520
|
+
headers: signed.headers,
|
|
521
|
+
signal: AbortSignal.timeout(15_000),
|
|
522
|
+
});
|
|
523
|
+
if (res.status === 404) return { ok: true, note: 'no lifecycle configuration' };
|
|
524
|
+
if (!res.ok) {
|
|
525
|
+
// Cannot read the config ⇒ cannot claim it is safe. Skipped, never passed.
|
|
526
|
+
return {
|
|
527
|
+
ok: false,
|
|
528
|
+
skipped: true,
|
|
529
|
+
note: `could not read lifecycle config (HTTP ${res.status})`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const xml = await res.text();
|
|
533
|
+
const rules = xml.match(/<Rule>/g)?.length ?? 0;
|
|
534
|
+
if (rules === 0) return { ok: true, note: 'no lifecycle rules' };
|
|
535
|
+
// Any rule at all is reported. Deciding which prefixes a rule matches from
|
|
536
|
+
// its XML is exactly the kind of parsing that is wrong in the one case
|
|
537
|
+
// that matters, so this reports rather than adjudicates.
|
|
538
|
+
return {
|
|
539
|
+
ok: false,
|
|
540
|
+
note: `${rules} lifecycle rule(s) on this bucket — confirm none can expire assets/`,
|
|
541
|
+
};
|
|
542
|
+
} catch (err) {
|
|
543
|
+
return {
|
|
544
|
+
ok: false,
|
|
545
|
+
skipped: true,
|
|
546
|
+
note: `lifecycle check failed: ${err.message.slice(0, 100)}`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** A backup nobody has restored is a hypothesis. Runs the real drill. */
|
|
552
|
+
async function verifyRestoreDrill(config) {
|
|
553
|
+
if (!config.s3) return { ok: false, skipped: true, note: 'no backup target configured' };
|
|
554
|
+
// The drill needs the hub's own data dir, which lives inside the container.
|
|
555
|
+
// Deliberately left to the operator's `maude hub restore-drill` rather than
|
|
556
|
+
// reaching into a volume from out here: a half-run drill that reports
|
|
557
|
+
// success is worse than an honest skip, and this is the one check whose
|
|
558
|
+
// whole point is that somebody actually did it.
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
skipped: true,
|
|
562
|
+
note: 'run `maude hub restore-drill` against this deployment — it needs the hub data dir',
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Retry a flaky-at-startup operation. Rethrows the LAST error, so the
|
|
567
|
+
* reported cause is the real one rather than "timed out". */
|
|
568
|
+
async function retry(fn, { attempts, delayMs }) {
|
|
569
|
+
let last;
|
|
570
|
+
for (let i = 0; i < attempts; i++) {
|
|
571
|
+
try {
|
|
572
|
+
return await fn();
|
|
573
|
+
} catch (err) {
|
|
574
|
+
last = err;
|
|
575
|
+
// A definitive answer from the service is not worth retrying — only the
|
|
576
|
+
// "not listening yet" shape is.
|
|
577
|
+
if (!/fetch failed|ECONNREFUSED|socket hang up/i.test(err.message)) throw err;
|
|
578
|
+
if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
throw last;
|
|
582
|
+
}
|
|
583
|
+
|
|
297
584
|
async function tryFetch(url, init) {
|
|
298
585
|
try {
|
|
299
586
|
const ctrl = new AbortController();
|
|
@@ -312,7 +599,7 @@ async function tryFetch(url, init) {
|
|
|
312
599
|
function printDryRun({ config, outDir, files, plan, duties, reusedSecret }) {
|
|
313
600
|
process.stdout.write(
|
|
314
601
|
`maude hub workspace-up — DRY RUN, nothing was written\n\n` +
|
|
315
|
-
` workspace
|
|
602
|
+
` workspace ${workspaceBaseUrl(config)}${config.local ? ' (LOCAL — plain HTTP)' : ''}\n` +
|
|
316
603
|
` first user ${config.adminEmail}\n` +
|
|
317
604
|
` storage ${config.s3 ? `${config.s3.bucket} @ ${config.s3.endpoint}${config.s3.dev ? ' (dev MinIO)' : ''}` : 'none — media stays in git'}\n` +
|
|
318
605
|
` project ${config.seedRepo ?? 'starts fresh'}\n` +
|