@1agh/maude 0.49.2 → 0.51.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.
Files changed (39) hide show
  1. package/apps/studio/api.ts +751 -52
  2. package/apps/studio/canvas-artifacts.ts +153 -0
  3. package/apps/studio/canvas-create.ts +23 -0
  4. package/apps/studio/canvas-slug.ts +30 -0
  5. package/apps/studio/client/app.jsx +547 -90
  6. package/apps/studio/client/panels/CloudBar.jsx +388 -0
  7. package/apps/studio/client/styles/3-shell-maude.css +28 -0
  8. package/apps/studio/client/tree-row-menu.jsx +132 -0
  9. package/apps/studio/client/use-tree-drag.js +120 -0
  10. package/apps/studio/cloud/endpoints.ts +284 -0
  11. package/apps/studio/collab/registry.ts +23 -0
  12. package/apps/studio/debug-bundle.ts +144 -0
  13. package/apps/studio/dist/client.bundle.js +1235 -1228
  14. package/apps/studio/dist/styles.css +1 -1
  15. package/apps/studio/http.ts +205 -1
  16. package/apps/studio/inspect.ts +49 -0
  17. package/apps/studio/server.ts +15 -0
  18. package/apps/studio/sync/workspace-signin.ts +15 -0
  19. package/apps/studio/test/canvas-artifacts.test.ts +125 -0
  20. package/apps/studio/test/canvas-create-api.test.ts +159 -1
  21. package/apps/studio/test/canvas-move-api.test.ts +556 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  23. package/apps/studio/test/cloud-endpoints.test.ts +130 -0
  24. package/apps/studio/test/debug-bundle.test.ts +105 -0
  25. package/apps/studio/test/fs-mkdir-api.test.ts +248 -0
  26. package/apps/studio/test/workspace-signin.test.ts +19 -0
  27. package/apps/studio/whats-new.json +27 -0
  28. package/cli/bin/maude.mjs +1 -0
  29. package/cli/commands/hub-workspace.mjs +294 -7
  30. package/cli/commands/init.mjs +33 -2
  31. package/cli/commands/kg.mjs +129 -4
  32. package/cli/commands/share.mjs +184 -0
  33. package/cli/lib/ddr-to-kgai.mjs +310 -82
  34. package/cli/lib/ddr-to-kgai.test.mjs +20 -0
  35. package/cli/lib/share-plan.mjs +96 -0
  36. package/cli/lib/share-plan.test.mjs +57 -0
  37. package/cli/lib/workspace-plan.mjs +130 -12
  38. package/cli/lib/workspace-plan.test.mjs +160 -0
  39. package/package.json +9 -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 (!DOMAIN_RE.test(cfg.domain))
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) errors.push('acmeEmail is required (Let’s Encrypt expiry notices)');
57
- else if (!EMAIL_RE.test(cfg.acmeEmail))
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: 'public hostname; Caddy fetches a cert for it',
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
- return `# Maude workspace — generated by \`maude hub workspace-up\`.
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
- {$PUBLIC_DOMAIN} {
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 https://${cfg.domain}/health returns ok`,
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.49.2",
3
+ "version": "0.51.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": {
@@ -25,6 +25,7 @@
25
25
  "build:desktop": "PATH=\"$HOME/.cargo/bin:$PATH\" pnpm --filter @maude/desktop tauri build",
26
26
  "test:e2e:desktop:build": "PATH=\"$HOME/.cargo/bin:$PATH\" pnpm --filter @maude/desktop tauri build --debug --config src-tauri/tauri.e2e.conf.json",
27
27
  "test:e2e:desktop": "pnpm --filter @maude/desktop-e2e e2e",
28
+ "test:e2e:desktop:cloud": "pnpm --filter @maude/desktop-e2e e2e:cloud",
28
29
  "test:e2e:desktop:git": "pnpm --filter @maude/desktop-e2e e2e:git",
29
30
  "build": "pnpm -r --if-present --filter '!@maude/desktop' '!@maude/desktop-e2e' run build",
30
31
  "build:binary": "bun run apps/studio/build.ts --release",
@@ -51,13 +52,13 @@
51
52
  "test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
52
53
  },
53
54
  "optionalDependencies": {
54
- "@1agh/maude-darwin-arm64": "0.49.2",
55
- "@1agh/maude-darwin-x64": "0.49.2",
56
- "@1agh/maude-linux-arm64": "0.49.2",
57
- "@1agh/maude-linux-arm64-musl": "0.49.2",
58
- "@1agh/maude-linux-x64": "0.49.2",
59
- "@1agh/maude-linux-x64-musl": "0.49.2",
60
- "@1agh/maude-win32-x64": "0.49.2"
55
+ "@1agh/maude-darwin-arm64": "0.51.0",
56
+ "@1agh/maude-darwin-x64": "0.51.0",
57
+ "@1agh/maude-linux-arm64": "0.51.0",
58
+ "@1agh/maude-linux-arm64-musl": "0.51.0",
59
+ "@1agh/maude-linux-x64": "0.51.0",
60
+ "@1agh/maude-linux-x64-musl": "0.51.0",
61
+ "@1agh/maude-win32-x64": "0.51.0"
61
62
  },
62
63
  "files": [
63
64
  "cli",