@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 +100 -1
- package/package.json +1 -1
- package/src/hosts/version.mjs +154 -0
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
|
@@ -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
|
+
}
|