@skyf0xx/hedgehog 4.3.6 → 4.4.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/bin/cli.mjs CHANGED
@@ -50,6 +50,7 @@ import { rebuildDb } from '../src/db/rebuild.mjs';
50
50
  import { loadOverrides, addOverride, orphanedOverrides, OVERRIDES_DIR } from '../src/db/overrides.mjs';
51
51
  import { HOSTS, HOST_FLAGS, DEFAULT_HOST, availableHosts } from '../src/hosts/index.mjs';
52
52
  import { recordHosts, installedHosts } from '../src/hosts/installed.mjs';
53
+ import { recordVersion, checkForUpdate, installedVersion } from '../src/hosts/version.mjs';
53
54
 
54
55
  const AUTHORED_CORE_PATH = '.hedgehog/core.yaml';
55
56
 
@@ -65,6 +66,12 @@ const DEST_ROOT = process.cwd();
65
66
  const CORES_ROOT = join(PKG_ROOT, 'src/golden-cores');
66
67
  const DEFAULT_CORE = 'full-stack-app';
67
68
 
69
+ // The version of the payload this CLI carries — what `init` and
70
+ // `update` stamp into the project they write to.
71
+ const PKG_VERSION = JSON.parse(
72
+ await readFile(join(PKG_ROOT, 'package.json'), 'utf8'),
73
+ ).version;
74
+
68
75
  // One install flag per core, named for what a user is asking to build
69
76
  // rather than the internal src/golden-cores/<name> directory — the two
70
77
  // diverge deliberately so the CLI's public surface can stay stable
@@ -384,6 +391,7 @@ ${bold('Usage')}
384
391
  npx @skyf0xx/hedgehog init --all-hosts install for every supported coding agent
385
392
  npx @skyf0xx/hedgehog init --force overwrite existing files
386
393
  npx @skyf0xx/hedgehog update refresh the installed agents + skills
394
+ npx @skyf0xx/hedgehog update --check report whether a newer release is published
387
395
  npx @skyf0xx/hedgehog db init create .hedgehog/hedgehog.db if absent
388
396
  npx @skyf0xx/hedgehog db rebuild re-derive the build graph from committed intents + git history
389
397
  npx @skyf0xx/hedgehog plan compile pending intents into tasks + dependencies
@@ -449,6 +457,15 @@ those directories. The instructions file, the build graph, the core
449
457
  workspace, and vendor-skills/BMAD and vendor-skills/GSAP stay as they
450
458
  are — those are project-specific or updated deliberately, not by this
451
459
  command.
