@1agh/maude 0.57.0 → 0.58.1

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.
@@ -1507,6 +1507,14 @@ export function createHttp(
1507
1507
  ...ctx.cfg,
1508
1508
  canvasOrigin: ctx.canvasOrigin,
1509
1509
  readOnly: projectReadOnly(req),
1510
+ // WHICH RELEASE THIS IS. `/_config` reaches the browser in cloud mode,
1511
+ // so the bar for adding a field here is "would I publish it" — and a
1512
+ // version is the git tag, which is public. Nothing else rides along.
1513
+ //
1514
+ // Resolved through `resolveMaudeVersion`, the same function the What's
1515
+ // New feed uses: one answer, so the chip in the status bar and the feed
1516
+ // can never name different versions.
1517
+ version: resolveMaudeVersion(),
1510
1518
  // Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
1511
1519
  // canvas origin, minted per session by the proxy. Absent on a desktop,
1512
1520
  // where the canvas origin is loopback and needs none. `canvasUrl()`
@@ -446,6 +446,34 @@ export function yieldsToArtboardDrag(
446
446
  );
447
447
  }
448
448
 
449
+ /**
450
+ * Click-phase counterpart to `yieldsToArtboardDrag` (issue-78). The onClick
451
+ * handler below suppresses the browser's native `click` for every gesture it
452
+ * already routed as `select` on the matching pointerdown, so a just-selected
453
+ * element's own click handler doesn't also fire. But an artboard-chrome
454
+ * control — e.g. the video-timeline badge (`.dc-artboard-video-badge`,
455
+ * DDR-148) rendered as a sibling of `.dc-artboard-body` — never goes through
456
+ * `select`; it's an interactive button that needs its own `onClick` to reach
457
+ * React. Without this exemption a bare click on it while in Move (or a
458
+ * Cmd/Ctrl+click from Browse/an annotation tool) is preventDefault +
459
+ * stopImmediatePropagation'd in the capture phase before React's delegated
460
+ * listener ever runs, so the button silently does nothing. Mirrors
461
+ * `yieldsToArtboardDrag`'s modifier gate: a held Cmd/Ctrl is reserved for
462
+ * deep-select and stays unexempted.
463
+ */
464
+ export function yieldsClickToArtboardChrome(
465
+ wouldRouteKind: RouterAction['kind'] | null,
466
+ target: EventTarget | null,
467
+ modifiers: { metaKey: boolean; ctrlKey: boolean }
468
+ ): boolean {
469
+ return (
470
+ wouldRouteKind === 'select' &&
471
+ !modifiers.metaKey &&
472
+ !modifiers.ctrlKey &&
473
+ isArtboardDragChrome(target)
474
+ );
475
+ }
476
+
449
477
  export function useInputRouter(opts: UseInputRouterOptions): void {
450
478
  const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
451
479
 
@@ -631,8 +659,18 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
631
659
  ? 'context-menu'
632
660
  : null;
633
661
  if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
662
+ // issue-78 — a bare click on an artboard-chrome control (the video-
663
+ // timeline badge) must still reach its own onClick; see
664
+ // yieldsClickToArtboardChrome for why. Mirrors the onPointerDown/
665
+ // onMouseDown pattern above: preventDefault always runs (so a forged
666
+ // chrome-alike element inside untrusted canvas content can't use this
667
+ // exemption to let its own native click default action — e.g. an
668
+ // <a>'s navigation or an <input type="file">'s picker — fire), but
669
+ // stopImmediatePropagation is skipped so the real chrome button's own
670
+ // React onClick still gets the (now-inert-by-default) event.
671
+ const yields = yieldsClickToArtboardChrome(wouldRouteKind, e.target, e);
634
672
  e.preventDefault();
635
- e.stopImmediatePropagation();
673
+ if (!yields) e.stopImmediatePropagation();
636
674
  }
637
675
  };
638
676
 
