@1agh/maude 1.0.3 → 1.0.4
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/dist/client.bundle.js +1 -1
- package/apps/studio/test/canvas-shell-build-error.test.ts +4 -1
- package/apps/studio/whats-new.json +9 -0
- package/cli/commands/hub-workspace.mjs +86 -0
- package/cli/commands/hub-workspace.test.mjs +107 -3
- package/cli/lib/workspace-plan.mjs +103 -0
- package/cli/lib/workspace-plan.test.mjs +103 -1
- package/package.json +8 -8
|
@@ -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(
|
|
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], {
|
|
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(
|
|
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
|
+
"version": "1.0.4",
|
|
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.
|
|
58
|
-
"@1agh/maude-darwin-x64": "1.0.
|
|
59
|
-
"@1agh/maude-linux-arm64": "1.0.
|
|
60
|
-
"@1agh/maude-linux-arm64-musl": "1.0.
|
|
61
|
-
"@1agh/maude-linux-x64": "1.0.
|
|
62
|
-
"@1agh/maude-linux-x64-musl": "1.0.
|
|
63
|
-
"@1agh/maude-win32-x64": "1.0.
|
|
57
|
+
"@1agh/maude-darwin-arm64": "1.0.4",
|
|
58
|
+
"@1agh/maude-darwin-x64": "1.0.4",
|
|
59
|
+
"@1agh/maude-linux-arm64": "1.0.4",
|
|
60
|
+
"@1agh/maude-linux-arm64-musl": "1.0.4",
|
|
61
|
+
"@1agh/maude-linux-x64": "1.0.4",
|
|
62
|
+
"@1agh/maude-linux-x64-musl": "1.0.4",
|
|
63
|
+
"@1agh/maude-win32-x64": "1.0.4"
|
|
64
64
|
},
|
|
65
65
|
"files": [
|
|
66
66
|
"cli",
|