460
+
461
+ Both ${bold('init')} and ${bold('update')} stamp the version they wrote into
462
+ .hedgehog/version.json. ${bold('update --check')} compares that stamp against
463
+ the newest published release and exits 1 when a newer one exists, without
464
+ writing anything. The same comparison rides on ${bold('status')} and
465
+ ${bold('next')} as a one-line notice, from a once-a-day cached lookup, so a
466
+ stale project surfaces itself; set HEDGEHOG_NO_UPDATE_CHECK=1 to silence it.
467
+ Run ${bold('npx @skyf0xx/hedgehog@latest update')} to pull a newer release —
468
+ the @latest tag matters, since a bare npx may reuse a cached older CLI.
452
469
  `);
453
470
  }
454
471
 
@@ -500,6 +517,7 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
500
517
  // Recorded before anything is written: the routing doc is generated
501
518
  // from this list, so it has to already name the host being installed.
502
519
  await recordHosts(DEST_ROOT, [host]);
520
+ await recordVersion(DEST_ROOT, PKG_VERSION);
503
521
 
504
522
  let written = 0;
505
523
  let overwritten = 0;
@@ -588,6 +606,42 @@ async function init({ force, core, explicitCore, host = DEFAULT_HOST, hostOnly =
588
606
  }
589
607
  }
590
608
 
609
+ // `update --check` answers the question without acting on it: what's
610
+ // installed, what's published, and whether they differ. Exit code 0 when
611
+ // current or unknown, 1 when an update is available, so a script or a
612
+ // host's own tooling can branch on it without parsing the output.
613
+ async function updateCheck() {
614
+ const { installed, latest, stale } = await checkForUpdate(DEST_ROOT, {
615
+ force: true,
616
+ });
617
+
618
+ if (!installed) {
619
+ console.log(
620
+ `${yellow('Installed version unknown.')} ${dim(
621
+ 'This project predates version stamping — run `hedgehog update` to refresh and stamp it.',
622
+ )}\n`,
623
+ );
624
+ return;
625
+ }
626
+ if (!latest) {
627
+ console.log(
628
+ `Installed: ${bold(installed)}\n${dim(
629
+ "Couldn't reach the npm registry — no update check performed.",
630
+ )}\n`,
631
+ );
632
+ return;
633
+ }
634
+ if (stale) {
635
+ console.log(
636
+ `${yellow(bold('Update available.'))} installed ${bold(installed)} → latest ${bold(latest)}\n\n` +
637
+ ` Run ${bold('npx @skyf0xx/hedgehog@latest update')} to refresh this project's agents and skills.\n`,
638
+ );
639
+ process.exitCode = 1;
640
+ return;
641
+ }
642
+ console.log(`${green('Up to date.')} ${dim(`installed ${installed}, latest ${latest}`)}\n`);
643
+ }
644
+
591
645
  async function update({ hosts }) {
592
646
  const targets = hosts?.length ? hosts : await installedHosts(DEST_ROOT);
593
647
 
@@ -614,10 +668,19 @@ async function update({ hosts }) {
614
668
  }
615
669
  }
616
670
 
671
+ // Stamped after the writes land, so the recorded version always
672
+ // describes the payload actually on disk.
673
+ const previous = await installedVersion(DEST_ROOT);
674
+ await recordVersion(DEST_ROOT, PKG_VERSION);
675
+
617
676
  const label = targets.map((h) => HOSTS[h].label).join(', ');
677
+ const versionNote =
678
+ previous && previous !== PKG_VERSION
679
+ ? `${previous} → ${PKG_VERSION}`
680
+ : PKG_VERSION;
618
681
  console.log(
619
682
  `\n${green(bold('Hedgehog agents/skills updated.'))} ${dim(
620
- `${written} files written for ${label}`,
683
+ `${versionNote} — ${written} files written for ${label}`,
621
684
  )}\n`,
622
685
  );
623
686
  console.log('Next steps:');
@@ -1104,6 +1167,34 @@ function warnSingularModuleIdsAtPlan(core, db) {
1104
1167
  );
1105
1168
  }
1106
1169
 
