@1agh/maude 1.0.3 → 1.0.5

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.
@@ -36,7 +36,10 @@ describe('a canvas build error reaches the person (M8)', () => {
36
36
  });
37
37
 
38
38
  test('the soft-reload holding toast gets the same diagnosis', () => {
39
- const soft = SHELL.slice(SHELL.indexOf('function softReload'), SHELL.indexOf('function softReload') + 1500);
39
+ const soft = SHELL.slice(
40
+ SHELL.indexOf('function softReload'),
41
+ SHELL.indexOf('function softReload') + 1500
42
+ );
40
43
  expect(soft).toContain('diagnoseImportFailure(canvasUrl, err)');
41
44
  });
42
45
 
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "invite-links-open-a-welcome-page",
6
+ "version": "1.0.4",
7
+ "date": "2026-08-21",
8
+ "kind": "fix",
9
+ "title": "Invite links open a welcome page",
10
+ "summary": "Someone you invite to a self-hosted workspace used to open the link and see a screen of raw data. Now they get a welcome page: their email is already filled in, they choose a password, and one click drops them straight into the studio in their browser — no download, no setup. Expired or already-used links say so in plain words. And if they signed in with your identity provider instead, the People view now has Approve: one click creates their account and links that sign-in, no password to invent.",
11
+ "surface": "design-ui"
12
+ },
4
13
  {
5
14
  "id": "export-from-the-cloud-studio",
6
15
  "version": "1.0.1",
@@ -19,11 +19,14 @@ import { resolve } from 'node:path';
19
19
 
20
20
  import { parseArgs } from '../lib/argv.mjs';
21
21
  import {
22
+ classifyRenderImage,
22
23
  envEntries,
24
+ hostContainerPlatform,
23
25
  operatorDuties,
24
26
  renderCaddyfile,
25
27
  renderCompose,
26
28
  renderEnv,
29
+ renderImageRef,
27
30
  safeSeedUrl,
28
31
  validateWorkspaceConfig,
29
32
  verificationPlan,
@@ -64,6 +67,11 @@ export function usage() {
64
67
  export from the hosted studio (DDR-230). Without
65
68
  it those formats say so and point at the desktop
66
69
  app; ZIP export works either way.
70
+ Needs a host architecture the sidecar image is
71
+ published for, and ~3 GB of image plus headroom for
72
+ Chromium. This command checks the published
73
+ architectures before writing anything and refuses
74
+ rather than leaving you an "exec format error".
67
75
  --seed-repo URL clone an existing project; omit to start fresh
68
76
  --image-tag TAG default "latest" — pin it before you rely on this
69
77
  --config FILE read all of the above from a JSON file
@@ -149,6 +157,35 @@ export async function run({ args, pkgRoot }) {
149
157
  process.exit(2);
150
158
  }
151
159
 
160
+ // REFUSE `--render` on a host the sidecar image cannot run on (M10).
161
+ //
162
+ // Checked HERE — before the admin-password gate, before a byte is written —
163
+ // because the whole point is to cost the operator a message rather than a
164
+ // 2.99 GB pull that ends in `exec format error`. Applies to `--dry-run` too:
165
+ // a dry run that describes a stack which cannot start is the bug, not a
166
+ // preview of it.
167
+ if (config.render) {
168
+ const imageRef = renderImageRef(config);
169
+ const verdict = classifyRenderImage({
170
+ imageRef,
171
+ hostPlatform: hostContainerPlatform(),
172
+ probe: await probeImagePlatforms(imageRef),
173
+ });
174
+ if (!verdict.ok) {
175
+ if (flags.json) {
176
+ process.stdout.write(
177
+ `${JSON.stringify({ ok: false, errors: [verdict.message] }, null, 2)}\n`
178
+ );
179
+ } else {
180
+ process.stderr.write(`maude hub workspace-up: ${verdict.message}`);
181
+ }
182
+ process.exit(2);
183
+ }
184
+ // stderr even under --json: a caveat the machine reader will not see is a
185
+ // caveat nobody sees, and stderr never corrupts the JSON on stdout.
186
+ if (verdict.level === 'warn') process.stderr.write(verdict.message);
187
+ }
188
+
152
189
  // REFUSE a new admin password on a re-run, rather than writing one that goes
153
190
  // nowhere.
154
191
  //
@@ -389,6 +426,55 @@ async function which(bin) {
389
426
  return res.code === 0;
390
427
  }
391
428
 
429
+ /**
430
+ * Ask the registry which platforms an image is published for.
431
+ *
432
+ * `docker manifest inspect --verbose` is the form that answers for BOTH shapes:
433
+ * an ARRAY of descriptors for a manifest list, ONE object for a single-platform
434
+ * image — whose `.Descriptor.platform` is the only place its architecture
435
+ * appears at all. The plain (non-verbose) form omits it entirely for the single
436
+ * case, which is precisely why the live AWS run could not tell an amd64-only
437
+ * image from a portable one without pulling 3 GB of it.
438
+ *
439
+ * Never throws and never blocks: anything it cannot establish comes back as
440
+ * `unknown`, and `classifyRenderImage` turns that into a warning.
441
+ */
442
+ async function probeImagePlatforms(imageRef) {
443
+ if (!(await which('docker'))) {
444
+ return { status: 'unknown', platforms: [], note: 'docker is not on PATH' };
445
+ }
446
+ const res = await sh('docker', ['manifest', 'inspect', '--verbose', imageRef]);
447
+ if (res.code !== 0) {
448
+ const err = `${res.stderr}${res.stdout}`;
449
+ if (/manifest unknown|not found|no such manifest|denied/i.test(err)) {
450
+ return { status: 'missing', platforms: [] };
451
+ }
452
+ return { status: 'unknown', platforms: [], note: firstLine(err) };
453
+ }
454
+ try {
455
+ const parsed = JSON.parse(res.stdout);
456
+ const platforms = [
457
+ ...new Set(
458
+ (Array.isArray(parsed) ? parsed : [parsed])
459
+ .map((entry) => entry?.Descriptor?.platform)
460
+ // `unknown/unknown` is how buildx describes an attestation manifest —
461
+ // a sibling of the real images in the list, never a runnable one.
462
+ .filter((p) => p?.os && p?.architecture && p.architecture !== 'unknown')
463
+ .map((p) => `${p.os}/${p.architecture}`)
464
+ ),
465
+ ];
466
+ return platforms.length
467
+ ? { status: 'ok', platforms }
468
+ : { status: 'unknown', platforms: [], note: 'the manifest declared no platform' };
469
+ } catch {
470
+ return { status: 'unknown', platforms: [], note: 'the manifest was not JSON' };
471
+ }
472
+ }
473
+
474
+ function firstLine(text) {
475
+ return String(text).trim().split('\n')[0].slice(0, 200);
476
+ }
477
+
392
478
  /**
393
479
  * Execute one verification step.
394
480
  *
@@ -5,7 +5,7 @@
5
5
 
6
6
  import assert from 'node:assert/strict';
7
7
  import { spawnSync } from 'node:child_process';
8
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { dirname, join, resolve } from 'node:path';
11
11
  import { test } from 'node:test';
@@ -15,8 +15,12 @@ const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude
15
15
 
16
16
  const { classifyDrillFailure } = await import('./hub-workspace.mjs');
17
17
 
18
- function runCli(args, { cwd } = {}) {
19
- return spawnSync(process.execPath, [BIN, ...args], { cwd, encoding: 'utf8' });
18
+ function runCli(args, { cwd, env } = {}) {
19
+ return spawnSync(process.execPath, [BIN, ...args], {
20
+ cwd,
21
+ encoding: 'utf8',
22
+ env: { ...process.env, ...env },
23
+ });
20
24
  }
21
25
 
22
26
  function withDir(fn) {
@@ -169,3 +173,103 @@ test('--local does not warn — localhost IS reachable from the browser that mat
169
173
  assert.ok(!/NOT render in remote browsers/i.test(`${r.stdout}${r.stderr}`));
170
174
  });
171
175
  });
176
+
177
+ // M10 — `--render` on a host the sidecar image is not published for.
178
+ //
179
+ // On the live AWS run the flag was accepted, the compose file was written, the
180
+ // image PULLED (Docker falls back across architectures on pull), and the truth
181
+ // arrived as `exec format error` in a container log — after 2.99 GB. The
182
+ // judgement lives in `classifyRenderImage` (unit-tested next door); what is
183
+ // only true out here is that the command asks BEFORE it writes.
184
+
185
+ /**
186
+ * A `docker` on PATH that answers exactly one question — which platforms an
187
+ * image is published for — and exits non-zero for everything else, so a test
188
+ * that slips past the gate fails loudly instead of trying to boot a stack.
189
+ *
190
+ * `manifest inspect --verbose` is the shape the real probe uses: an ARRAY of
191
+ * descriptors for a manifest list, ONE object for a single-platform image.
192
+ */
193
+ function withFakeDocker({ platforms, missing = false }, fn) {
194
+ const dir = mkdtempSync(join(tmpdir(), 'maude-fake-docker-'));
195
+ let script;
196
+ if (missing) {
197
+ script =
198
+ '#!/bin/sh\ncase "$*" in\n' +
199
+ ' "manifest inspect --verbose "*) echo "manifest unknown" >&2; exit 1 ;;\n' +
200
+ 'esac\nexit 90\n';
201
+ } else {
202
+ const descriptor = (p) => ({
203
+ Ref: 'ghcr.io/1agh/maude-render',
204
+ Descriptor: { platform: { os: p.split('/')[0], architecture: p.split('/')[1] } },
205
+ });
206
+ const body = platforms.length === 1 ? descriptor(platforms[0]) : platforms.map(descriptor);
207
+ script =
208
+ '#!/bin/sh\ncase "$*" in\n' +
209
+ ' "manifest inspect --verbose "*) cat <<\'JSON\'\n' +
210
+ `${JSON.stringify(body, null, 2)}\nJSON\n exit 0 ;;\n` +
211
+ 'esac\nexit 90\n';
212
+ }
213
+ const bin = join(dir, 'docker');
214
+ writeFileSync(bin, script);
215
+ chmodSync(bin, 0o755);
216
+ try {
217
+ return fn({ PATH: `${dir}:${process.env.PATH}` });
218
+ } finally {
219
+ rmSync(dir, { recursive: true, force: true });
220
+ }
221
+ }
222
+
223
+ /** What `hostContainerPlatform()` will report inside the child process. */
224
+ const HOST_PLATFORM = `linux/${{ x64: 'amd64', arm64: 'arm64' }[process.arch] ?? process.arch}`;
225
+
226
+ test('--render on a host the image is not published for refuses, and writes NOTHING', () => {
227
+ withDir((dir) => {
228
+ // s390x so the case holds on any machine this suite runs on.
229
+ const r = withFakeDocker({ platforms: ['linux/s390x'] }, (env) =>
230
+ runCli([...BASE, '--render', '--image-tag', 'v1.0.2'], { cwd: dir, env })
231
+ );
232
+ assert.equal(r.status, 2, `${r.stdout}${r.stderr}`);
233
+ const out = `${r.stdout}${r.stderr}`;
234
+ assert.match(out, /--render cannot work on this machine/);
235
+ assert.match(out, /exec format error/);
236
+ assert.match(out, new RegExp(HOST_PLATFORM.replace('/', '\\/')));
237
+ // The whole point: no compose file naming a container that cannot start.
238
+ assert.ok(!existsSync(join(dir, 'docker-compose.yml')), 'no compose file may be written');
239
+ assert.ok(!existsSync(join(dir, '.env')), 'no .env may be written');
240
+ });
241
+ });
242
+
243
+ test('--render without --image-tag says the sidecar has no :latest, before pulling it', () => {
244
+ withDir((dir) => {
245
+ const r = withFakeDocker({ missing: true }, (env) =>
246
+ runCli([...BASE, '--render'], { cwd: dir, env })
247
+ );
248
+ assert.equal(r.status, 2, `${r.stdout}${r.stderr}`);
249
+ assert.match(`${r.stdout}${r.stderr}`, /RELEASE TAGS ONLY/);
250
+ assert.ok(!existsSync(join(dir, 'docker-compose.yml')));
251
+ });
252
+ });
253
+
254
+ test('--render on a multi-arch image covering this host proceeds', () => {
255
+ withDir((dir) => {
256
+ const r = withFakeDocker({ platforms: ['linux/amd64', 'linux/arm64', HOST_PLATFORM] }, (env) =>
257
+ runCli([...BASE, '--render', '--image-tag', 'v1.0.3', '--dry-run'], { cwd: dir, env })
258
+ );
259
+ assert.equal(r.status, 0, `${r.stdout}${r.stderr}`);
260
+ assert.match(r.stdout, /DRY RUN/);
261
+ assert.ok(!/cannot work on this machine/.test(`${r.stdout}${r.stderr}`));
262
+ });
263
+ });
264
+
265
+ test('no docker at all is a warning, not a refusal — an offline operator is not the bug', () => {
266
+ withDir((dir) => {
267
+ const r = runCli([...BASE, '--render', '--image-tag', 'v1.0.3', '--dry-run'], {
268
+ cwd: dir,
269
+ // An empty PATH: `which docker` cannot find anything.
270
+ env: { PATH: join(dir, 'nothing-here') },
271
+ });
272
+ assert.equal(r.status, 0, `${r.stdout}${r.stderr}`);
273
+ assert.match(r.stderr, /could not check whether/);
274
+ });
275
+ });
@@ -831,3 +831,106 @@ export function operatorDuties(cfg) {
831
831
  }
832
832
  return duties;
833
833
  }
834
+
835
+ /**
836
+ * The self-host sidecar image, resolved.
837
+ *
838
+ * `renderCompose` names it through `${MAUDE_IMAGE_TAG:-latest}` so the operator
839
+ * can re-pin without regenerating; this is that same reference with the tag
840
+ * this run would write into `.env`.
841
+ */
842
+ export function renderImageRef(cfg) {
843
+ return `ghcr.io/1agh/maude-render:${cfg.imageTag}`;
844
+ }
845
+
846
+ /**
847
+ * `process.arch` → the platform a container registry names.
848
+ *
849
+ * Always `linux/*`, whatever the host OS is: the sidecar is a Linux container
850
+ * either way, and on Docker Desktop the host kernel is a Linux VM of the same
851
+ * architecture. What decides the answer is the CPU, not the desktop OS.
852
+ */
853
+ export function hostContainerPlatform(arch = process.arch) {
854
+ const architecture =
855
+ { x64: 'amd64', arm64: 'arm64', arm: 'arm', ppc64: 'ppc64le', s390x: 's390x' }[arch] ?? arch;
856
+ return `linux/${architecture}`;
857
+ }
858
+
859
+ /**
860
+ * Can THIS machine run the render sidecar? (M10)
861
+ *
862
+ * The failure this exists to prevent: `--render` was accepted, the compose file
863
+ * was written, `docker pull` SUCCEEDED (Docker falls back across architectures
864
+ * on pull), and the operator learned the truth from `exec format error` in the
865
+ * container log — after 2.99 GB. The remedy the product itself recommends
866
+ * failed on the host the product's own AWS runbook recommends.
867
+ *
868
+ * Pure on purpose: the probe is one `docker manifest inspect` away in
869
+ * `hub-workspace.mjs`, and every judgement about what its answer MEANS is here,
870
+ * where it can be tested without a registry.
871
+ *
872
+ * Three verdicts, and the third is deliberate: a probe that could not run is a
873
+ * WARNING, never a refusal. Not knowing the architecture is not evidence of a
874
+ * mismatch, and a guard that blocks an offline operator is a worse bug than the
875
+ * one it guards.
876
+ *
877
+ * @param {object} input
878
+ * @param {string} input.imageRef the image the compose file will name
879
+ * @param {string} input.hostPlatform `linux/arm64`, from `hostContainerPlatform()`
880
+ * @param {{status: 'ok'|'missing'|'unknown', platforms?: string[], note?: string}} input.probe
881
+ * @returns {{ok: boolean, level: 'ok'|'warn'|'refuse', message: string}}
882
+ */
883
+ export function classifyRenderImage({ imageRef, hostPlatform, probe }) {
884
+ const tag = imageRef.slice(imageRef.lastIndexOf(':') + 1);
885
+
886
+ if (probe.status === 'unknown') {
887
+ return {
888
+ ok: true,
889
+ level: 'warn',
890
+ message:
891
+ `\n ⚠ could not check whether ${imageRef} runs on this machine` +
892
+ `${probe.note ? ` (${probe.note})` : ''}.\n` +
893
+ ` This host is ${hostPlatform}. If the image is not published for it, the\n` +
894
+ ` render container will exit with "exec format error" after pulling ~3 GB.\n` +
895
+ ` Check with: docker manifest inspect --verbose ${imageRef}\n\n`,
896
+ };
897
+ }
898
+
899
+ if (probe.status === 'missing') {
900
+ return {
901
+ ok: false,
902
+ level: 'refuse',
903
+ message:
904
+ `--render names an image that is not published: ${imageRef}\n\n` +
905
+ (tag === 'latest'
906
+ ? ' ghcr.io/1agh/maude-render publishes RELEASE TAGS ONLY — there is no\n' +
907
+ ' `latest`. The hub image has one, which is why the default tag looks\n' +
908
+ ' like it should work here and does not.\n\n' +
909
+ ' Pass the release you want, e.g. --image-tag v1.0.3. It pins the hub too.\n'
910
+ : ` Pick a published release tag (see\n` +
911
+ ` https://github.com/1aGh/maude/pkgs/container/maude-render), or drop\n` +
912
+ ` --render: ZIP export and Maude Desktop work either way.\n`),
913
+ };
914
+ }
915
+
916
+ const platforms = probe.platforms ?? [];
917
+ if (platforms.includes(hostPlatform)) return { ok: true, level: 'ok', message: '' };
918
+
919
+ return {
920
+ ok: false,
921
+ level: 'refuse',
922
+ message:
923
+ `--render cannot work on this machine.\n\n` +
924
+ ` ${imageRef}\n` +
925
+ ` is published for ${platforms.join(', ') || 'no architecture this can read'}; ` +
926
+ `this host is ${hostPlatform}.\n\n` +
927
+ ` Docker would pull it anyway — it falls back across architectures on pull —\n` +
928
+ ` and the container would exit with "exec format error" after ~3 GB.\n\n` +
929
+ ` What to do:\n` +
930
+ ` • run the workspace on ${platforms[0] ?? 'matching'} hardware ` +
931
+ `(on AWS that is t3.small, not t4g.small), or\n` +
932
+ ` • pin --image-tag to a release published for ${hostPlatform}, or\n` +
933
+ ` • drop --render: ZIP export still works, and Maude Desktop renders\n` +
934
+ ` PNG/PDF/PPTX/video locally.\n`,
935
+ };
936
+ }
@@ -9,11 +9,14 @@ import assert from 'node:assert/strict';
9
9
  import { test } from 'node:test';
10
10
 
11
11
  import {
12
+ classifyRenderImage,
12
13
  envEntries,
14
+ hostContainerPlatform,
13
15
  operatorDuties,
14
16
  renderCaddyfile,
15
17
  renderCompose,
16
18
  renderEnv,
19
+ renderImageRef,
17
20
  safeSeedUrl,
18
21
  validateWorkspaceConfig,
19
22
  verificationPlan,
@@ -512,7 +515,11 @@ test('--render wires the sidecar end to end; without it nothing render-shaped ap
512
515
  const cfg = ok({ ...BASE, render: true });
513
516
  const yaml = renderCompose(cfg);
514
517
  assert.match(yaml, /ghcr\.io\/1agh\/maude-render/, 'the sidecar service renders');
515
- assert.match(yaml, /MAUDE_RENDER_CANVAS_ORIGINS: http:\/\/hub:1234/, 'origin allowlist pins the hub');
518
+ assert.match(
519
+ yaml,
520
+ /MAUDE_RENDER_CANVAS_ORIGINS: http:\/\/hub:1234/,
521
+ 'origin allowlist pins the hub'
522
+ );
516
523
  assert.match(yaml, /MAUDE_RENDER_URL/, 'the hub is told where to dispatch');
517
524
  const env = renderEnv(
518
525
  envEntries(cfg, { hubSecret: 'x', adminPassword: 'y'.repeat(12), renderSecret: 'rsec' })
@@ -595,3 +602,98 @@ test('local mode keeps the canvas name loopback-only and plain HTTP', () => {
595
602
  });
596
603
  assert.equal(bad.ok, false);
597
604
  });
605
+
606
+ // -------------------------------------------- the render sidecar's architecture
607
+
608
+ // M10 — the sidecar shipped `linux/amd64` only while the AWS runbook recommends
609
+ // `t4g.small` (arm64). `--render` was accepted, the compose file was written,
610
+ // the image PULLED (Docker falls back across architectures on pull), and the
611
+ // container exited with `exec format error` after 2.99 GB. Everything below is
612
+ // the judgement that turns a manifest into an answer BEFORE any of that.
613
+
614
+ test('the sidecar reference follows the image tag the run would write', () => {
615
+ assert.equal(renderImageRef(ok({ ...BASE })), 'ghcr.io/1agh/maude-render:latest');
616
+ assert.equal(
617
+ renderImageRef(ok({ ...BASE, imageTag: 'v1.0.3' })),
618
+ 'ghcr.io/1agh/maude-render:v1.0.3'
619
+ );
620
+ });
621
+
622
+ test('the host platform is a linux container platform whatever the desktop OS is', () => {
623
+ assert.equal(hostContainerPlatform('arm64'), 'linux/arm64');
624
+ assert.equal(hostContainerPlatform('x64'), 'linux/amd64');
625
+ // Unknown architectures pass through rather than guessing amd64 — a wrong
626
+ // confident answer here refuses a run that would have worked.
627
+ assert.equal(hostContainerPlatform('riscv64'), 'linux/riscv64');
628
+ });
629
+
630
+ test('an image published for this host passes', () => {
631
+ const verdict = classifyRenderImage({
632
+ imageRef: 'ghcr.io/1agh/maude-render:v1.0.3',
633
+ hostPlatform: 'linux/arm64',
634
+ probe: { status: 'ok', platforms: ['linux/amd64', 'linux/arm64'] },
635
+ });
636
+ assert.equal(verdict.ok, true);
637
+ assert.equal(verdict.level, 'ok');
638
+ });
639
+
640
+ test('an amd64-only image on an arm64 host is REFUSED, and says why and what to do', () => {
641
+ const verdict = classifyRenderImage({
642
+ imageRef: 'ghcr.io/1agh/maude-render:v1.0.2',
643
+ hostPlatform: 'linux/arm64',
644
+ probe: { status: 'ok', platforms: ['linux/amd64'] },
645
+ });
646
+ assert.equal(verdict.ok, false);
647
+ assert.equal(verdict.level, 'refuse');
648
+ // The three things the live AWS run had to work out for itself.
649
+ assert.match(verdict.message, /linux\/amd64/);
650
+ assert.match(verdict.message, /linux\/arm64/);
651
+ assert.match(verdict.message, /exec format error/);
652
+ assert.match(verdict.message, /t3\.small/);
653
+ // …and the way out that needs no new machine at all.
654
+ assert.match(verdict.message, /ZIP export/);
655
+ });
656
+
657
+ test('the mirror case is refused too — this is not an arm64 special case', () => {
658
+ const verdict = classifyRenderImage({
659
+ imageRef: 'ghcr.io/1agh/maude-render:v9.9.9',
660
+ hostPlatform: 'linux/amd64',
661
+ probe: { status: 'ok', platforms: ['linux/arm64'] },
662
+ });
663
+ assert.equal(verdict.ok, false);
664
+ });
665
+
666
+ test('an unpublished tag is refused before the pull, and `latest` says the real reason', () => {
667
+ const latest = classifyRenderImage({
668
+ imageRef: 'ghcr.io/1agh/maude-render:latest',
669
+ hostPlatform: 'linux/arm64',
670
+ probe: { status: 'missing' },
671
+ });
672
+ assert.equal(latest.ok, false);
673
+ // The compose file defaults to `${MAUDE_IMAGE_TAG:-latest}` and the HUB
674
+ // publishes `:latest` — so the default looks like it works and does not.
675
+ assert.match(latest.message, /RELEASE TAGS ONLY/);
676
+ assert.match(latest.message, /--image-tag/);
677
+
678
+ const pinned = classifyRenderImage({
679
+ imageRef: 'ghcr.io/1agh/maude-render:v0.0.1',
680
+ hostPlatform: 'linux/arm64',
681
+ probe: { status: 'missing' },
682
+ });
683
+ assert.equal(pinned.ok, false);
684
+ assert.doesNotMatch(pinned.message, /RELEASE TAGS ONLY/);
685
+ });
686
+
687
+ test('a probe that could not run WARNS — it never refuses', () => {
688
+ const verdict = classifyRenderImage({
689
+ imageRef: 'ghcr.io/1agh/maude-render:v1.0.3',
690
+ hostPlatform: 'linux/arm64',
691
+ probe: { status: 'unknown', note: 'docker is not on PATH' },
692
+ });
693
+ // Not knowing the architecture is not evidence of a mismatch. A guard that
694
+ // blocks an offline operator is a worse bug than the one it guards.
695
+ assert.equal(verdict.ok, true);
696
+ assert.equal(verdict.level, 'warn');
697
+ assert.match(verdict.message, /docker is not on PATH/);
698
+ assert.match(verdict.message, /docker manifest inspect/);
699
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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": {
@@ -54,13 +54,13 @@
54
54
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@1agh/maude-darwin-arm64": "1.0.3",
58
- "@1agh/maude-darwin-x64": "1.0.3",
59
- "@1agh/maude-linux-arm64": "1.0.3",
60
- "@1agh/maude-linux-arm64-musl": "1.0.3",
61
- "@1agh/maude-linux-x64": "1.0.3",
62
- "@1agh/maude-linux-x64-musl": "1.0.3",
63
- "@1agh/maude-win32-x64": "1.0.3"
57
+ "@1agh/maude-darwin-arm64": "1.0.5",
58
+ "@1agh/maude-darwin-x64": "1.0.5",
59
+ "@1agh/maude-linux-arm64": "1.0.5",
60
+ "@1agh/maude-linux-arm64-musl": "1.0.5",
61
+ "@1agh/maude-linux-x64": "1.0.5",
62
+ "@1agh/maude-linux-x64-musl": "1.0.5",
63
+ "@1agh/maude-win32-x64": "1.0.5"
64
64
  },
65
65
  "files": [
66
66
  "cli",