@@ -0,0 +1,36 @@
1
+ // artboard-hug-commit.ts — issue #79. The Artboard Inspector's Height
2
+ // NumberField silently dropped a typed value whenever the artboard was in
3
+ // Hug mode (fixed=false, the default per DDR-027 Design Decision 1) — Width
4
+ // always worked because it has no such mode. Typing a height should instead
5
+ // promote the artboard to Fixed at that height, same as the Hug/Fixed
6
+ // toggle's own freeze-current-height write.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { resolveHeightCommit } from '../artboard-hug-commit.ts';
11
+
12
+ describe('artboard-hug-commit / resolveHeightCommit', () => {
13
+ test('fixed=true → plain resize (Width-field parity)', () => {
14
+ expect(resolveHeightCommit(true, 480)).toEqual({ kind: 'resize', height: 480 });
15
+ });
16
+
17
+ test('fixed=false (Hug, the default) → promotes to Fixed at the typed height', () => {
18
+ // Before the fix this typed value was dropped entirely (no action) —
19
+ // reproducing "when I want to set height... manually from inspector
20
+ // panel, nothing happened. Width works."
21
+ expect(resolveHeightCommit(false, 480)).toEqual({ kind: 'promote-to-fixed', height: 480 });
22
+ });
23
+
24
+ test('rounds a fractional typed value', () => {
25
+ expect(resolveHeightCommit(true, 480.6)).toEqual({ kind: 'resize', height: 481 });
26
+ expect(resolveHeightCommit(false, 480.6)).toEqual({ kind: 'promote-to-fixed', height: 481 });
27
+ });
28
+
29
+ test('non-finite or non-positive input → noop, in either mode', () => {
30
+ expect(resolveHeightCommit(true, Number.NaN)).toEqual({ kind: 'noop' });
31
+ expect(resolveHeightCommit(true, 0)).toEqual({ kind: 'noop' });
32
+ expect(resolveHeightCommit(true, -10)).toEqual({ kind: 'noop' });
33
+ expect(resolveHeightCommit(false, Number.NaN)).toEqual({ kind: 'noop' });
34
+ expect(resolveHeightCommit(false, 0)).toEqual({ kind: 'noop' });
35
+ });
36
+ });
@@ -31,6 +31,11 @@ import {
31
31
 
32
32
  const CANVAS = '/abs/Canvas.tsx';
33
33
 
34
+ // Adversarial fixture (security review 2026-07-30) — a prompt embedding a
35
+ // fake </AIPlaceholder> close tag + template-literal/env-var syntax, proving
36
+ // the JSON.stringify escaping discipline (DDR-150 P1) holds under attack.
37
+ const EVIL = '*/ `backtick` ${process.env.HOME} </AIPlaceholder> "q" \\';
38
+
34
39
  const SERIES = [
35
40
  "import { TransitionSeries } from '@remotion/transitions';",
36
41
  'const S1 = 90;',
@@ -568,8 +573,6 @@ describe('greenfield first drop (Task 20)', () => {
568
573
  });
569
574
 
570
575
  describe('AIPlaceholder insert + resolve (Tasks 21–22)', () => {
571
- const EVIL = '*/ `backtick` ${process.env.HOME} </AIPlaceholder> "q" \\';
572
-
573
576
  test('prompt is JSON.stringified into the JSX (injection fixtures) and enumerates back', () => {
574
577
  const r = applyInsertClipAt(CANVAS, SERIES, 'a', {
575
578
  lane: 'storyline',
@@ -833,4 +836,83 @@ describe('layers model — set-text + move between layers', () => {
833
836
  expect(cc.clips.filter((c) => c.tag === 'Sequence').length).toBe(0); // overlay row gone
834
837
  expect(back.source).toContain('const TOTAL = 270;'); // exact content end again
835
838
  });
839
+
840
+ // Bug: issue #81 — an AI placeholder slate (no media src yet) was refused by
841
+ // both move verbs, so it could never leave the storyline it was dropped
842
+ // into. A placeholder has no <src> to graft, but it has a prompt + kind
843
+ // that can ride over unchanged.
844
+ test('AI placeholder beat → overlay layer keeps the prompt (no media src required)', () => {
845
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
846
+ lane: 'storyline',
847
+ index: 3,
848
+ durationInFrames: 60,
849
+ placeholder: { prompt: 'drone shot over Brno', kind: 'veo' },
850
+ });
851
+ const r = applyMoveClipToOverlay(
852
+ CANVAS,
853
+ withPh.source,
854
+ 'a',
855
+ withPh.stableId as string,
856
+ undefined
857
+ );
858
+ const cc = enumerateClips(CANVAS, r.source, 'a');
859
+ const overlays = cc.clips.filter((c) => c.tag === 'Sequence');
860
+ expect(overlays.length).toBe(1);
861
+ expect(overlays[0]?.placeholder?.prompt).toBe('drone shot over Brno');
862
+ expect(overlays[0]?.mediaSrc).toBe(null);
863
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
864
+ expect(beats.length).toBe(3); // the placeholder beat left the storyline
865
+ });
866
+
867
+ test('AI placeholder overlay → storyline round-trips the prompt back into a beat', () => {
868
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
869
+ lane: 'overlay',
870
+ from: 0,
871
+ durationInFrames: 45,
872
+ placeholder: { prompt: 'sunset time-lapse', kind: 'image' },
873
+ });
874
+ const back = applyMoveClipToStoryline(
875
+ CANVAS,
876
+ withPh.source,
877
+ 'a',
878
+ withPh.stableId as string,
879
+ undefined
880
+ );
881
+ const cc = enumerateClips(CANVAS, back.source, 'a');
882
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
883
+ expect(beats.some((c) => c.placeholder?.prompt === 'sunset time-lapse')).toBe(true);
884
+ expect(cc.clips.filter((c) => c.tag === 'Sequence').length).toBe(0); // overlay row gone
885
+ });
886
+
887
+ // Adversarial (security review 2026-08-07): the move path must go through
888
+ // the same JSON.stringify escaping as a fresh insert (DDR-150 P1) — prove
889
+ // it with the same EVIL fixture the insert/resolve suite uses above, not
890
+ // just infer it from code-path sharing.
891
+ test('AI placeholder move preserves EVIL-prompt escaping (F-A3 companion)', () => {
892
+ const withPh = applyInsertClipAt(CANVAS, HARDCUT, 'a', {
893
+ lane: 'storyline',
894
+ index: 3,
895
+ durationInFrames: 60,
896
+ placeholder: { prompt: EVIL, kind: 'veo' },
897
+ });
898
+ const moved = applyMoveClipToOverlay(
899
+ CANVAS,
900
+ withPh.source,
901
+ 'a',
902
+ withPh.stableId as string,
903
+ undefined
904
+ );
905
+ expect(moved.source).toContain(`<span>{${JSON.stringify(EVIL)}}</span>`);
906
+ const back = applyMoveClipToStoryline(
907
+ CANVAS,
908
+ moved.source,
909
+ 'a',
910
+ moved.stableId as string,
911
+ undefined
912
+ );
913
+ expect(back.source).toContain(`<span>{${JSON.stringify(EVIL)}}</span>`);
914
+ const cc = enumerateClips(CANVAS, back.source, 'a');
915
+ const beats = cc.clips.filter((c) => c.kind === 'sequence' && c.tag !== 'Sequence');
916
+ expect(beats.some((c) => c.placeholder?.prompt === EVIL)).toBe(true);
917
+ });
836
918
  });
@@ -0,0 +1,88 @@
1
+ // The running version reaches the client — feature-release-reaches-the-fleet.
2
+ //
3
+ // Reading which release a Maude was actually running used to mean probing
4
+ // production by hand, and the first probe of the v0.57.0 incident was MISREAD
5
+ // (a 401 that looked like "route exists" was the blanket answer for any unknown
6
+ // path). The fix has three surfaces reporting one version: the hub's `/health`,
7
+ // this `/_config`, and the status-bar chip the client draws from it.
8
+ //
9
+ // What is asserted here is the middle one, plus the two properties that make it
10
+ // safe to put there: it is resolved through the SINGLE version function, and it
11
+ // carries nothing else.
12
+
13
+ import { afterAll, describe, expect, test } from 'bun:test';
14
+ import type { Subprocess } from 'bun';
15
+
16
+ import { resolveMaudeVersion } from '../whats-new.ts';
17
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
18
+
19
+ const procs: Subprocess[] = [];
20
+ afterAll(async () => {
21
+ for (const p of procs) await killProc(p);
22
+ });
23
+
24
+ async function boot(env: Record<string, string> = {}) {
25
+ const { root } = makeSandbox();
26
+ const port = nextPort();
27
+ procs.push(await bootServer(root, port, env));
28
+ return `http://localhost:${port}`;
29
+ }
30
+
31
+ describe('/_config names the release it is running', () => {
32
+ test('the version is present, and is a real version', async () => {
33
+ const base = await boot();
34
+ const cfg = (await (await fetch(`${base}/_config`)).json()) as { version?: string };
35
+
36
+ expect(typeof cfg.version).toBe('string');
37
+ // `0.0.0` was the private-workspace placeholder these manifests carried
38
+ // before they joined the release line — reporting it is the bug, not a pass.
39
+ expect(cfg.version).not.toBe('0.0.0');
40
+ expect(cfg.version).toMatch(/^\d+\.\d+\.\d+/);
41
+ });
42
+
43
+ test('it is the SAME answer the What’s New feed uses', async () => {
44
+ // One resolution path (DDR-045). Two would eventually disagree, and a chip
45
+ // that contradicts the feed beside it is worse than no chip.
46
+ const base = await boot();
47
+ const cfg = (await (await fetch(`${base}/_config`)).json()) as { version?: string };
48
+ const feed = (await (await fetch(`${base}/_api/whats-new`)).json()) as { version?: string };
49
+
50
+ expect(cfg.version).toBe(resolveMaudeVersion());
51
+ expect(cfg.version).toBe(feed.version);
52
+ });
53
+ });
54
+
55
+ describe('the version rides along, and nothing else does', () => {
56
+ test('a cloud tab gets the version too', async () => {
57
+ // `/_config` reaches a browser on someone else's machine in workspace mode.
58
+ // A git tag is public, so the version is fine there — this pins that it
59
+ // actually arrives, since the chip is the cloud's only version surface.
60
+ const base = await boot({
61
+ MAUDE_WORKSPACE_MODE: '1',
62
+ MAUDE_WORKSPACE_ALLOW_DEV_MODULES: '1',
63
+ MAUDE_PROJECT_NAME: 'Alligators',
64
+ });
65
+ const cfg = (await (await fetch(`${base}/_config`)).json()) as {
66
+ version?: string;
67
+ cloud?: unknown;
68
+ };
69
+
70
+ expect(typeof cfg.version).toBe('string');
71
+ expect(cfg.cloud).toBeDefined();
72
+ });
73
+
74
+ test('no server path or secret came with it', async () => {
75
+ const base = await boot();
76
+ const cfg = (await (await fetch(`${base}/_config`)).json()) as Record<string, unknown>;
77
+
78
+ // The version was the one field this change added. Anything matching these
79
+ // shapes arriving in the browser payload is a leak, and the review that
80
+ // waves it through will be the one that reads "it was already there".
81
+ for (const key of Object.keys(cfg)) {
82
+ expect(key).not.toMatch(/token$/i);
83
+ expect(key).not.toMatch(/secret|password|apiKey/i);
84
+ }
85
+ const serialized = JSON.stringify(cfg);
86
+ expect(serialized).not.toMatch(/BEGIN [A-Z ]*PRIVATE KEY/);
87
+ });
88
+ });
@@ -10,6 +10,7 @@ import {
10
10
  isArtboardDragChrome,
11
11
  isEditableTarget,
12
12
  type Tool,
13
+ yieldsClickToArtboardChrome,
13
14
  yieldsToArtboardDrag,
14
15
  } from '../input-router.tsx';
15
16
 
@@ -552,6 +553,61 @@ describe('input-router / yieldsToArtboardDrag', () => {
552
553
  });
553
554
  });
