@1agh/maude 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/bin/maude.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { dirname, resolve } from 'node:path';
3
4
  // maude — Maude CLI. Scaffold .ai workspace, run dev servers, manage config.
4
5
  import { fileURLToPath } from 'node:url';
6
+ import { runUpdateCheck } from '../lib/update-check.mjs';
5
7
 
6
8
  const __filename = fileURLToPath(import.meta.url);
7
9
  const __dirname = dirname(__filename);
@@ -16,10 +18,24 @@ const COMMANDS = {
16
18
  version: () => import('../commands/version.mjs'),
17
19
  };
18
20
 
21
+ function readCurrentVersion() {
22
+ try {
23
+ const raw = readFileSync(resolve(PKG_ROOT, 'package.json'), 'utf8');
24
+ return JSON.parse(raw).version || null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
19
30
  async function main(argv) {
20
31
  const args = argv.slice(2);
21
32
  const cmd = args[0];
22
33
 
34
+ // Print "update available" notice (from cached registry data) before
35
+ // dispatch, so it lands on stderr ahead of any subcommand output. Hot
36
+ // path is sync + non-blocking — the stale-cache refresh is detached.
37
+ runUpdateCheck(readCurrentVersion());
38
+
23
39
  if (!cmd || cmd === '--help' || cmd === '-h') {
24
40
  const { run } = await COMMANDS.help();
25
41
  return run({ args: args.slice(1), pkgRoot: PKG_ROOT });
@@ -0,0 +1,145 @@
1
+ // Update-availability notifier for the `maude` CLI.
2
+ //
3
+ // Design (intentional, see PR discussion):
4
+ // • Hot path reads ONLY the local cache. Never blocks on a network fetch.
5
+ // • A detached child process refreshes the cache when stale (>24h). The
6
+ // notice for a freshly published version therefore appears on the run
7
+ // AFTER the cache rolls — same pattern as `update-notifier`.
8
+ // • Opt-out via MAUDE_NO_UPDATE_CHECK=1, NO_UPDATE_NOTIFIER=1, CI=true,
9
+ // or any non-TTY stderr (pipes, CI logs, etc.).
10
+ // • Best-effort everywhere: cache read/write, registry fetch, and spawn
11
+ // all swallow errors. The CLI must never fail because of this module.
12
+
13
+ import { spawn } from 'node:child_process';
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const PKG_NAME = '@1agh/maude';
20
+ const REGISTRY_URL = `https://registry.npmjs.org/${PKG_NAME}/latest`;
21
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
22
+ const FETCH_TIMEOUT_MS = 3000;
23
+
24
+ function cachePath() {
25
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
26
+ return join(base, 'maude', 'update-check.json');
27
+ }
28
+
29
+ function shouldSkip() {
30
+ if (process.env.MAUDE_NO_UPDATE_CHECK) return true;
31
+ if (process.env.NO_UPDATE_NOTIFIER) return true;
32
+ if (process.env.CI) return true;
33
+ if (!process.stderr.isTTY) return true;
34
+ return false;
35
+ }
36
+
37
+ // Compare semver x.y.z (ignoring prerelease/build metadata). Returns
38
+ // positive if a > b, negative if a < b, 0 if equal.
39
+ export function cmpSemver(a, b) {
40
+ const parse = (v) =>
41
+ String(v)
42
+ .split(/[-+]/)[0]
43
+ .split('.')
44
+ .map((n) => Number(n) || 0);
45
+ const pa = parse(a);
46
+ const pb = parse(b);
47
+ for (let i = 0; i < 3; i += 1) {
48
+ const x = pa[i] || 0;
49
+ const y = pb[i] || 0;
50
+ if (x !== y) return x - y;
51
+ }
52
+ return 0;
53
+ }
54
+
55
+ function readCacheSync() {
56
+ try {
57
+ const raw = readFileSync(cachePath(), 'utf8');
58
+ const parsed = JSON.parse(raw);
59
+ if (typeof parsed?.latest === 'string' && typeof parsed?.checkedAt === 'number') {
60
+ return parsed;
61
+ }
62
+ } catch {
63
+ /* missing or corrupt — treat as no cache */
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function writeCacheSync(data) {
69
+ const path = cachePath();
70
+ try {
71
+ mkdirSync(dirname(path), { recursive: true });
72
+ writeFileSync(path, JSON.stringify(data), 'utf8');
73
+ } catch {
74
+ /* read-only fs / no permission — non-fatal */
75
+ }
76
+ }
77
+
78
+ function printNotice(current, latest) {
79
+ const msg = [
80
+ '',
81
+ ` ⚠ maude update available: ${current} → ${latest}`,
82
+ ` Run: npm i -g ${PKG_NAME}@latest (or pnpm add -g / bun add -g)`,
83
+ '',
84
+ '',
85
+ ].join('\n');
86
+ process.stderr.write(msg);
87
+ }
88
+
89
+ function spawnDetachedRefresh() {
90
+ try {
91
+ const self = fileURLToPath(import.meta.url);
92
+ const child = spawn(process.execPath, [self, '--refresh'], {
93
+ detached: true,
94
+ stdio: 'ignore',
95
+ windowsHide: true,
96
+ env: { ...process.env, MAUDE_UPDATE_CHECK_CHILD: '1' },
97
+ });
98
+ child.unref();
99
+ } catch {
100
+ /* spawn failed — skip the refresh, will retry next run */
101
+ }
102
+ }
103
+
104
+ async function refreshCache() {
105
+ const ac = new AbortController();
106
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
107
+ try {
108
+ const res = await fetch(REGISTRY_URL, {
109
+ headers: { accept: 'application/json' },
110
+ signal: ac.signal,
111
+ });
112
+ if (!res.ok) return;
113
+ const json = await res.json();
114
+ if (typeof json?.version === 'string') {
115
+ writeCacheSync({ latest: json.version, checkedAt: Date.now() });
116
+ }
117
+ } catch {
118
+ /* offline, DNS failure, registry down — ignore */
119
+ } finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Run the update check from the CLI entry. Synchronous-fast: reads cache,
126
+ * prints notice if outdated, kicks off a detached refresh when the cache
127
+ * is stale, returns immediately.
128
+ */
129
+ export function runUpdateCheck(currentVersion) {
130
+ if (shouldSkip()) return;
131
+ if (!currentVersion) return;
132
+
133
+ const cache = readCacheSync();
134
+ if (cache?.latest && cmpSemver(cache.latest, currentVersion) > 0) {
135
+ printNotice(currentVersion, cache.latest);
136
+ }
137
+ const isStale = !cache || Date.now() - cache.checkedAt > CACHE_TTL_MS;
138
+ if (isStale) spawnDetachedRefresh();
139
+ }
140
+
141
+ // Entrypoint when invoked as a detached refresh child:
142
+ // node cli/lib/update-check.mjs --refresh
143
+ if (process.argv[1]?.endsWith('update-check.mjs') && process.argv.includes('--refresh')) {
144
+ refreshCache().catch(() => process.exit(0));
145
+ }
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { cmpSemver } from './update-check.mjs';
4
+
5
+ test('cmpSemver: equal versions return 0', () => {
6
+ assert.equal(cmpSemver('0.18.2', '0.18.2'), 0);
7
+ assert.equal(cmpSemver('1.0.0', '1.0.0'), 0);
8
+ });
9
+
10
+ test('cmpSemver: newer patch > older', () => {
11
+ assert.ok(cmpSemver('0.18.3', '0.18.2') > 0);
12
+ assert.ok(cmpSemver('0.18.2', '0.18.3') < 0);
13
+ });
14
+
15
+ test('cmpSemver: newer minor > older', () => {
16
+ assert.ok(cmpSemver('0.19.0', '0.18.99') > 0);
17
+ assert.ok(cmpSemver('0.18.99', '0.19.0') < 0);
18
+ });
19
+
20
+ test('cmpSemver: newer major > older', () => {
21
+ assert.ok(cmpSemver('1.0.0', '0.99.99') > 0);
22
+ });
23
+
24
+ test('cmpSemver: prerelease tags stripped (compares core only)', () => {
25
+ assert.equal(cmpSemver('0.18.2-beta.1', '0.18.2'), 0);
26
+ assert.ok(cmpSemver('0.19.0-rc.0', '0.18.2') > 0);
27
+ });
28
+
29
+ test('cmpSemver: missing components default to 0', () => {
30
+ assert.equal(cmpSemver('1', '1.0.0'), 0);
31
+ assert.equal(cmpSemver('1.2', '1.2.0'), 0);
32
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.18.2",
3
+ "version": "0.19.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": {
@@ -41,13 +41,13 @@
41
41
  "prepublishOnly": "bash scripts/check-version-parity.sh"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@1agh/maude-darwin-arm64": "0.18.2",
45
- "@1agh/maude-darwin-x64": "0.18.2",
46
- "@1agh/maude-linux-arm64": "0.18.2",
47
- "@1agh/maude-linux-arm64-musl": "0.18.2",
48
- "@1agh/maude-linux-x64": "0.18.2",
49
- "@1agh/maude-linux-x64-musl": "0.18.2",
50
- "@1agh/maude-win32-x64": "0.18.2"
44
+ "@1agh/maude-darwin-arm64": "0.19.0",
45
+ "@1agh/maude-darwin-x64": "0.19.0",
46
+ "@1agh/maude-linux-arm64": "0.19.0",
47
+ "@1agh/maude-linux-arm64-musl": "0.19.0",
48
+ "@1agh/maude-linux-x64": "0.19.0",
49
+ "@1agh/maude-linux-x64-musl": "0.19.0",
50
+ "@1agh/maude-win32-x64": "0.19.0"
51
51
  },
52
52
  "files": [
53
53
  "cli",
@@ -839,12 +839,12 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
839
839
  }
840
840
  const items: { label: string; path: string; group: string }[] = [];
841
841
  for (const m of matches) {
842
- const files = await findFiles(m.abs, m.rel, ['.html']);
842
+ const files = await findFiles(m.abs, m.rel, ['.tsx', '.html']);
843
843
  for (const f of files) {
844
844
  const fname = f
845
845
  .split('/')
846
846
  .pop()
847
- ?.replace(/\.html$/i, '');
847
+ ?.replace(/\.(tsx|html)$/i, '');
848
848
  const group = f.split('/').slice(-2, -1)[0] || folderName;
849
849
  const label = fname.toLowerCase() === 'index' ? group : fname;
850
850
  items.push({ label, path: f, group });
@@ -27,15 +27,24 @@ import path from 'node:path';
27
27
 
28
28
  import type { BunPlugin } from 'bun';
29
29
 
30
+ import { DEV_SERVER_ROOT } from './paths.ts';
31
+
30
32
  export const CANVAS_LIB_SPECIFIER = '@maude/canvas-lib';
31
33
 
32
34
  /**
33
35
  * Returns the dev-server-internal canvas-lib path. The `_designRoot` parameter
34
36
  * is retained for one minor (back-compat with callers we don't control) but
35
37
  * ignored — canvas-lib now ships with the dev-server install (DDR-025).
38
+ *
39
+ * Uses DEV_SERVER_ROOT (paths.ts) instead of `import.meta.dir` per DDR-045 —
40
+ * inside `bun --compile` standalone binaries `import.meta.dir` resolves to the
41
+ * virtual `/$bunfs/root` and any subsequent `existsSync` / `fs.watch` against
42
+ * that path silently fails. Same bug class as v0.18.0/0.18.1; this one surfaces
43
+ * as `[canvas-lib] failed to watch ... '/$bunfs/root/canvas-lib.tsx'` at boot
44
+ * and disables canvas-lib HMR.
36
45
  */
37
46
  export function canvasLibPath(_designRoot?: string): string {
38
- return path.join(import.meta.dir, 'canvas-lib.tsx');
47
+ return path.join(DEV_SERVER_ROOT, 'canvas-lib.tsx');
39
48
  }
40
49
 
41
50
  export interface CanvasLibResolverOptions {
@@ -1073,7 +1073,7 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
1073
1073
  if (t.path === SYSTEM_TAB) {
1074
1074
  return (
1075
1075
  <div key={t.path} className={'system-view' + (t.path === activePath ? ' active' : '')}>
1076
- <SystemView data={systemData} onOpen={onOpenFromSystem} />
1076
+ <SystemView data={systemData} onOpen={onOpenFromSystem} cfg={cfg} />
1077
1077
  </div>
1078
1078
  );
1079
1079
  }
@@ -1152,7 +1152,7 @@ function TypeLadder() {
1152
1152
  );
1153
1153
  }
1154
1154
 
1155
- function SystemView({ data, onOpen }) {
1155
+ function SystemView({ data, onOpen, cfg }) {
1156
1156
  if (!data) {
1157
1157
  return <div className="sv-empty"><p>Loading design system…</p></div>;
1158
1158
  }
@@ -1176,15 +1176,15 @@ function SystemView({ data, onOpen }) {
1176
1176
  </div>
1177
1177
  ) : (
1178
1178
  <>
1179
- <Gallery title="preview" items={previewGallery} onOpen={onOpen} kind="preview" />
1180
- <Gallery title="ui kits" items={uiKitsGallery} onOpen={onOpen} kind="ui_kits" />
1179
+ <Gallery title="preview" items={previewGallery} onOpen={onOpen} kind="preview" cfg={cfg} />
1180
+ <Gallery title="ui kits" items={uiKitsGallery} onOpen={onOpen} kind="ui_kits" cfg={cfg} />
1181
1181
  </>
1182
1182
  )}
1183
1183
  </div>
1184
1184
  );
1185
1185
  }
1186
1186
 
1187
- function Gallery({ title, items, onOpen, kind }) {
1187
+ function Gallery({ title, items, onOpen, kind, cfg }) {
1188
1188
  if (!items || items.length === 0) return null;
1189
1189
  return (
1190
1190
  <section className="sv-section">
@@ -1193,7 +1193,7 @@ function Gallery({ title, items, onOpen, kind }) {
1193
1193
  {items.map(p => (
1194
1194
  <article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
1195
1195
  <div className="sv-preview-frame">
1196
- <iframe src={urlOf(p.path)} title={p.label} scrolling="no" />
1196
+ <iframe src={canvasUrl(p.path, cfg)} title={p.label} scrolling="no" />
1197
1197
  </div>
1198
1198
  <div className="sv-preview-foot">
1199
1199
  <strong>{p.label}</strong>