geneseed 4.1.0 → 4.3.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/CHANGELOG.md +43 -0
- package/README.md +4 -4
- package/SHIPPED.md +3 -3
- package/adapters/claude-code/README.md +0 -21
- package/adapters/opencode/plugins/geneseed-guard.js +31 -0
- package/docs/concepts/agents.md +1 -0
- package/docs/guides/uninstall.md +2 -0
- package/docs/guides/upgrade.md +2 -0
- package/docs/token-footprint.md +1 -1
- package/docs/understand/take-it-out.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +6 -0
- package/js/build/driver.mjs +47 -11
- package/js/build/emit-claude.mjs +22 -10
- package/js/build/emit-common.mjs +9 -3
- package/js/build/emit-opencode.mjs +18 -11
- package/js/build/source.mjs +1 -0
- package/js/build/version.mjs +10 -3
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks.mjs +4 -0
- package/js/hosts/installs.mjs +1 -1
- package/js/hosts/native.mjs +38 -6
- package/js/hosts/owned.mjs +159 -0
- package/js/hosts/settings.mjs +4 -0
- package/js/hosts/usage.mjs +62 -0
- package/js/inspect/checks-repo.mjs +76 -1
- package/js/inspect/cost.mjs +83 -0
- package/js/inspect/doctor.mjs +2 -1
- package/js/inspect/inventory.mjs +1 -1
- package/js/maintain/uninstall.mjs +77 -42
- package/js/web/api.mjs +66 -3
- package/js/web/gates-api.mjs +44 -0
- package/js/web/routes.mjs +8 -2
- package/js/web/update-check.mjs +61 -0
- package/js/web/usage-api.mjs +95 -0
- package/package.json +1 -1
- package/registry.json +25 -25
- package/src/AGENT.md.tmpl +1 -1
- package/src/agents/performance.md +72 -0
- package/src/laws/universal.md +7 -5
- package/src/skills/council.md +1 -1
- package/src/skills/develop.md +9 -0
- package/src/skills/geneseed-code-review.md +5 -1
- package/src/skills/security-audit.md +7 -1
- package/src/skills/wayfinder.md +25 -6
- package/src/skills/worktree.md +5 -5
- package/themes/_TEMPLATE.json +1 -1
- package/themes/biker.json +1 -1
- package/themes/commentator.json +1 -1
- package/themes/cyberpunk.json +1 -1
- package/themes/gamer.json +1 -1
- package/themes/imperial.json +1 -1
- package/themes/joker.json +1 -1
- package/themes/marvin.json +1 -1
- package/themes/mean.json +1 -1
- package/themes/military.json +1 -1
- package/themes/neutral.json +1 -1
- package/themes/pirate.json +1 -1
- package/themes/sports.json +1 -1
- package/themes/verstappen.json +1 -1
- package/themes/wizard.json +1 -1
- package/web/dist/assets/{Activity-CqVt2u9s.js → Activity-DDTRkc4M.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bui5Gomc.js → ActivityDetail-C41RsH-S.js} +1 -1
- package/web/dist/assets/Dashboard-B0DJ2oUk.js +2 -0
- package/web/dist/assets/Docs-DBj7-n9F.js +5 -0
- package/web/dist/assets/{GreenhouseView-DgNY8QKU.js → GreenhouseView-Bz-Q1Xl-.js} +1 -1
- package/web/dist/assets/{Installs-DZwZOUZJ.js → Installs-BNlWdJJE.js} +2 -2
- package/web/dist/assets/{JournalView-DXpFbKOG.js → JournalView-BkwmfkEy.js} +1 -1
- package/web/dist/assets/{Laws-Bh7ztekk.js → Laws-CzvMZCUL.js} +2 -2
- package/web/dist/assets/Library-BgIpOTHi.js +1 -0
- package/web/dist/assets/{Loops-CvFMZ7_P.js → Loops-s_NsuNaQ.js} +1 -1
- package/web/dist/assets/{OperatorHudView-FPTUH1CB.js → OperatorHudView-CgeWJnPI.js} +1 -1
- package/web/dist/assets/{Personal-DcVA502F.js → Personal-DMM6jfE0.js} +1 -1
- package/web/dist/assets/{Tabs-DtyR4h75.js → Tabs-CPpcsYDc.js} +1 -1
- package/web/dist/assets/api-Bp5eW_6m.js +1 -0
- package/web/dist/assets/{drawable-CNSPVZxX.js → drawable-D-fIT_ow.js} +1 -1
- package/web/dist/assets/{index-Ctp-4poP.css → index-CO-9wMJY.css} +1 -1
- package/web/dist/assets/index-DXIa0ZG_.js +2 -0
- package/web/dist/index.html +3 -3
- package/src/skills/herdr.md +0 -154
- package/web/dist/assets/Dashboard-VlIx1-At.js +0 -2
- package/web/dist/assets/Docs-6cYYkVeA.js +0 -5
- package/web/dist/assets/Library-DBLX-1SD.js +0 -1
- package/web/dist/assets/api-CRqWld2H.js +0 -1
- package/web/dist/assets/index-BmjcLIOr.js +0 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT ONE INSTALL COSTS EVERY SESSION, read off the files it emitted — the Overview's
|
|
3
|
+
* always-on figure. Characters, not tokens: the console shows ≈ chars ÷ 4 and says it is an
|
|
4
|
+
* estimate, because a tokenizer would be a vendored dependency for a number that only has to
|
|
5
|
+
* be honest about its size.
|
|
6
|
+
*
|
|
7
|
+
* Four parts, each the thing the host loads before the user types:
|
|
8
|
+
* - `carrier` the root instruction file (`AGENT.md`, `CLAUDE.md`, Bob's `rules/geneseed.md` or
|
|
9
|
+
* `AGENTS.md`), CRLF decoded exactly as the smoke test decodes it, so the figure
|
|
10
|
+
* and `CARRIER_CEILING` are the same measurement.
|
|
11
|
+
* - `skills` the name + description every native skill lists (aliases included: they are
|
|
12
|
+
* SKILL.md files the host lists like any other).
|
|
13
|
+
* - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
|
|
14
|
+
* - `context` the session files the context hook injects (untouched seeds already skipped).
|
|
15
|
+
* The per-repo project context is NOT counted: it changes with the working dir.
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { sessionFiles } from '../hosts/hooks-context.mjs';
|
|
20
|
+
import { carriersFor } from '../hosts/installs.mjs';
|
|
21
|
+
import { frontmatter } from '../hosts/memory-files.mjs';
|
|
22
|
+
import { listDir } from '../hosts/hooks-prims.mjs';
|
|
23
|
+
import { isFile, readText } from '../lib/fs.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* THE ANTI-BLOAT CEILING on the carrier, per footprint — the most a root file may weigh with
|
|
27
|
+
* every doctrine pack active. `tests/unit/emit_smoke.test.mjs` gates every emit against it and the
|
|
28
|
+
* console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
|
|
29
|
+
* exactly the measured overrun and say why in that test's docblock, which keeps its history.
|
|
30
|
+
*/
|
|
31
|
+
export const CARRIER_CEILING = { full: 62_076, lean: 38_890 };
|
|
32
|
+
|
|
33
|
+
const decode = (p) => readText(p).split('\r\n').join('\n');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A frontmatter value as the host shows it. The emit writes descriptions JSON-escaped
|
|
37
|
+
* (`—` for one em dash), and `frontmatter()` has already stripped the quotes, so the
|
|
38
|
+
* escapes are decoded here or one character counts as six.
|
|
39
|
+
*/
|
|
40
|
+
function fmValue(raw) {
|
|
41
|
+
if (!raw) return '';
|
|
42
|
+
if (raw.includes('\\')) {
|
|
43
|
+
try { return JSON.parse(`"${raw}"`); } catch { /* not a JSON escape after all: count as written */ }
|
|
44
|
+
}
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** name + description of one spec file; `fallbackName` for agents, whose name is the file stem. */
|
|
49
|
+
function listedChars(file, fallbackName) {
|
|
50
|
+
let fm;
|
|
51
|
+
try { [fm] = frontmatter(decode(file)); } catch { return 0; }
|
|
52
|
+
const name = fmValue(fm.get('name')) || fallbackName;
|
|
53
|
+
return name.length + fmValue(fm.get('description')).length;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `root` is the install's root (`installTargets`), `dir` its data dir (`viewCfg`), which holds
|
|
58
|
+
* `skills/`, `agents/` and the session files. Every part that cannot be read counts 0; a missing
|
|
59
|
+
* carrier answers `null` for the whole cost, so the console shows "not measured", not a tiny bar.
|
|
60
|
+
*/
|
|
61
|
+
export function alwaysOnCost({ host, root, dir, footprint }) {
|
|
62
|
+
const carrierPath = carriersFor(host).map((rel) => path.join(root, rel)).find(isFile);
|
|
63
|
+
if (!carrierPath) return null;
|
|
64
|
+
const carrier = decode(carrierPath).length;
|
|
65
|
+
|
|
66
|
+
let skills = 0;
|
|
67
|
+
const skillsDir = path.join(dir, 'skills');
|
|
68
|
+
for (const name of listDir(skillsDir)) {
|
|
69
|
+
const f = path.join(skillsDir, name, 'SKILL.md');
|
|
70
|
+
if (isFile(f)) skills += listedChars(f, name);
|
|
71
|
+
}
|
|
72
|
+
let agents = 0;
|
|
73
|
+
if (host !== 'bob') {
|
|
74
|
+
const agentsDir = path.join(dir, 'agents');
|
|
75
|
+
for (const name of listDir(agentsDir)) {
|
|
76
|
+
if (name.endsWith('.md')) agents += listedChars(path.join(agentsDir, name), name.slice(0, -3));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let context = 0;
|
|
80
|
+
try { for (const s of sessionFiles(dir)) context += s.text.length; } catch { /* none */ }
|
|
81
|
+
|
|
82
|
+
return { carrier, skills, agents, context, ceiling: CARRIER_CEILING[footprint] ?? null };
|
|
83
|
+
}
|
package/js/inspect/doctor.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { authoringProblems, loopProblems } from './checks-authoring.mjs';
|
|
|
42
42
|
import {
|
|
43
43
|
checkBuild, colorThemeProblems, renderedProblems, themeParityProblems,
|
|
44
44
|
} from './checks-build.mjs';
|
|
45
|
-
import { moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
|
|
45
|
+
import { hostMatrixProblems, moduleMapProblems, scorecardProblems, shimProblems } from './checks-repo.mjs';
|
|
46
46
|
import { NOTE, isDoctorNote, sortedProblems, sortedUnique, stemOf, withTempDir } from './scan.mjs';
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -249,6 +249,7 @@ export function doctorCollect({
|
|
|
249
249
|
problems = problems.concat(ran('openclaude_dual', 'OpenClaude dual harness',
|
|
250
250
|
openclaudeDualHarnessNotes()));
|
|
251
251
|
problems = problems.concat(ran('map', 'Module map', moduleMapProblems()));
|
|
252
|
+
problems = problems.concat(ran('host_matrix', 'Host tables', hostMatrixProblems()));
|
|
252
253
|
problems = problems.concat(ran('scorecard', 'Scorecard floor', scorecardProblems()));
|
|
253
254
|
// P10c's `cli` check is GONE, and the reason is not that it stopped mattering. It hashed
|
|
254
255
|
// `rituals/harness.py` and compared that against a digest baked into `cli.json`, to catch a
|
package/js/inspect/inventory.mjs
CHANGED
|
@@ -106,7 +106,7 @@ export const SKILL_CLASS = {
|
|
|
106
106
|
// knowledge base, terminal) rather than about the user's code. They sat under
|
|
107
107
|
// `understand` before, which made that class a grab-bag.
|
|
108
108
|
wiki: 'harness', geneseed: 'harness', rule: 'harness', profile: 'harness',
|
|
109
|
-
|
|
109
|
+
'skill-forge': 'harness',
|
|
110
110
|
teach: 'learn', quiz: 'learn', 'learn-mode': 'learn',
|
|
111
111
|
};
|
|
112
112
|
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
resolvePath, settingsFile,
|
|
60
60
|
} from '../hosts/hosts.mjs';
|
|
61
61
|
import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
|
|
62
|
+
import { hashesOf, ownedState, ownedWithin } from '../hosts/owned.mjs';
|
|
62
63
|
import {
|
|
63
64
|
atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
|
|
64
65
|
loadJsonObject, mergeClaudeSettings, opencodeTarget, OPENCODE_SENTINEL, opencodeSentinelWrite,
|
|
@@ -67,7 +68,7 @@ import {
|
|
|
67
68
|
} from '../hosts/settings.mjs';
|
|
68
69
|
import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
|
|
69
70
|
import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
|
|
70
|
-
import { comparePaths, isAbsolutePath
|
|
71
|
+
import { comparePaths, isAbsolutePath } from '../lib/paths.mjs';
|
|
71
72
|
|
|
72
73
|
const hostSpec = (host) => HOSTS.find((h) => h.host === host);
|
|
73
74
|
|
|
@@ -145,7 +146,7 @@ function pruneAncestors(start, stop) {
|
|
|
145
146
|
|
|
146
147
|
/**
|
|
147
148
|
* The manifest's `owned` list, unlinked one by one with the ancestor prune, returning
|
|
148
|
-
* `[removed, failed]`.
|
|
149
|
+
* `[removed, failed, kept]`.
|
|
149
150
|
*
|
|
150
151
|
* The third copy of this loop in `_harness_mcp.py` and the reason it is one function here:
|
|
151
152
|
* `_uninstall_global` (rooted at the target), `_claude_uninstall` (rooted at the cfg dir) and
|
|
@@ -157,15 +158,28 @@ function pruneAncestors(start, stop) {
|
|
|
157
158
|
* `ownedWithin` first, the same containment boundary deactivate draws: the manifest is a file
|
|
158
159
|
* on disk, and without it an `owned` entry of `../../.bashrc` was unlinked — and the prune
|
|
159
160
|
* then climbed from ITS parent, which never meets `base`, removing empty directories as it went.
|
|
160
|
-
*
|
|
161
|
+
*
|
|
162
|
+
* Task 5 (O-1's uninstall half): `hashes` is the manifest's recorded `hashes` map (`hashesOf`,
|
|
163
|
+
* `{}` for an older manifest — the constraint that keeps a hash-less install's behaviour
|
|
164
|
+
* unchanged). Unless `force`, a rel whose `ownedState` reads `'modified'` is left on disk rather
|
|
165
|
+
* than unlinked and lands in `kept`, not `failed` — it is not an error, the file just is not
|
|
166
|
+
* Geneseed's to delete any more once the user has edited it. `'unknown'` and `'missing'` are
|
|
167
|
+
* NOT `'modified'` and are unlinked exactly as before, so an older, hash-less manifest's
|
|
168
|
+
* uninstall is byte-identical to today's.
|
|
169
|
+
* @returns {[number, string[], string[]]}
|
|
161
170
|
*/
|
|
162
|
-
function unlinkOwned(base, owned, label = '') {
|
|
171
|
+
function unlinkOwned(base, owned, label = '', hashes = {}, force = false) {
|
|
163
172
|
let removed = 0;
|
|
164
173
|
const failed = [];
|
|
174
|
+
const kept = [];
|
|
165
175
|
for (const rel of ownedWithin(base, owned)) {
|
|
166
176
|
const victim = path.join(base, rel);
|
|
167
177
|
try {
|
|
168
178
|
if (isFile(victim)) {
|
|
179
|
+
if (!force && ownedState(base, rel, hashes) === 'modified') {
|
|
180
|
+
kept.push(`${label}${rel}`);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
169
183
|
unlinkSync(victim);
|
|
170
184
|
removed += 1;
|
|
171
185
|
pruneAncestors(path.dirname(victim), base);
|
|
@@ -178,24 +192,7 @@ function unlinkOwned(base, owned, label = '') {
|
|
|
178
192
|
failed.push(`${label}${rel} (${e && e.message ? e.message : e})`);
|
|
179
193
|
}
|
|
180
194
|
}
|
|
181
|
-
return [removed, failed];
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
|
|
186
|
-
* `owned` goes through (uninstall, both deactivates).
|
|
187
|
-
*
|
|
188
|
-
* `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
|
|
189
|
-
* boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
|
|
190
|
-
* `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
|
|
191
|
-
* `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
|
|
192
|
-
* concatenated it into `<root>/C:/evil.md`, which passes containment and would then be acted
|
|
193
|
-
* on. `path.resolve` has pathlib's semantics for exactly this case, and collapses `..`
|
|
194
|
-
* besides, which is why the guard runs on the resolved path rather than the lexical one.
|
|
195
|
-
*/
|
|
196
|
-
function ownedWithin(base, rels) {
|
|
197
|
-
const rroot = resolvePath(base);
|
|
198
|
-
return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
|
|
195
|
+
return [removed, failed, kept];
|
|
199
196
|
}
|
|
200
197
|
|
|
201
198
|
/** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
|
|
@@ -204,6 +201,19 @@ function warnSurvivors(failed) {
|
|
|
204
201
|
+ `${failed.length} owned file(s): ${failed.join(', ')}\n`);
|
|
205
202
|
}
|
|
206
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The message a reversal prints for every rel `unlinkOwned` left on disk because the user had
|
|
206
|
+
* edited it and `--force` was not given. Separate from `warnSurvivors`: a kept file is not an
|
|
207
|
+
* error (nothing failed) — it is the user's now, exactly like a refused claim on re-emit, and
|
|
208
|
+
* the uninstall completes around it. So the hint is `--force` on the uninstall itself, not a
|
|
209
|
+
* retry: once it completes there is no install left to rerun against.
|
|
210
|
+
*/
|
|
211
|
+
function warnKept(kept) {
|
|
212
|
+
printErr(`[uninstall] kept ${kept.length} file(s) you edited: ${kept.join(', ')}`
|
|
213
|
+
+ ' — they are yours now (uninstall with --force to delete edited files too)\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Only a FAILED unlink keeps the manifest and markers — a kept edit never does. */
|
|
207
217
|
function warnMarkersKept() {
|
|
208
218
|
printErr('[uninstall] WARN: the manifest and markers were KEPT so '
|
|
209
219
|
+ '`geneseed uninstall` can be retried once the file(s) are '
|
|
@@ -332,11 +342,12 @@ function ownedOf(man) {
|
|
|
332
342
|
* 'absent' and the retry the WARN promises would bounce off `cmdUninstall`'s own gate,
|
|
333
343
|
* stranding the leftovers forever.
|
|
334
344
|
*/
|
|
335
|
-
export function claudeUninstall(cfg, archiveMemory) {
|
|
345
|
+
export function claudeUninstall(cfg, archiveMemory, force = false) {
|
|
336
346
|
const man = claudeReadManifest(cfg);
|
|
337
347
|
const managed = managedOf(man);
|
|
338
|
-
const [removed, failed] = unlinkOwned(cfg, ownedOf(man));
|
|
348
|
+
const [removed, failed, kept] = unlinkOwned(cfg, ownedOf(man), '', hashesOf(man), force);
|
|
339
349
|
if (failed.length) warnSurvivors(failed);
|
|
350
|
+
if (kept.length) warnKept(kept);
|
|
340
351
|
const hooks = managed.settings_hooks || [];
|
|
341
352
|
const sf = settingsFile(cfg, managed);
|
|
342
353
|
const unwired = unwireClaudeSettings(sf, hooks);
|
|
@@ -354,7 +365,7 @@ export function claudeUninstall(cfg, archiveMemory) {
|
|
|
354
365
|
archived = archiveStore(path.join(cfg, 'memory'));
|
|
355
366
|
}
|
|
356
367
|
// `unmerged` reports REALITY: hooks were recorded AND the unwire actually rewrote the file.
|
|
357
|
-
return { removed, unmerged: Boolean(hooks.length) && unwired, archived, failed };
|
|
368
|
+
return { removed, unmerged: Boolean(hooks.length) && unwired, archived, failed, kept };
|
|
358
369
|
}
|
|
359
370
|
|
|
360
371
|
/**
|
|
@@ -364,11 +375,12 @@ export function claudeUninstall(cfg, archiveMemory) {
|
|
|
364
375
|
* install and an OpenCode one can share one entry point: the Claude family has no
|
|
365
376
|
* opencode.json to unmerge and a settings.json plus a managed block instead.
|
|
366
377
|
*/
|
|
367
|
-
export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
|
|
368
|
-
if (CLAUDE_STYLE.includes(host)) return claudeUninstall(target, archiveMemory);
|
|
378
|
+
export function uninstallGlobal(target, archiveMemory, host = 'opencode', force = false) {
|
|
379
|
+
if (CLAUDE_STYLE.includes(host)) return claudeUninstall(target, archiveMemory, force);
|
|
369
380
|
const man = claudeReadManifest(target);
|
|
370
|
-
const [removed, failed] = unlinkOwned(target, ownedOf(man));
|
|
381
|
+
const [removed, failed, kept] = unlinkOwned(target, ownedOf(man), '', hashesOf(man), force);
|
|
371
382
|
if (failed.length) warnSurvivors(failed);
|
|
383
|
+
if (kept.length) warnKept(kept);
|
|
372
384
|
for (const d of ['agents', 'skills', 'plugins']) {
|
|
373
385
|
const p = path.join(target, d);
|
|
374
386
|
if (isDir(p) && isEmptyDir(p)) rmdirQuiet(p);
|
|
@@ -382,7 +394,7 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
|
|
|
382
394
|
if (archiveMemory && isDir(path.join(target, 'memory'))) {
|
|
383
395
|
archived = archiveStore(path.join(target, 'memory'));
|
|
384
396
|
}
|
|
385
|
-
return { removed, unmerged, archived, failed };
|
|
397
|
+
return { removed, unmerged, archived, failed, kept };
|
|
386
398
|
}
|
|
387
399
|
|
|
388
400
|
/**
|
|
@@ -431,19 +443,22 @@ export function installAgentEntryOf(instr) {
|
|
|
431
443
|
* describing a file that no longer exists, which the fallback would then paper over with the
|
|
432
444
|
* canonical spelling — and a bundle sub-dir layout would be left wired.
|
|
433
445
|
*/
|
|
434
|
-
function opencodeProjectUninstall(root) {
|
|
446
|
+
function opencodeProjectUninstall(root, force = false) {
|
|
435
447
|
const entry = installAgentEntry(root, 'project');
|
|
436
448
|
let removed = 0;
|
|
437
449
|
let failed = [];
|
|
450
|
+
let kept = [];
|
|
438
451
|
let man = null;
|
|
439
452
|
const oc = path.join(root, '.opencode');
|
|
440
453
|
const manifestPath = path.join(oc, GLOBAL_MANIFEST);
|
|
441
454
|
if (isFile(manifestPath)) {
|
|
442
455
|
man = claudeReadManifest(oc);
|
|
443
|
-
[removed, failed] = unlinkOwned(oc, ownedOf(man), '.opencode/');
|
|
456
|
+
[removed, failed, kept] = unlinkOwned(oc, ownedOf(man), '.opencode/', hashesOf(man), force);
|
|
444
457
|
if (failed.length) warnSurvivors(failed);
|
|
458
|
+
if (kept.length) warnKept(kept);
|
|
445
459
|
// Survivors gate, mirroring the other two reversals: `_project_qualifies` keys off the
|
|
446
460
|
// manifest, so deleting it while an owned file survives makes the install unfindable.
|
|
461
|
+
// A kept (edited) file is not a survivor: it is the user's now, not this install's.
|
|
447
462
|
if (!failed.length) {
|
|
448
463
|
unlinkQuiet(manifestPath);
|
|
449
464
|
if (isDir(oc) && isEmptyDir(oc)) rmdirQuiet(oc);
|
|
@@ -464,6 +479,7 @@ function opencodeProjectUninstall(root) {
|
|
|
464
479
|
man ? skillPermissionNames(man) : []);
|
|
465
480
|
const result = { removed, unmerged, archived: null };
|
|
466
481
|
if (failed.length) result.failed = failed;
|
|
482
|
+
if (kept.length) result.kept = kept;
|
|
467
483
|
return result;
|
|
468
484
|
}
|
|
469
485
|
|
|
@@ -506,8 +522,16 @@ export function ownedDirsFor(root, host, scope, data) {
|
|
|
506
522
|
* plugins), so a locked owned file OUTSIDE those (Bob's `rules/geneseed.md`) would slip the
|
|
507
523
|
* gate while the reversal kept its manifest, and deleting the root markers here would then
|
|
508
524
|
* contradict that "KEPT" promise. The reversal's own `failed` list closes that hole.
|
|
525
|
+
*
|
|
526
|
+
* A KEPT file (Task 5: the user edited it, no `--force`) is the opposite case: it is the
|
|
527
|
+
* user's now, exactly like a refused claim on re-emit, so it must NOT hold the install open —
|
|
528
|
+
* a manifest kept for it left the install 'active' and the next `rebuild-all`/`update`
|
|
529
|
+
* re-wired hooks into a repo the user had uninstalled. So a watched directory whose only
|
|
530
|
+
* remaining files are kept ones is not a survivor, and `kept` never gates the markers; it is
|
|
531
|
+
* returned so a caller (the web's remove) can name the files and the `--force` way out.
|
|
509
532
|
*/
|
|
510
|
-
export function installUninstall(root, host = 'opencode', scope = 'global', memory = 'keep'
|
|
533
|
+
export function installUninstall(root, host = 'opencode', scope = 'global', memory = 'keep',
|
|
534
|
+
force = false) {
|
|
511
535
|
if (!['keep', 'archive', 'delete'].includes(memory)) memory = 'keep';
|
|
512
536
|
if (installState(root, host, scope) === 'absent') {
|
|
513
537
|
return { ok: false, error: 'nothing installed here' };
|
|
@@ -517,13 +541,13 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
517
541
|
// install's, and they go with it.
|
|
518
542
|
let summary;
|
|
519
543
|
if (CLAUDE_STYLE.includes(host)) {
|
|
520
|
-
summary = claudeUninstall(data, false);
|
|
544
|
+
summary = claudeUninstall(data, false, force);
|
|
521
545
|
rmtreeQuiet(path.join(data, DISABLED_STASH, host));
|
|
522
546
|
} else if (installKind(root) === 'global') {
|
|
523
|
-
summary = uninstallGlobal(root, false, 'opencode');
|
|
547
|
+
summary = uninstallGlobal(root, false, 'opencode', force);
|
|
524
548
|
rmtreeQuiet(path.join(root, DISABLED_STASH));
|
|
525
549
|
} else {
|
|
526
|
-
summary = opencodeProjectUninstall(root);
|
|
550
|
+
summary = opencodeProjectUninstall(root, force);
|
|
527
551
|
rmtreeQuiet(path.join(root, DISABLED_STASH));
|
|
528
552
|
}
|
|
529
553
|
// 2. Memory + notebook, independent of the owned-file removal above.
|
|
@@ -536,7 +560,14 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
536
560
|
}
|
|
537
561
|
// 3. The root markers, but only once the reversal's dirs are actually gone.
|
|
538
562
|
const failed = summary.failed || [];
|
|
539
|
-
const
|
|
563
|
+
const kept = summary.kept || [];
|
|
564
|
+
// `kept` rels are spelled relative to the reversal's own base: the cfg dir for the Claude
|
|
565
|
+
// family, the root for OpenCode (whose project reversal already prefixes `.opencode/`).
|
|
566
|
+
const keptAbs = new Set(kept.map((r) => path.join(CLAUDE_STYLE.includes(host) ? data : root, r)));
|
|
567
|
+
const survivors = ownedDirsFor(root, host, scope, data).filter((d) => existsSync(d)
|
|
568
|
+
&& !(keptAbs.size && readdirSync(d, { recursive: true, withFileTypes: true })
|
|
569
|
+
.every((e) => e.isDirectory() || keptAbs.has(path.join(e.parentPath, e.name)))));
|
|
570
|
+
const withKept = (o) => (kept.length ? { ...o, kept } : o);
|
|
540
571
|
if (survivors.length || failed.length) {
|
|
541
572
|
if (survivors.length && !failed.length) {
|
|
542
573
|
// With `failed` set the reversal already warned twice — no third overlapping WARN.
|
|
@@ -544,12 +575,15 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
544
575
|
+ `${survivors.join(', ')}. The install marker was KEPT so you can retry `
|
|
545
576
|
+ '`geneseed uninstall` once the file(s) are unlocked/removable.\n');
|
|
546
577
|
}
|
|
547
|
-
|
|
578
|
+
// `incomplete` names every reason the root markers stayed — directory survivors and a
|
|
579
|
+
// failed unlink — deduped.
|
|
580
|
+
const named = survivors.concat(failed);
|
|
581
|
+
const out = withKept({
|
|
548
582
|
ok: true,
|
|
549
583
|
removed: summary.removed ?? 0,
|
|
550
584
|
memory,
|
|
551
|
-
incomplete:
|
|
552
|
-
};
|
|
585
|
+
incomplete: named.filter((f, i) => named.indexOf(f) === i),
|
|
586
|
+
});
|
|
553
587
|
if (archived.length) out.archived = archived;
|
|
554
588
|
return out;
|
|
555
589
|
}
|
|
@@ -623,7 +657,7 @@ export function installUninstall(root, host = 'opencode', scope = 'global', memo
|
|
|
623
657
|
}
|
|
624
658
|
// 4. Tidy an emptied marker dir (.claude/.bob/.openclaude) so no husk lingers in the repo.
|
|
625
659
|
if (data !== root && isDir(data) && isEmptyDir(data)) rmdirQuiet(data);
|
|
626
|
-
const out = { ok: true, removed: summary.removed ?? 0, memory };
|
|
660
|
+
const out = withKept({ ok: true, removed: summary.removed ?? 0, memory });
|
|
627
661
|
if (archived.length) out.archived = archived;
|
|
628
662
|
return out;
|
|
629
663
|
}
|
|
@@ -806,7 +840,7 @@ export function cmdUninstall(args) {
|
|
|
806
840
|
}
|
|
807
841
|
}
|
|
808
842
|
const memory = args.archiveMemory ? 'archive' : 'keep';
|
|
809
|
-
const s = installUninstall(root, host, scope, memory);
|
|
843
|
+
const s = installUninstall(root, host, scope, memory, Boolean(args.force));
|
|
810
844
|
if (!(s.ok ?? true)) {
|
|
811
845
|
printErr(`[uninstall] failed: ${s.error || 'unknown error'}\n`);
|
|
812
846
|
return 1;
|
|
@@ -820,7 +854,8 @@ export function cmdUninstall(args) {
|
|
|
820
854
|
+ 'the install marker was kept — retry `geneseed uninstall` once they\'re '
|
|
821
855
|
+ `removable. ${cfgfile} updated where needed; memory/notebook ${mem}.\n`);
|
|
822
856
|
} else {
|
|
823
|
-
|
|
857
|
+
const keptNote = s.kept ? `, kept ${s.kept.length} you edited` : '';
|
|
858
|
+
printOut(`[uninstall] done — removed ${s.removed} file(s)${keptNote}; ${cfgfile} updated where `
|
|
824
859
|
+ `needed; memory/notebook ${mem}. Start a new session to apply.\n`);
|
|
825
860
|
}
|
|
826
861
|
if (scope === 'global') printSurvivingProjectInventory(root);
|
package/js/web/api.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The web console's dashboard and settings reads — `overview`, `recent`, `themes`, `setup`,
|
|
3
|
-
* `doctor`, `diff`, `installs`, `excludes` and the two `loops` reads.
|
|
3
|
+
* `doctor`, `diff`, `installs`, `fleet`, `update`, `excludes` and the two `loops` reads.
|
|
4
4
|
*
|
|
5
5
|
* WHAT IS NOT HERE, AND WHERE IT WENT: the Library read model (catalog sections, items, wiki,
|
|
6
6
|
* memory, `NotFound`) is `catalog.mjs`; the `WebState` every endpoint reads from is `state.mjs`;
|
|
@@ -11,12 +11,14 @@
|
|
|
11
11
|
import { statSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
|
-
import { CONFIG, ROOT, THEMES, discoverNames } from '../build/source.mjs';
|
|
14
|
+
import { CONFIG, ROOT, THEMES, discoverNames, makeCfg } from '../build/source.mjs';
|
|
15
|
+
import { readVersion, sourceFingerprint } from '../build/version.mjs';
|
|
15
16
|
import { sourceReleaseVersion } from '../hosts/opencode.mjs';
|
|
17
|
+
import { alwaysOnCost } from '../inspect/cost.mjs';
|
|
16
18
|
import { diffCollect } from '../inspect/diff.mjs';
|
|
17
19
|
import { doctorCollect } from '../inspect/doctor.mjs';
|
|
18
20
|
import { excludesSnapshot } from '../inspect/excludes.mjs';
|
|
19
|
-
import { CLAUDE_STYLE, HOSTS, resolvePath } from '../hosts/hosts.mjs';
|
|
21
|
+
import { CLAUDE_STYLE, HOSTS, VERSION_MARKER, resolvePath } from '../hosts/hosts.mjs';
|
|
20
22
|
import {
|
|
21
23
|
footprintOfDir, installState, installTargets, modeOfDir, trustOfDir,
|
|
22
24
|
postureOfDir, readJsonMaybe, themeOfDir,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
|
|
33
35
|
} from './catalog.mjs';
|
|
34
36
|
import { stampMinute } from './state.mjs';
|
|
37
|
+
import { updateStatus } from './update-check.mjs';
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
40
|
* `js/web/docs.mjs` imports these three from HERE, and that file is outside this split — so
|
|
@@ -141,6 +144,63 @@ export function apiInstalls(state) {
|
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
|
|
147
|
+
/**
|
|
148
|
+
* EVERY INSTALLED HARNESS ON THE MACHINE, EACH JUDGED ON ITS OWN — the Overview's fleet table.
|
|
149
|
+
*
|
|
150
|
+
* Installed means `active` or `disabled`: a host with nothing installed is not listed, because
|
|
151
|
+
* installing belongs to the Installs page with its other settings (user, 2026-10-13).
|
|
152
|
+
*
|
|
153
|
+
* Its own route, not more fields on `/api/installs`: the sidebar refetches that list on every
|
|
154
|
+
* data revision, and the drift check below re-renders a whole emit per global install.
|
|
155
|
+
* ponytail: one `diffCollect` render per global install per request, fine for the handful a
|
|
156
|
+
* machine carries; cache per (root, build mtime) if a fleet ever grows past that.
|
|
157
|
+
*
|
|
158
|
+
* Each row reads ITS OWN `.geneseed-version`. `statusData().installed_fp` is the first carrier
|
|
159
|
+
* a fixed walk finds, which describes the wrong install as soon as two hosts are deployed.
|
|
160
|
+
* Every unknown stays `null` — `verdict: 'unknown'`, `drift: null` — never a reassuring zero.
|
|
161
|
+
*/
|
|
162
|
+
export function apiFleet(state) {
|
|
163
|
+
const sourceFp = sourceFingerprint(makeCfg());
|
|
164
|
+
const installs = [];
|
|
165
|
+
for (const [host, scope, root] of installTargets()) {
|
|
166
|
+
const st = installState(root, host, scope);
|
|
167
|
+
if (st !== 'active' && st !== 'disabled') continue;
|
|
168
|
+
const dir = viewCfg(host, scope, root);
|
|
169
|
+
const fp = readVersion(dir);
|
|
170
|
+
let drift = null;
|
|
171
|
+
if (st === 'active') {
|
|
172
|
+
try {
|
|
173
|
+
const { files } = diffCollect({ target: dir });
|
|
174
|
+
if (files !== null) {
|
|
175
|
+
drift = {
|
|
176
|
+
edited: files.filter((f) => f.status === 'edited').length,
|
|
177
|
+
added: files.filter((f) => f.status === 'added').length,
|
|
178
|
+
missing: files.filter((f) => f.status === 'missing').length,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} catch { /* an install that cannot be re-rendered reports no drift figure, not zero */ }
|
|
182
|
+
}
|
|
183
|
+
let built = null;
|
|
184
|
+
try { built = Math.floor(statSync(path.join(dir, VERSION_MARKER)).mtimeMs / 1000); } catch { /* never built */ }
|
|
185
|
+
installs.push({
|
|
186
|
+
id: `${host}:${scope}:${root}`, host, scope, path: root, state: st,
|
|
187
|
+
theme: themeOfDir(root, host),
|
|
188
|
+
footprint: footprintOfDir(root),
|
|
189
|
+
installed_fp: fp,
|
|
190
|
+
verdict: fp === null ? 'unknown' : fp === sourceFp ? 'synced' : 'behind',
|
|
191
|
+
// A project install has no drift check at all (`diffCollect` answers `files: null`), which
|
|
192
|
+
// the console must say as "not checkable" — so the fact travels apart from the figure.
|
|
193
|
+
diffable: drift !== null,
|
|
194
|
+
drift,
|
|
195
|
+
built_epoch: built,
|
|
196
|
+
// What this install injects every session, by part (characters); null when its carrier
|
|
197
|
+
// is missing, so the console says "not measured" rather than drawing an empty bar.
|
|
198
|
+
cost: alwaysOnCost({ host, root, dir, footprint: footprintOfDir(root) }),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return { installs, source_fp: sourceFp, doctor: state.doctor };
|
|
202
|
+
}
|
|
203
|
+
|
|
144
204
|
/** The dashboard aggregate. */
|
|
145
205
|
export function apiOverview(state) {
|
|
146
206
|
const inv = state.inventory;
|
|
@@ -354,6 +414,9 @@ export function apiLoopsActive() {
|
|
|
354
414
|
}
|
|
355
415
|
|
|
356
416
|
/** The source's release label, or null when it cannot be read. */
|
|
417
|
+
/** Is a newer release on npm than the source this console serves? See update-check.mjs. */
|
|
418
|
+
export const apiUpdate = (state) => updateStatus(state, releaseLabel());
|
|
419
|
+
|
|
357
420
|
function releaseLabel() {
|
|
358
421
|
const v = sourceReleaseVersion({ config: CONFIG });
|
|
359
422
|
return v && v !== '0.0.0' ? v : null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/gates — gate asks per rule per day over the last 30 days, across every installed
|
|
3
|
+
* harness: the Overview's "Gates" sparklines. The ledger (`notebook/gates.jsonl`, written by
|
|
4
|
+
* js/hosts/hooks.mjs and the OpenCode guard plugin) already stamps each ask with an ISO `ts`
|
|
5
|
+
* and a `rule` (`law-1`, `process-1`, `wiki`…); this only reads it. `gateSummary` keeps its
|
|
6
|
+
* count-only shape for the CLI's status panel.
|
|
7
|
+
*
|
|
8
|
+
* `{ days: ['YYYY-MM-DD' × 30], rules: [{ rule, n, per_day[30] }] }`, busiest rule first.
|
|
9
|
+
* Garbage lines are skipped; nothing here writes (the writers already trim at 1000 lines).
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { GATE_LEDGER } from '../hosts/hosts.mjs';
|
|
15
|
+
import { installState, installTargets } from '../hosts/installs.mjs';
|
|
16
|
+
import { viewCfg } from './api.mjs';
|
|
17
|
+
|
|
18
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
const WINDOW = 30;
|
|
20
|
+
|
|
21
|
+
export function gateDays({ now = Date.now(), targets = installTargets() } = {}) {
|
|
22
|
+
const days = Array.from({ length: WINDOW }, (_, i) =>
|
|
23
|
+
new Date(now - (WINDOW - 1 - i) * DAY_MS).toISOString().slice(0, 10));
|
|
24
|
+
const index = new Map(days.map((d, i) => [d, i]));
|
|
25
|
+
const by = new Map();
|
|
26
|
+
for (const [host, scope, root] of targets) {
|
|
27
|
+
if (installState(root, host, scope) !== 'active') continue;
|
|
28
|
+
let raw;
|
|
29
|
+
try { raw = readFileSync(path.join(viewCfg(host, scope, root), GATE_LEDGER), 'utf8'); } catch { continue; }
|
|
30
|
+
for (const line of raw.split('\n')) {
|
|
31
|
+
let e;
|
|
32
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
33
|
+
const i = typeof e?.ts === 'string' ? index.get(e.ts.slice(0, 10)) : undefined;
|
|
34
|
+
if (i === undefined || typeof e.rule !== 'string') continue;
|
|
35
|
+
const row = by.get(e.rule) || { rule: e.rule, n: 0, per_day: new Array(WINDOW).fill(0) };
|
|
36
|
+
row.n += 1;
|
|
37
|
+
row.per_day[i] += 1;
|
|
38
|
+
by.set(e.rule, row);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { days, rules: [...by.values()].sort((a, b) => b.n - a.n || a.rule.localeCompare(b.rule)) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const apiGates = () => gateDays();
|
package/js/web/routes.mjs
CHANGED
|
@@ -21,10 +21,12 @@ import { percentDecode } from '../lib/text.mjs';
|
|
|
21
21
|
import { apiExcludesMutate, apiInstallToggle, apiLoopsPresetMutate, apiMcp, apiMemoryDelete, apiSelectView } from './actions.mjs';
|
|
22
22
|
import { apiActivity, apiActivityDetail, apiActivityToggle } from './activity.mjs';
|
|
23
23
|
import {
|
|
24
|
-
apiDiff, apiDoctor, apiExcludes, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
|
|
25
|
-
apiRecent, apiSetup, apiThemes,
|
|
24
|
+
apiDiff, apiDoctor, apiExcludes, apiFleet, apiInstalls, apiLoops, apiLoopsActive, apiOverview,
|
|
25
|
+
apiRecent, apiSetup, apiThemes, apiUpdate,
|
|
26
26
|
} from './api.mjs';
|
|
27
27
|
import { NotFound, apiCatalog, apiItem } from './catalog.mjs';
|
|
28
|
+
import { apiUsage } from './usage-api.mjs';
|
|
29
|
+
import { apiGates } from './gates-api.mjs';
|
|
28
30
|
import { apiProfile, apiProfileSave, apiRules, apiRulesMutate, apiRulesPromote } from './user-files.mjs';
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -146,6 +148,10 @@ export const STATE_ROUTES = {
|
|
|
146
148
|
'/api/setup': apiSetup,
|
|
147
149
|
'/api/doctor': apiDoctor,
|
|
148
150
|
'/api/installs': apiInstalls,
|
|
151
|
+
'/api/fleet': apiFleet,
|
|
152
|
+
'/api/update': apiUpdate,
|
|
153
|
+
'/api/usage': apiUsage,
|
|
154
|
+
'/api/gates': apiGates,
|
|
149
155
|
'/api/excludes': apiExcludes,
|
|
150
156
|
'/api/profile': apiProfile,
|
|
151
157
|
'/api/diff': apiDiff,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS A NEWER GENESEED RELEASED? — the Overview's "4.2.0 available" chip.
|
|
3
|
+
*
|
|
4
|
+
* Route handlers answer synchronously, so this never waits on the network: it answers from the
|
|
5
|
+
* last result it holds and, when that is missing or a day old, starts ONE background fetch whose
|
|
6
|
+
* answer the next poll reads. The console refetches after every mutation and on mount, so the
|
|
7
|
+
* chip appears within a poll or two of the daemon starting.
|
|
8
|
+
*
|
|
9
|
+
* Every failure is `latest: null` and says nothing more: a corporate proxy, an offline laptop and
|
|
10
|
+
* a registry outage all read "update check unavailable", never an error. `GENESEED_NO_UPDATE_CHECK`
|
|
11
|
+
* turns the fetch off for a machine that must not phone out at all.
|
|
12
|
+
*
|
|
13
|
+
* ponytail: the cache lives on the daemon's state, in memory. A restart costs one more request;
|
|
14
|
+
* a disk cache would be a file to version for a figure that is free to re-ask.
|
|
15
|
+
*/
|
|
16
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
/** A failed ask is retried sooner than a good answer is refreshed: a proxy hiccup is not a day. */
|
|
18
|
+
const RETRY_MS = 60 * 60 * 1000;
|
|
19
|
+
const REGISTRY = 'https://registry.npmjs.org/geneseed/latest';
|
|
20
|
+
|
|
21
|
+
/** x.y.z only; a pre-release tag or a malformed label compares as nothing newer. */
|
|
22
|
+
export function semverGreater(a, b) {
|
|
23
|
+
const parse = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '').trim()) || []).slice(1).map(Number);
|
|
24
|
+
const x = parse(a);
|
|
25
|
+
const y = parse(b);
|
|
26
|
+
if (x.length !== 3 || y.length !== 3) return false;
|
|
27
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function npmLatest() {
|
|
32
|
+
const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(3000) });
|
|
33
|
+
if (!res.ok) return null;
|
|
34
|
+
const body = await res.json();
|
|
35
|
+
return typeof body?.version === 'string' ? body.version : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `{ current, latest, available, checked_at }`. `available` is true only when `latest` is
|
|
40
|
+
* semver-greater than `current`, so a checkout ahead of the last release never shows one;
|
|
41
|
+
* null when either side is unknown. `fetcher` and `now` are injectable for the tests, which
|
|
42
|
+
* must never reach the network.
|
|
43
|
+
*/
|
|
44
|
+
export function updateStatus(state, current, { fetcher = npmLatest, now = Date.now() } = {}) {
|
|
45
|
+
const c = state._update || (state._update = { latest: null, checked_at: null, inflight: false });
|
|
46
|
+
const stale = c.checked_at === null
|
|
47
|
+
|| now - c.checked_at > (c.latest === null ? RETRY_MS : DAY_MS);
|
|
48
|
+
// Under `node --test` (NODE_TEST_CONTEXT) the real registry is never asked: the route probes in
|
|
49
|
+
// tests/unit/web_server.test.mjs reach this handler, and a suite must not depend on the network.
|
|
50
|
+
const off = process.env.GENESEED_NO_UPDATE_CHECK
|
|
51
|
+
|| (fetcher === npmLatest && process.env.NODE_TEST_CONTEXT);
|
|
52
|
+
if (stale && !c.inflight && !off) {
|
|
53
|
+
c.inflight = true;
|
|
54
|
+
Promise.resolve()
|
|
55
|
+
.then(fetcher)
|
|
56
|
+
.then((v) => { c.latest = v ?? null; }, () => { c.latest = null; })
|
|
57
|
+
.finally(() => { c.checked_at = now; c.inflight = false; });
|
|
58
|
+
}
|
|
59
|
+
const available = current && c.latest ? semverGreater(c.latest, current) : null;
|
|
60
|
+
return { current: current ?? null, latest: c.latest, available, checked_at: c.checked_at };
|
|
61
|
+
}
|