554
555
 
556
+ // issue-78 — the actual regression: the router's capture-phase `click`
557
+ // listener claimed every bare click while in the Move tool (and every
558
+ // Cmd/Ctrl+click from Browse/annotation tools) and called
559
+ // `stopImmediatePropagation()` unconditionally, unlike its pointerdown/
560
+ // mousedown siblings above. That killed the native click on any artboard-
561
+ // chrome control BEFORE React's delegated onClick ever ran — including the
562
+ // video-timeline badge (`.dc-artboard-video-badge`, DDR-148), which sits
563
+ // outside `.dc-artboard-body` as chrome. Clicking it did nothing.
564
+ // `yieldsClickToArtboardChrome` is the decision the click handler now gates
565
+ // its suppression on, mirroring `yieldsToArtboardDrag` above.
566
+ describe('input-router / yieldsClickToArtboardChrome', () => {
567
+ const chrome = (): EventTarget =>
568
+ ({
569
+ closest: (sel: string) => (sel === '[data-dc-screen]' ? {} : null),
570
+ }) as unknown as EventTarget;
571
+ const body = (): EventTarget => ({ closest: () => ({}) }) as unknown as EventTarget; // matches every selector, incl. .dc-artboard-body
572
+
573
+ test('bare click on the video-timeline badge in Move tool (the reported bug) → true', () => {
574
+ expect(
575
+ yieldsClickToArtboardChrome('select', chrome(), { metaKey: false, ctrlKey: false })
576
+ ).toBe(true);
577
+ });
578
+
579
+ test('cmd+click on artboard chrome (deep-select, not a chrome control) → false', () => {
580
+ expect(yieldsClickToArtboardChrome('select', chrome(), { metaKey: true, ctrlKey: false })).toBe(
581
+ false
582
+ );
583
+ });
584
+
585
+ test('bare click on .dc-artboard-body content (not chrome) → false', () => {
586
+ expect(yieldsClickToArtboardChrome('select', body(), { metaKey: false, ctrlKey: false })).toBe(
587
+ false
588
+ );
589
+ });
590
+
591
+ test('bare click outside any artboard → false (nothing to yield to)', () => {
592
+ const outside = { closest: () => null } as unknown as EventTarget;
593
+ expect(yieldsClickToArtboardChrome('select', outside, { metaKey: false, ctrlKey: false })).toBe(
594
+ false
595
+ );
596
+ });
597
+
598
+ test('non-select routed kind (e.g. context-menu) on chrome → false', () => {
599
+ expect(
600
+ yieldsClickToArtboardChrome('context-menu', chrome(), { metaKey: false, ctrlKey: false })
601
+ ).toBe(false);
602
+ });
603
+
604
+ test('null routed kind on chrome → false', () => {
605
+ expect(yieldsClickToArtboardChrome(null, chrome(), { metaKey: false, ctrlKey: false })).toBe(
606
+ false
607
+ );
608
+ });
609
+ });
610
+
555
611
  // T25 — Drag-vs-click threshold. Centralized here so every drag-class gesture
