@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,57 @@
|
|
|
1
|
+
// Cloud Phase 18 — what gets published, and what must never be.
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
import { isShareable, publishPlan, publishSummary, shareMarker, validProjectId } from './share-plan.mjs';
|
|
7
|
+
|
|
8
|
+
test('SVG is never published, however it is named', () => {
|
|
9
|
+
// Same reason the share origin refuses to SERVE one: an SVG is a document
|
|
10
|
+
// that can carry <script>. Two lists that could disagree would be a hole,
|
|
11
|
+
// so both refuse it and both say why.
|
|
12
|
+
for (const bad of ['logo.svg', 'a/b.SVG', 'x.html', 'x.js', 'x.pdf', 'notes.md', 'x.tsx']) {
|
|
13
|
+
assert.equal(isShareable(bad), false, bad);
|
|
14
|
+
}
|
|
15
|
+
for (const ok of ['a.png', 'a.JPG', 'a.jpeg', 'a.webp', 'a.avif']) {
|
|
16
|
+
assert.equal(isShareable(ok), true, ok);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('a publish cannot climb out of the directory it was pointed at', () => {
|
|
21
|
+
const { uploads, skipped } = publishPlan(
|
|
22
|
+
['../secret.png', '/etc/x.png', 'a/../../b.png', 'ok.png', 'ui/home.png'],
|
|
23
|
+
'acme'
|
|
24
|
+
);
|
|
25
|
+
assert.deepEqual(uploads.map((u) => u.from), ['ok.png', 'ui/home.png']);
|
|
26
|
+
assert.equal(skipped.length, 3);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('every key is scoped to its own project', () => {
|
|
30
|
+
const { uploads } = publishPlan(['home.png'], 'acme');
|
|
31
|
+
assert.equal(uploads[0].key, 'tenants/acme/snapshots/home.png');
|
|
32
|
+
assert.throws(() => publishPlan(['a.png'], '../other'), /invalid project id/);
|
|
33
|
+
assert.throws(() => publishPlan(['a.png'], 'Acme'), /invalid project id/);
|
|
34
|
+
assert.equal(validProjectId('a-b-1'), 'a-b-1');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the marker is what turns sharing on, and defaults closed', () => {
|
|
38
|
+
const on = JSON.parse(shareMarker('acme', { enabled: true, name: 'Acme Co' }).body);
|
|
39
|
+
assert.deepEqual(on, { enabled: true, name: 'Acme Co' });
|
|
40
|
+
// Anything that is not exactly true is off. The share view treats a missing
|
|
41
|
+
// or unparseable marker the same way, so every failure mode is closed.
|
|
42
|
+
const off = JSON.parse(shareMarker('acme', { enabled: false }).body);
|
|
43
|
+
assert.equal(off.enabled, false);
|
|
44
|
+
assert.equal(off.name, 'acme');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the summary says what a shared view is NOT', () => {
|
|
48
|
+
const out = publishSummary({ project: 'acme', uploaded: 3, skipped: 0 });
|
|
49
|
+
assert.match(out, /https:\/\/view-acme\.cloud\.maude\.sh/);
|
|
50
|
+
// The single most likely misunderstanding is that this is live.
|
|
51
|
+
assert.match(out, /do not update\s*\n?themselves/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a skipped file is explained, not silently dropped', () => {
|
|
55
|
+
const out = publishSummary({ project: 'acme', uploaded: 1, skipped: 2 });
|
|
56
|
+
assert.match(out, /SVG is excluded on purpose/);
|
|
57
|
+
});
|
|
@@ -42,19 +42,55 @@ export function validateWorkspaceConfig(raw = {}) {
|
|
|
42
42
|
const errors = [];
|
|
43
43
|
const cfg = { ...raw };
|
|
44
44
|
|
|
45
|
+
// Local mode serves plain HTTP and never talks to Let's Encrypt, so an ACME
|
|
46
|
+
// contact is meaningless there. Requiring one anyway is how a verification
|
|
47
|
+
// suite ends up needing a purchased domain before it can run even once.
|
|
48
|
+
cfg.local = raw.local === true;
|
|
49
|
+
|
|
45
50
|
cfg.domain = String(raw.domain ?? '')
|
|
46
51
|
.trim()
|
|
47
52
|
.toLowerCase()
|
|
48
53
|
.replace(/^https?:\/\//, '')
|
|
49
54
|
.replace(/\/.*$/, '');
|
|
55
|
+
|
|
56
|
+
// A local workspace defaults to `localhost` and MUST stay a genuine loopback
|
|
57
|
+
// NAME, not merely a name that happens to resolve to 127.0.0.1.
|
|
58
|
+
//
|
|
59
|
+
// The first cut used `ws.127.0.0.1.nip.io` to satisfy the fully-qualified
|
|
60
|
+
// rule. It resolved fine and the whole stack came up — and then the studio's
|
|
61
|
+
// sync agent refused to connect, because its plaintext guard allowlists
|
|
62
|
+
// loopback names literally. That guard is RIGHT: `127.0.0.1.nip.io` is a
|
|
63
|
+
// name somebody else registers and controls, so "it resolves to loopback
|
|
64
|
+
// today" is a promise a third party can withdraw. Pattern-matching an
|
|
65
|
+
// embedded IP would have opened exactly that hole (`127.0.0.1.evil.com`).
|
|
66
|
+
//
|
|
67
|
+
// So the fix belongs here, not in the guard: local mode uses names that ARE
|
|
68
|
+
// loopback by specification — `localhost` and, per RFC 6761, anything under
|
|
69
|
+
// `.localhost`.
|
|
70
|
+
if (cfg.local && !cfg.domain) cfg.domain = 'localhost';
|
|
71
|
+
|
|
72
|
+
const isLoopbackName = cfg.domain === 'localhost' || cfg.domain.endsWith('.localhost');
|
|
50
73
|
if (!cfg.domain) errors.push('domain is required (the public hostname, e.g. design.acme.com)');
|
|
51
|
-
else if (
|
|
74
|
+
else if (cfg.local && isLoopbackName) {
|
|
75
|
+
// `localhost` has no dot, and that is the point — skip the FQDN rule.
|
|
76
|
+
} else if (!DOMAIN_RE.test(cfg.domain))
|
|
52
77
|
errors.push(`domain "${cfg.domain}" is not a valid hostname`);
|
|
53
78
|
else if (!cfg.domain.includes('.')) errors.push('domain must be fully qualified');
|
|
54
79
|
|
|
80
|
+
// A name that only LOOKS local is the trap: it passes every check here and
|
|
81
|
+
// then fails at the one place that matters, after the operator has already
|
|
82
|
+
// been told the workspace is up.
|
|
83
|
+
if (cfg.local && !isLoopbackName) {
|
|
84
|
+
errors.push(
|
|
85
|
+
`--local requires a loopback name (localhost or *.localhost); "${cfg.domain}" is not one — ` +
|
|
86
|
+
'a name that merely resolves to 127.0.0.1 is controlled by whoever owns the domain'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
55
90
|
cfg.acmeEmail = String(raw.acmeEmail ?? '').trim();
|
|
56
|
-
if (!cfg.acmeEmail)
|
|
57
|
-
|
|
91
|
+
if (!cfg.acmeEmail) {
|
|
92
|
+
if (!cfg.local) errors.push('acmeEmail is required (Let’s Encrypt expiry notices)');
|
|
93
|
+
} else if (!EMAIL_RE.test(cfg.acmeEmail))
|
|
58
94
|
errors.push(`acmeEmail "${cfg.acmeEmail}" is not an email`);
|
|
59
95
|
|
|
60
96
|
cfg.adminEmail = String(raw.adminEmail ?? '')
|
|
@@ -115,6 +151,18 @@ export function validateWorkspaceConfig(raw = {}) {
|
|
|
115
151
|
return { ok: errors.length === 0, errors, config: cfg };
|
|
116
152
|
}
|
|
117
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The URL the workspace is reachable at.
|
|
156
|
+
*
|
|
157
|
+
* ONE definition, used by the compose render, the verification steps and every
|
|
158
|
+
* message printed to the operator — a local run that verified `https://` while
|
|
159
|
+
* Caddy served `http://` would fail every check for a reason that has nothing
|
|
160
|
+
* to do with the workspace.
|
|
161
|
+
*/
|
|
162
|
+
export function workspaceBaseUrl(cfg) {
|
|
163
|
+
return `${cfg.local ? 'http' : 'https'}://${cfg.domain}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
118
166
|
/**
|
|
119
167
|
* The `.env` a workspace deployment needs. Returned as an ordered array of
|
|
120
168
|
* `{ key, value, comment }` so the renderer can annotate and a test can assert
|
|
@@ -129,9 +177,15 @@ export function envEntries(cfg, { hubSecret, adminPassword }) {
|
|
|
129
177
|
{
|
|
130
178
|
key: 'PUBLIC_DOMAIN',
|
|
131
179
|
value: cfg.domain,
|
|
132
|
-
comment:
|
|
180
|
+
comment: cfg.local
|
|
181
|
+
? 'hostname Caddy matches on; LOCAL MODE — plain HTTP, no certificate'
|
|
182
|
+
: 'public hostname; Caddy fetches a cert for it',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
key: 'ACME_EMAIL',
|
|
186
|
+
value: cfg.acmeEmail,
|
|
187
|
+
comment: cfg.local ? 'unused in local mode' : "Let's Encrypt expiry notices",
|
|
133
188
|
},
|
|
134
|
-
{ key: 'ACME_EMAIL', value: cfg.acmeEmail, comment: "Let's Encrypt expiry notices" },
|
|
135
189
|
{
|
|
136
190
|
key: 'HUB_SECRET',
|
|
137
191
|
value: hubSecret,
|
|
@@ -143,6 +197,17 @@ export function envEntries(cfg, { hubSecret, adminPassword }) {
|
|
|
143
197
|
comment: 'Caddy fronts the hub; without this every client shares one rate-limit bucket',
|
|
144
198
|
},
|
|
145
199
|
{ key: 'HUB_WORKSPACE_MODE', value: '1', comment: 'users required; permissive dev auth off' },
|
|
200
|
+
...(cfg.local
|
|
201
|
+
? [
|
|
202
|
+
{
|
|
203
|
+
key: 'HUB_INSECURE_HTTP',
|
|
204
|
+
value: '1',
|
|
205
|
+
comment:
|
|
206
|
+
'LOCAL TESTING ONLY — lifts the hub’s refusal to serve over plaintext. ' +
|
|
207
|
+
'Delete this line before this deployment is reachable by anyone else.',
|
|
208
|
+
},
|
|
209
|
+
]
|
|
210
|
+
: []),
|
|
146
211
|
{
|
|
147
212
|
key: 'MAUDE_WORKSPACE_MODE',
|
|
148
213
|
value: '1',
|
|
@@ -209,8 +274,13 @@ export function renderCompose(cfg) {
|
|
|
209
274
|
'HUB_SECRET',
|
|
210
275
|
'HUB_TRUSTED_PROXIES',
|
|
211
276
|
'HUB_WORKSPACE_MODE',
|
|
277
|
+
...(cfg.local ? ['HUB_INSECURE_HTTP'] : []),
|
|
212
278
|
'MAUDE_WORKSPACE_MODE',
|
|
213
279
|
'MAUDE_ADMIN_EMAIL',
|
|
280
|
+
// The password has to cross into the container too, or the hub knows WHO
|
|
281
|
+
// the first user is and has no way to create them — which is precisely the
|
|
282
|
+
// half-wired state that shipped: `.env` held both, compose forwarded one.
|
|
283
|
+
'MAUDE_ADMIN_PASSWORD',
|
|
214
284
|
...(cfg.s3
|
|
215
285
|
? [
|
|
216
286
|
'MAUDE_S3_ENDPOINT',
|
|
@@ -238,10 +308,19 @@ services:
|
|
|
238
308
|
image: ghcr.io/1agh/maude-hub:\${MAUDE_IMAGE_TAG:-latest}
|
|
239
309
|
restart: unless-stopped
|
|
240
310
|
environment:
|
|
241
|
-
HUB_PUBLIC_URL: https://\${PUBLIC_DOMAIN}
|
|
311
|
+
HUB_PUBLIC_URL: ${cfg.local ? 'http' : 'https'}://\${PUBLIC_DOMAIN}
|
|
312
|
+
# The server-side checkout (Cloud Phase 16). In a workspace there is
|
|
313
|
+
# nobody at a keyboard to commit, so the hub keeps the history itself —
|
|
314
|
+
# without this the project's only record is its current bytes.
|
|
315
|
+
MAUDE_REPO_DIR: /repo
|
|
242
316
|
${envLines(hubEnv)}
|
|
243
317
|
volumes:
|
|
244
318
|
- hub-data:/data
|
|
319
|
+
# A SEPARATE volume from hub-data on purpose: the checkout is the one
|
|
320
|
+
# thing here that can be reconstructed from a mirror, and keeping it
|
|
321
|
+
# separable is what lets an operator reset it without touching the
|
|
322
|
+
# documents, or vice versa.
|
|
323
|
+
- hub-repo:/repo
|
|
245
324
|
expose:
|
|
246
325
|
- "1234"
|
|
247
326
|
|
|
@@ -249,8 +328,7 @@ ${envLines(hubEnv)}
|
|
|
249
328
|
image: caddy:2-alpine
|
|
250
329
|
restart: unless-stopped
|
|
251
330
|
ports:
|
|
252
|
-
- "80:80"
|
|
253
|
-
- "443:443"
|
|
331
|
+
- "80:80"${cfg.local ? '' : '\n - "443:443"'}
|
|
254
332
|
environment:
|
|
255
333
|
PUBLIC_DOMAIN: \${PUBLIC_DOMAIN}
|
|
256
334
|
ACME_EMAIL: \${ACME_EMAIL}
|
|
@@ -278,11 +356,37 @@ ${
|
|
|
278
356
|
ports:
|
|
279
357
|
- "9000:9000"
|
|
280
358
|
- "9001:9001"
|
|
359
|
+
healthcheck:
|
|
360
|
+
test: ["CMD", "mc", "ready", "local"]
|
|
361
|
+
interval: 3s
|
|
362
|
+
timeout: 3s
|
|
363
|
+
retries: 20
|
|
364
|
+
|
|
365
|
+
# Creates the bucket the hub was just told to use. Without it, --dev-minio
|
|
366
|
+
# renders a complete storage configuration pointing at a bucket that does not
|
|
367
|
+
# exist — every write fails with NoSuchBucket, and the operator sees a
|
|
368
|
+
# verification failure with no hint that the missing piece was never theirs
|
|
369
|
+
# to supply. Same "looks configured, does nothing" shape as the admin
|
|
370
|
+
# credentials and the seed repo before Cloud Phase 16 wired them.
|
|
371
|
+
minio-init:
|
|
372
|
+
profiles: ["dev"]
|
|
373
|
+
image: minio/mc:latest
|
|
374
|
+
depends_on:
|
|
375
|
+
minio:
|
|
376
|
+
condition: service_healthy
|
|
377
|
+
entrypoint: >
|
|
378
|
+
/bin/sh -c "
|
|
379
|
+
mc alias set local http://minio:9000 \${MAUDE_S3_ACCESS_KEY_ID} \${MAUDE_S3_SECRET_ACCESS_KEY} &&
|
|
380
|
+
mc mb --ignore-existing local/\${MAUDE_S3_BUCKET} &&
|
|
381
|
+
echo 'bucket \${MAUDE_S3_BUCKET} ready'
|
|
382
|
+
"
|
|
383
|
+
restart: "no"
|
|
281
384
|
`
|
|
282
385
|
: ''
|
|
283
386
|
}
|
|
284
387
|
volumes:
|
|
285
388
|
hub-data:
|
|
389
|
+
hub-repo:
|
|
286
390
|
caddy-data:
|
|
287
391
|
caddy-config:${cfg.devMinio ? '\n minio-data:' : ''}
|
|
288
392
|
`;
|
|
@@ -290,13 +394,27 @@ volumes:
|
|
|
290
394
|
|
|
291
395
|
/** Caddyfile with `trusted_proxies` wired, so XFF is honoured correctly. */
|
|
292
396
|
export function renderCaddyfile(cfg) {
|
|
293
|
-
|
|
294
|
-
|
|
397
|
+
// LOCAL MODE: an explicit `http://` site address turns Caddy's automatic
|
|
398
|
+
// HTTPS off entirely. The alternative — `tls internal` — mints a cert from a
|
|
399
|
+
// CA nothing on the machine trusts, so every verification step would fail on
|
|
400
|
+
// certificate validation rather than on anything about the workspace.
|
|
401
|
+
//
|
|
402
|
+
// This is a TESTING mode and the file says so, because a Caddyfile serving a
|
|
403
|
+
// real workspace over plain HTTP would put every password on the wire.
|
|
404
|
+
const site = cfg.local ? 'http://{$PUBLIC_DOMAIN}' : '{$PUBLIC_DOMAIN}';
|
|
405
|
+
const header = cfg.local
|
|
406
|
+
? `# LOCAL TESTING ONLY — plain HTTP, no certificate. Never serve a real
|
|
407
|
+
# workspace from this file: sign-in passwords would travel in the clear.
|
|
408
|
+
`
|
|
409
|
+
: `
|
|
295
410
|
{
|
|
296
411
|
email {$ACME_EMAIL}
|
|
297
412
|
}
|
|
413
|
+
`;
|
|
298
414
|
|
|
299
|
-
|
|
415
|
+
return `# Maude workspace — generated by \`maude hub workspace-up\`.
|
|
416
|
+
${header}
|
|
417
|
+
${site} {
|
|
300
418
|
encode zstd gzip
|
|
301
419
|
|
|
302
420
|
# The hub reads X-Forwarded-For only from addresses it trusts
|
|
@@ -324,7 +442,7 @@ export function verificationPlan(cfg) {
|
|
|
324
442
|
{
|
|
325
443
|
id: 'health',
|
|
326
444
|
title: 'the workspace answers',
|
|
327
|
-
detail: `GET
|
|
445
|
+
detail: `GET ${workspaceBaseUrl(cfg)}/health returns ok`,
|
|
328
446
|
},
|
|
329
447
|
{
|
|
330
448
|
id: 'admin-claimed',
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
renderEnv,
|
|
17
17
|
validateWorkspaceConfig,
|
|
18
18
|
verificationPlan,
|
|
19
|
+
workspaceBaseUrl,
|
|
19
20
|
} from './workspace-plan.mjs';
|
|
20
21
|
|
|
21
22
|
const BASE = {
|
|
@@ -221,3 +222,162 @@ test('object storage adds the never-expire duty; dev MinIO adds its own warning'
|
|
|
221
222
|
.join(' ');
|
|
222
223
|
assert.match(dev, /MinIO credentials are in this directory/);
|
|
223
224
|
});
|
|
225
|
+
|
|
226
|
+
// ------------------------------------------------------- local testing mode
|
|
227
|
+
//
|
|
228
|
+
// Local mode exists because six of the eight verification steps had never run
|
|
229
|
+
// even once: exercising them required a public domain and a certificate, so
|
|
230
|
+
// nobody could test their own workspace before buying one. The risk it adds is
|
|
231
|
+
// obvious — a workspace served over plain HTTP puts sign-in passwords on the
|
|
232
|
+
// wire — so every test below is about keeping that mode unmistakable and
|
|
233
|
+
// impossible to reach by accident.
|
|
234
|
+
|
|
235
|
+
const LOCAL = { ...BASE, domain: 'localhost', local: true };
|
|
236
|
+
|
|
237
|
+
test('local mode drops the certificate, so it must NOT be reachable by default', () => {
|
|
238
|
+
assert.equal(ok(BASE).local, false, 'absent flag is off');
|
|
239
|
+
// A non-boolean must not switch it on — and then the config is a PRODUCTION
|
|
240
|
+
// one, so it needs a production domain.
|
|
241
|
+
assert.equal(ok({ ...BASE, local: 'yes' }).local, false, 'only a real boolean turns it on');
|
|
242
|
+
assert.equal(ok(LOCAL).local, true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('local mode serves http, production serves https — one definition', () => {
|
|
246
|
+
assert.equal(workspaceBaseUrl(ok(BASE)), 'https://design.acme.com');
|
|
247
|
+
assert.equal(workspaceBaseUrl(ok(LOCAL)), 'http://localhost');
|
|
248
|
+
// The verification plan must agree, or a local run fails every check for a
|
|
249
|
+
// reason that has nothing to do with the workspace.
|
|
250
|
+
const step = verificationPlan(ok(LOCAL)).find((s) => s.id === 'health');
|
|
251
|
+
assert.match(step.detail, /http:\/\/localhost/);
|
|
252
|
+
assert.ok(!step.detail.includes('https://'));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('an ACME contact is required for a real deployment and pointless locally', () => {
|
|
256
|
+
const real = validateWorkspaceConfig({ ...BASE, acmeEmail: undefined });
|
|
257
|
+
assert.equal(real.ok, false);
|
|
258
|
+
assert.ok(real.errors.some((e) => /acmeEmail is required/.test(e)));
|
|
259
|
+
|
|
260
|
+
const local = validateWorkspaceConfig({ ...LOCAL, acmeEmail: undefined });
|
|
261
|
+
assert.equal(local.ok, true, 'no certificate is fetched, so no contact is needed');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('the local Caddyfile turns automatic HTTPS off explicitly', () => {
|
|
265
|
+
// `tls internal` would mint a cert from a CA nothing trusts, so every check
|
|
266
|
+
// would fail on certificate validation instead. An `http://` site address is
|
|
267
|
+
// the only form that disables it outright.
|
|
268
|
+
const local = renderCaddyfile(ok(LOCAL));
|
|
269
|
+
assert.match(local, /^http:\/\/\{\$PUBLIC_DOMAIN\} \{/m);
|
|
270
|
+
assert.ok(!local.includes('{$ACME_EMAIL}'), 'no ACME block in local mode');
|
|
271
|
+
assert.match(local, /LOCAL TESTING ONLY/);
|
|
272
|
+
assert.match(local, /passwords would travel in the clear/i);
|
|
273
|
+
|
|
274
|
+
const real = renderCaddyfile(ok(BASE));
|
|
275
|
+
assert.match(real, /^\{\$PUBLIC_DOMAIN\} \{/m, 'production keeps automatic HTTPS');
|
|
276
|
+
assert.ok(!real.includes('LOCAL TESTING ONLY'));
|
|
277
|
+
assert.ok(!real.includes('http://{$PUBLIC_DOMAIN}'));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("the hub's own plaintext refusal is lifted ONLY in local mode", () => {
|
|
281
|
+
// The hub refuses to serve a public URL over plaintext. Local mode has to
|
|
282
|
+
// lift that or the container crash-loops — and it must not lift it anywhere
|
|
283
|
+
// else, because that refusal is the thing protecting real deployments.
|
|
284
|
+
const keys = (cfg) => envEntries(cfg, { hubSecret: 'x', adminPassword: 'y' }).map((e) => e.key);
|
|
285
|
+
assert.ok(!keys(ok(BASE)).includes('HUB_INSECURE_HTTP'));
|
|
286
|
+
assert.ok(keys(ok(LOCAL)).includes('HUB_INSECURE_HTTP'));
|
|
287
|
+
|
|
288
|
+
const entry = envEntries(ok(LOCAL), {
|
|
289
|
+
hubSecret: 'x',
|
|
290
|
+
adminPassword: 'y',
|
|
291
|
+
}).find((e) => e.key === 'HUB_INSECURE_HTTP');
|
|
292
|
+
assert.match(entry.comment, /LOCAL TESTING ONLY/);
|
|
293
|
+
assert.match(entry.comment, /Delete this line/, 'says how to undo it');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('the compose file matches the scheme it is actually serving', () => {
|
|
297
|
+
const local = renderCompose(ok(LOCAL));
|
|
298
|
+
assert.match(local, /HUB_PUBLIC_URL: http:\/\/\$\{PUBLIC_DOMAIN\}/);
|
|
299
|
+
assert.ok(!local.includes('"443:443"'), 'nothing terminates TLS, so nothing binds 443');
|
|
300
|
+
assert.match(local, /HUB_INSECURE_HTTP/, 'and the variable reaches the container');
|
|
301
|
+
|
|
302
|
+
const real = renderCompose(ok(BASE));
|
|
303
|
+
assert.match(real, /HUB_PUBLIC_URL: https:\/\/\$\{PUBLIC_DOMAIN\}/);
|
|
304
|
+
assert.match(real, /"443:443"/);
|
|
305
|
+
assert.ok(!real.includes('HUB_INSECURE_HTTP'));
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('the admin PASSWORD reaches the container, not just the address', () => {
|
|
309
|
+
// The half-wired state that shipped: `.env` held both, compose forwarded
|
|
310
|
+
// only the email, so the hub knew who the first user was and had no way to
|
|
311
|
+
// create them — and `workspace-up` still reported success, because the
|
|
312
|
+
// sign-in check was one of the six that reported `skipped`.
|
|
313
|
+
const compose = renderCompose(ok(BASE));
|
|
314
|
+
assert.match(compose, /MAUDE_ADMIN_EMAIL/);
|
|
315
|
+
assert.match(compose, /MAUDE_ADMIN_PASSWORD/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('local mode defaults to localhost and REFUSES a merely-resolving name', () => {
|
|
319
|
+
// The bug this pins: the first cut used `ws.127.0.0.1.nip.io` to satisfy the
|
|
320
|
+
// fully-qualified rule. Everything came up, and then the studio sync agent
|
|
321
|
+
// refused to connect — its plaintext guard allowlists loopback names
|
|
322
|
+
// literally, and it is right to: `127.0.0.1.nip.io` is registered and
|
|
323
|
+
// controlled by a third party, so "resolves to loopback" is a promise that
|
|
324
|
+
// can be withdrawn. Accepting it here would have pushed the failure to the
|
|
325
|
+
// one place that matters, after the operator was told the workspace was up.
|
|
326
|
+
const defaulted = ok({ ...BASE, domain: undefined, local: true });
|
|
327
|
+
assert.equal(defaulted.domain, 'localhost');
|
|
328
|
+
|
|
329
|
+
const sub = validateWorkspaceConfig({ ...BASE, domain: 'ws.localhost', local: true });
|
|
330
|
+
assert.equal(sub.ok, true, 'RFC 6761 reserves everything under .localhost');
|
|
331
|
+
|
|
332
|
+
const fake = validateWorkspaceConfig({ ...BASE, domain: 'ws.127.0.0.1.nip.io', local: true });
|
|
333
|
+
assert.equal(fake.ok, false);
|
|
334
|
+
assert.ok(fake.errors.some((e) => /loopback name/.test(e)));
|
|
335
|
+
assert.ok(
|
|
336
|
+
fake.errors.some((e) => /controlled by whoever owns the domain/.test(e)),
|
|
337
|
+
'the error says WHY, or the next person picks another nip.io'
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('`localhost` has no dot, and only local mode is allowed to skip the FQDN rule', () => {
|
|
342
|
+
assert.equal(validateWorkspaceConfig({ ...BASE, domain: 'localhost', local: true }).ok, true);
|
|
343
|
+
|
|
344
|
+
const production = validateWorkspaceConfig({ ...BASE, domain: 'localhost' });
|
|
345
|
+
assert.equal(production.ok, false, 'a real deployment still needs a fully-qualified name');
|
|
346
|
+
assert.ok(production.errors.some((e) => /not a valid hostname|fully qualified/.test(e)));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
/* --------------------------------------------- Cloud Phase 16: server-owned git */
|
|
350
|
+
|
|
351
|
+
test('the workspace gets a checkout the hub can actually commit into', () => {
|
|
352
|
+
// Until Phase 16 the hub had no repo at all: autosave ran only in the CLIENT,
|
|
353
|
+
// so a project with no desktop attached kept no history — and "mirror to
|
|
354
|
+
// GitHub" would have been a claim with nothing behind it.
|
|
355
|
+
const compose = renderCompose(ok(BASE));
|
|
356
|
+
assert.match(compose, /MAUDE_REPO_DIR: \/repo/, 'the hub must be told where its checkout is');
|
|
357
|
+
assert.match(
|
|
358
|
+
compose,
|
|
359
|
+
/- hub-repo:\/repo/,
|
|
360
|
+
'the checkout needs a volume, or it dies with the container'
|
|
361
|
+
);
|
|
362
|
+
assert.match(compose, /^volumes:\n(?:.*\n)*? {2}hub-repo:$/m, 'the volume must be declared');
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('the checkout is a SEPARATE volume from the documents', () => {
|
|
366
|
+
// Same volume would mean an operator resetting a corrupt checkout also
|
|
367
|
+
// destroys the documents — and the two have genuinely different recovery
|
|
368
|
+
// stories (a checkout can come back from a mirror; documents cannot).
|
|
369
|
+
const compose = renderCompose(ok(BASE));
|
|
370
|
+
assert.match(compose, /- hub-data:\/data/);
|
|
371
|
+
assert.match(compose, /- hub-repo:\/repo/);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('MAUDE_SEED_REPO crosses into the container when one is configured', () => {
|
|
375
|
+
// The variable existed and was rendered into .env long before anything read
|
|
376
|
+
// it — the same half-wired shape as MAUDE_ADMIN_PASSWORD. Assert the
|
|
377
|
+
// forwarding, because that is the half that rotted.
|
|
378
|
+
const withSeed = renderCompose(ok({ ...BASE, seedRepo: 'https://github.com/acme/design.git' }));
|
|
379
|
+
assert.match(withSeed, /MAUDE_SEED_REPO: \$\{MAUDE_SEED_REPO\}/);
|
|
380
|
+
|
|
381
|
+
const without = renderCompose(ok(BASE));
|
|
382
|
+
assert.ok(!/MAUDE_SEED_REPO/.test(without), 'no seed configured ⇒ no empty variable to misread');
|
|
383
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -51,13 +51,13 @@
|
|
|
51
51
|
"test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
|
|
52
52
|
},
|
|
53
53
|
"optionalDependencies": {
|
|
54
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
55
|
-
"@1agh/maude-darwin-x64": "0.
|
|
56
|
-
"@1agh/maude-linux-arm64": "0.
|
|
57
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
58
|
-
"@1agh/maude-linux-x64": "0.
|
|
59
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
60
|
-
"@1agh/maude-win32-x64": "0.
|
|
54
|
+
"@1agh/maude-darwin-arm64": "0.50.0",
|
|
55
|
+
"@1agh/maude-darwin-x64": "0.50.0",
|
|
56
|
+
"@1agh/maude-linux-arm64": "0.50.0",
|
|
57
|
+
"@1agh/maude-linux-arm64-musl": "0.50.0",
|
|
58
|
+
"@1agh/maude-linux-x64": "0.50.0",
|
|
59
|
+
"@1agh/maude-linux-x64-musl": "0.50.0",
|
|
60
|
+
"@1agh/maude-win32-x64": "0.50.0"
|
|
61
61
|
},
|
|
62
62
|
"files": [
|
|
63
63
|
"cli",
|