1170
+ // The passive half of update detection. Printed to stderr, after a
1171
+ // command's real output, on the commands every host's loop runs anyway —
1172
+ // so a stale project surfaces itself without anyone thinking to ask.
1173
+ //
1174
+ // This lives in the CLI rather than in any one host's hook because every
1175
+ // host drives the same binary: Claude Code, Cursor, and Gemini CLI all
1176
+ // reach it through `hedgehog next`, and one implementation here covers
1177
+ // all of them.
1178
+ //
1179
+ // Never throws and never blocks: the answer comes from a day-long cache,
1180
+ // a missing or unreachable registry reads as "no news", and the notice is
1181
+ // skipped entirely when output isn't a terminal so it can't corrupt
1182
+ // anything parsing stdout.
1183
+ async function noteAvailableUpdate() {
1184
+ if (process.env.HEDGEHOG_NO_UPDATE_CHECK) return;
1185
+ try {
1186
+ const { installed, latest, stale } = await checkForUpdate(DEST_ROOT);
1187
+ if (!stale) return;
1188
+ console.error(
1189
+ `\n${yellow(bold('Hedgehog update available.'))} ${dim(
1190
+ `${installed} → ${latest}. Run \`npx @skyf0xx/hedgehog@latest update\` to refresh this project's agents and skills.`,
1191
+ )}`,
1192
+ );
1193
+ } catch {
1194
+ // Advisory only — a failed check is never worth failing a command over.
1195
+ }
1196
+ }
1197
+
1107
1198
  async function nextCommand() {
1108
1199
  await ensureDb();
1109
1200
 
@@ -1140,6 +1231,7 @@ async function nextCommand() {
1140
1231
  return;
1141
1232
  }
1142
1233
  console.log(`${dim('No ready task.')} Nothing is planned with all dependencies complete.\n`);
1234
+ await noteAvailableUpdate();
1143
1235
  return;
1144
1236
  }
1145
1237
 
@@ -1156,6 +1248,7 @@ async function nextCommand() {
1156
1248
  }
1157
1249
 
1158
1250
  console.log(formatNext(packet, await resolveCoreId(), packetExists));
1251
+ await noteAvailableUpdate();
1159
1252
  }
1160
1253
 
1161
1254
  function printStalledTasks(stalled) {
@@ -1825,6 +1918,8 @@ async function statusCommand() {
1825
1918
 
1826
1919
  const warningLines = await coreWarningLines();
1827
1920
  if (warningLines.length > 0) console.log(warningLines.join('\n'));
1921
+
1922
+ await noteAvailableUpdate();
1828
1923
  }
1829
1924
 
1830
1925
  // `hedgehog ready` — read-only preview of what a `hedgehog claim` call
@@ -2441,6 +2536,10 @@ async function main() {
2441
2536
  }
2442
2537
 
2443
2538
  if (cmd === 'update') {
2539
+ if (args.includes('--check')) {
2540
+ await updateCheck();
2541
+ return;
2542
+ }
2444
2543
  await update({ hosts });
2445
2544
  return;
2446
2545
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyf0xx/hedgehog",
3
- "version": "4.3.6",
3
+ "version": "4.4.0",
4
4
  "description": "Install the Hedgehog build discipline (agents + skills) into a repo, for Claude Code, Cursor, or Gemini CLI.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,154 @@
1
+ // What version of the payload a project has, and whether a newer one exists.
2
+ //
3
+ // `init` and `update` stamp the version they wrote into
4
+ // `.hedgehog/version.json`. Without that stamp a project's installed
5
+ // agents and skills are indistinguishable from any other release's, so
6
+ // staleness can only be discovered by reading the files themselves.
7
+ //
8
+ // The registry lookup is cached and best-effort: a project that can't
9
+ // reach npm, or is offline entirely, gets no answer rather than an error.
10
+ // Nothing here is on the path of any command that has real work to do.
11
+
12
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ const VERSION_PATH = '.hedgehog/version.json';
16
+ const REGISTRY_URL = 'https://registry.npmjs.org/@skyf0xx/hedgehog/latest';
17
+
18
+ // How long a registry answer stays good. The payload ships daily, so a
19
+ // once-a-day question matches how often the answer can actually change,
20
+ // and keeps a session that runs many commands to a single network call.
21
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
22
+
23
+ // The check is advisory — it must never delay a command that has real
24
+ // work to do, so a registry that hangs is treated the same as one that
25
+ // says nothing.
26
+ const FETCH_TIMEOUT_MS = 1500;
27
+
28
+ /**
29
+ * Record the payload version now installed in `root`. Called by both
30
+ * `init` and `update`, so the stamp tracks the payload on disk rather
31
+ * than whichever release first landed.
32
+ */
33
+ export async function recordVersion(root, version) {
34
+ const path = join(root, VERSION_PATH);
35
+ const prior = await readVersionFile(root);
36
+ await mkdir(dirname(path), { recursive: true });
37
+ await writeFile(
38
+ path,
39
+ `${JSON.stringify({ ...prior, version, installedAt: new Date().toISOString() }, null, 2)}\n`,
40
+ );
41
+ return version;
42
+ }
43
+
44
+ async function readVersionFile(root) {
45
+ try {
46
+ return JSON.parse(await readFile(join(root, VERSION_PATH), 'utf8'));
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The payload version installed in `root`, or null for a project
54
+ * installed before the stamp existed.
55
+ */
56
+ export async function installedVersion(root) {
57
+ const { version } = await readVersionFile(root);
58
+ return typeof version === 'string' ? version : null;
59
+ }
60
+
61
+ /**
62
+ * Compare two semver strings. Returns true when `a` is strictly newer
63
+ * than `b`. Prerelease tags sort before their release, matching semver;
64
+ * anything unparseable compares as equal so a malformed version can
65
+ * never fabricate an update prompt.
66
+ */
67
+ export function isNewer(a, b) {
68
+ const parse = (v) => {
69
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v ?? '').trim());
70
+ return m
71
+ ? { nums: [+m[1], +m[2], +m[3]], pre: m[4] ?? null }
72
+ : null;
73
+ };
74
+ const pa = parse(a);
75
+ const pb = parse(b);
76
+ if (!pa || !pb) return false;
77
+ for (let i = 0; i < 3; i++) {
78
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i];
79
+ }
80
+ // Equal release numbers: a release outranks its own prereleases.
81
+ if (pa.pre === pb.pre) return false;
82
+ if (pa.pre === null) return true;
83
+ if (pb.pre === null) return false;
84
+ return pa.pre > pb.pre;
85
+ }
86
+
87
+ /**
88
+ * The newest published version, from cache when it's fresh and from the
89
+ * registry otherwise. Returns null on any failure — offline, timeout,
90
+ * rate limit, malformed response. A null answer means "don't know", and
91
+ * every caller treats that as "say nothing".
92
+ */
93
+ export async function latestVersion(root, { force = false } = {}) {
94
+ const cached = await readVersionFile(root);
95
+ if (!force && cached.checkedAt && cached.latest) {
96
+ const age = Date.now() - Date.parse(cached.checkedAt);
97
+ if (age >= 0 && age < CACHE_TTL_MS) return cached.latest;
98
+ }
99
+
100
+ const latest = await fetchLatest();
101
+ if (!latest) return null;
102
+
103
+ // Cache alongside the install stamp rather than in a separate file, so
104
+ // one read answers both halves of the question.
105
+ const path = join(root, VERSION_PATH);
106
+ try {
107
+ await mkdir(dirname(path), { recursive: true });
108
+ await writeFile(
109
+ path,
110
+ `${JSON.stringify({ ...cached, latest, checkedAt: new Date().toISOString() }, null, 2)}\n`,
111
+ );
112
+ } catch {
113
+ // A read-only or missing .hedgehog just means no caching; the
114
+ // answer we fetched is still good for this call.
115
+ }
116
+ return latest;
117
+ }
118
+
119
+ async function fetchLatest() {
120
+ try {
121
+ // No `accept` header. The abbreviated-metadata type
122
+ // (application/vnd.npm.install-v1+json) is only valid on the
123
+ // packument root — sending it to /latest returns 406, which this
124
+ // function would swallow as "no answer" and the check would
125
+ // silently never fire. Plain /latest returns just the one version
126
+ // object, which is also far smaller than the full packument.
127
+ const res = await fetch(REGISTRY_URL, {
128
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
129
+ });
130
+ if (!res.ok) return null;
131
+ const { version } = await res.json();
132
+ return typeof version === 'string' ? version : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The full staleness picture for a project: what's installed, what's
140
+ * published, and whether the gap is worth telling anyone about.
141
+ *
142
+ * `stale` is true only when both versions are known and the published
143
+ * one is newer — an unknown on either side reads as "no", so a project
144
+ * installed before stamping and an offline machine both stay quiet.
145
+ */
146
+ export async function checkForUpdate(root, { force = false } = {}) {
147
+ const installed = await installedVersion(root);
148
+ const latest = await latestVersion(root, { force });
149
+ return {
150
+ installed,
151
+ latest,
152
+ stale: Boolean(installed && latest && isNewer(latest, installed)),
153
+ };
154
+ }