556
612
  // (artboard drag, artboard marquee, element marquee, annotation pen/rect/etc.)
557
613
  // reads the same value. Microsoft Win32 SM_CXDRAG default + d3-drag convention.
@@ -0,0 +1,41 @@
1
+ // timeline-media-cache.test.ts — bug fix: dropping a clip on the timeline
2
+ // used to always trim it to a flat 3s instead of its real duration
3
+ // (durationInFrames was `Math.round(fps * 3)` regardless of the probed
4
+ // media length). durationFramesForDrop is the pure decision the drop
5
+ // handler in app.jsx now delegates to.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { durationFramesForDrop } from '../client/panels/timeline-media-cache.js';
10
+
11
+ describe('durationFramesForDrop', () => {
12
+ test('uses the real probed duration, not the flat 3s default', () => {
13
+ // A 7.4s clip at 30fps must land at its own length (222 frames), not the
14
+ // fixed fps*3 = 90 frames the drop handler used to hardcode.
15
+ expect(durationFramesForDrop(30, 7.4)).toBe(Math.round(30 * 7.4));
16
+ expect(durationFramesForDrop(30, 7.4)).not.toBe(Math.round(30 * 3));
17
+ });
18
+
19
+ test('rounds to the nearest frame', () => {
20
+ expect(durationFramesForDrop(24, 2.501)).toBe(60); // 24 * 2.501 = 60.024
21
+ });
22
+
23
+ test('falls back to 3s when no duration was probed (null/failed probe)', () => {
24
+ expect(durationFramesForDrop(30, null)).toBe(90);
25
+ expect(durationFramesForDrop(30, undefined)).toBe(90);
26
+ expect(durationFramesForDrop(30, NaN)).toBe(90);
27
+ });
28
+
29
+ test('falls back to 3s for a non-positive probed duration', () => {
30
+ expect(durationFramesForDrop(30, 0)).toBe(90);
31
+ expect(durationFramesForDrop(30, -1)).toBe(90);
32
+ });
33
+
34
+ test('a caller can override the fallback (e.g. synthetic content)', () => {
35
+ expect(durationFramesForDrop(30, null, 5)).toBe(150);
36
+ });
37
+
38
+ test('never returns less than 1 frame', () => {
39
+ expect(durationFramesForDrop(30, 0.001)).toBeGreaterThanOrEqual(1);
40
+ });
41
+ });
@@ -23,8 +23,20 @@ describe('whats-new feed loader', () => {
23
23
  }
24
24
  });
25
25
 
26
- test('resolveMaudeVersion reads the design plugin manifest', () => {
26
+ test('resolveMaudeVersion resolves a real release version', () => {
27
27
  expect(resolveMaudeVersion()).toMatch(/^\d+\.\d+\.\d+/);
28
+ // The studio's own manifest is preferred over the design plugin's (it is
29
+ // the one staged into the cell image), and `0.0.0` — the placeholder both
30
+ // carried before joining the release line — is never an answer.
31
+ expect(resolveMaudeVersion()).not.toBe('0.0.0');
32
+ });
33
+
34
+ test('an unversioned root falls through rather than reporting the placeholder', () => {
35
+ const root = mkdtempSync(join(tmpdir(), 'maude-ver-'));
36
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '0.0.0' }));
37
+ // No plugin manifest above a tmpdir either, so this lands on `dev` — a
38
+ // value obviously not a release, rather than a plausible wrong number.
39
+ expect(resolveMaudeVersion(root)).toBe('dev');
28
40
  });
29
41
 
30
42
  test('a missing feed dir yields empty entries and never throws', () => {
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "release-version-visible",
6
+ "version": "0.58.0",
7
+ "date": "2026-08-07",
8
+ "kind": "improvement",
9
+ "title": "See which version of Maude you are running",
10
+ "summary": "The Studio status bar now shows the running version, and the hub admin header shows it beside the bundle hash — so you can tell what you are on without digging. In the cloud it is the same chip, because the cloud serves the same Studio.",
11
+ "surface": "design-ui"
12
+ },
4
13
  {
5
14
  "id": "cloud-connect-tells-you-what-happened",
6
15
  "version": "0.57.0",
@@ -43,18 +43,42 @@ export interface WhatsNewFeed {
43
43
  let cache: WhatsNewFeed | null = null;
44
44
 
45
45
  /**
46
- * Read the installed maude version from the design plugin manifest.
47
- * `apps/studio/` → `plugins/design/.claude-plugin/plugin.json`.
48
- * That manifest ships in BOTH npm installs and marketplace-cache clones
49
- * (same rationale as build.ts `readPluginVersion`). Falls back to `dev`.
46
+ * Read the installed maude version.
47
+ *
48
+ * THE ONE resolution path (DDR-045: disk paths come from `paths.ts`). Everything
49
+ * that wants to name the running version — the What's New feed, `/_config`, the
50
+ * status-bar chip — calls this rather than growing a second answer that can
51
+ * disagree with the first.
52
+ *
53
+ * Two sources, in order:
54
+ *
55
+ * 1. the studio's OWN `package.json`. It rides the release line since the
56
+ * fleet-verification change, and it is the only one of the two that is
57
+ * staged into the cell image — a cloud tab asking "which version am I on"
58
+ * gets an answer rather than `dev`.
59
+ * 2. `plugins/design/.claude-plugin/plugin.json`, which ships in BOTH npm
60
+ * installs and marketplace-cache clones (same rationale as build.ts
61
+ * `readPluginVersion`). Kept as the fallback so an older layout that lacks
62
+ * a version-stamped studio manifest still resolves.
63
+ *
64
+ * Falls back to `dev` — a placeholder that is obviously not a release, never a
65
+ * plausible-looking wrong number.
50
66
  */
51
67
  export function resolveMaudeVersion(root: string = DEV_SERVER_ROOT): string {
52
- const manifest = join(root, '..', '..', 'plugins', 'design', '.claude-plugin', 'plugin.json');
53
- try {
54
- const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown };
55
- if (typeof parsed.version === 'string') return parsed.version;
56
- } catch {
57
- /* fall through */
68
+ const candidates = [
69
+ join(root, 'package.json'),
70
+ join(root, '..', '..', 'plugins', 'design', '.claude-plugin', 'plugin.json'),
71
+ ];
72
+ for (const manifest of candidates) {
73
+ try {
74
+ const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { version?: unknown };
75
+ // `0.0.0` is the private-workspace placeholder these manifests carried
76
+ // before they joined the release line. Treating it as an answer is how
77
+ // `/health` came to report `0.0.0` for months.
78
+ if (typeof parsed.version === 'string' && parsed.version !== '0.0.0') return parsed.version;
79
+ } catch {
80
+ /* try the next one */
81
+ }
58
82
  }
59
83
  return 'dev';
60
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.57.0",
3
+ "version": "0.58.1",
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": "0.57.0",
58
- "@1agh/maude-darwin-x64": "0.57.0",
59
- "@1agh/maude-linux-arm64": "0.57.0",
60
- "@1agh/maude-linux-arm64-musl": "0.57.0",
61
- "@1agh/maude-linux-x64": "0.57.0",
62
- "@1agh/maude-linux-x64-musl": "0.57.0",
63
- "@1agh/maude-win32-x64": "0.57.0"
57
+ "@1agh/maude-darwin-arm64": "0.58.1",
58
+ "@1agh/maude-darwin-x64": "0.58.1",
59
+ "@1agh/maude-linux-arm64": "0.58.1",
60
+ "@1agh/maude-linux-arm64-musl": "0.58.1",
61
+ "@1agh/maude-linux-x64": "0.58.1",
62
+ "@1agh/maude-linux-x64-musl": "0.58.1",
63
+ "@1agh/maude-win32-x64": "0.58.1"
64
64
  },
65
65
  "files": [
66
66
  "cli",