geneseed 3.13.0 → 3.15.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 +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +5 -6
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* 2. AN EMPTY DIRECTORY WAS INVISIBLE TO EVERY CELL. The ancestor-climb prune is a quarter of
|
|
18
18
|
* this verb and `_snapshot` could not see it: a port that unlinked the files and left
|
|
19
19
|
* `skills/<name>/` behind was byte-identical everywhere. Closed by a `<dirs>` column in
|
|
20
|
-
* `tests/
|
|
21
|
-
* entry closes it for all 219 cells written before it existed —
|
|
22
|
-
* order sat in the same hole. The ABSOLUTE half is a different axis and is the sixth
|
|
20
|
+
* the CLI matrix's snapshot (`tests/helpers/cli_golden.mjs`) rather than a per-cell
|
|
21
|
+
* expectation, because one entry closes it for all 219 cells written before it existed —
|
|
22
|
+
* `cmdTheme`'s statement order sat in the same hole. The ABSOLUTE half is a different axis and is the sixth
|
|
23
23
|
* expectation kind, `expect_absent_files`.
|
|
24
24
|
* 3. `--archive-memory` TOUCHES THE MEMORY STORE, the one thing here a user cannot rebuild.
|
|
25
25
|
* It is why `_archive_store` MOVES and never deletes, and why the Python's `memory=delete`
|
|
@@ -47,31 +47,28 @@ import {
|
|
|
47
47
|
} from 'node:fs';
|
|
48
48
|
import path from 'node:path';
|
|
49
49
|
|
|
50
|
-
import { hookRunnerEntry } from '../
|
|
51
|
-
import { confirm } from '
|
|
50
|
+
import { hookRunnerEntry } from '../hosts/shim.mjs';
|
|
51
|
+
import { confirm } from '../lib/prompt.mjs';
|
|
52
52
|
import {
|
|
53
53
|
claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
|
|
54
54
|
installState,
|
|
55
|
-
registeredTargets,
|
|
55
|
+
registeredTargets, DISABLED_STASH,
|
|
56
56
|
} from '../hosts/installs.mjs';
|
|
57
57
|
import {
|
|
58
|
-
GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
|
|
58
|
+
CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
|
|
59
59
|
} from '../hosts/hosts.mjs';
|
|
60
|
-
import { mcpCommented } from '../hosts/mcp.mjs';
|
|
60
|
+
import { mcpCommented, mcpLoad } from '../hosts/mcp.mjs';
|
|
61
61
|
import {
|
|
62
|
-
managedBlockRead, managedBlockRemove, managedBlockWrite,
|
|
63
|
-
mergeClaudeSettings, opencodeTarget,
|
|
62
|
+
atomicWriteJson, managedBlockRead, managedBlockRemove, managedBlockWrite,
|
|
63
|
+
loadJsonObject, mergeClaudeSettings, opencodeTarget,
|
|
64
64
|
settingsIntegrityCheck, wireClaudeExcludes, unwireClaudeExcludes, unwireClaudeSettings,
|
|
65
65
|
} from '../hosts/settings.mjs';
|
|
66
66
|
import { printOut, printErr, readText, writeText, isFile, isDir, isOsError } from '../lib/fs.mjs';
|
|
67
|
-
import { indexOfDeepEqual,
|
|
67
|
+
import { indexOfDeepEqual, isDict, jsonDumps, deepEquals } from '../lib/json.mjs';
|
|
68
68
|
import { comparePaths, isAbsolutePath, within } from '../lib/paths.mjs';
|
|
69
69
|
|
|
70
70
|
const hostSpec = (host) => HOSTS.find((h) => h.host === host);
|
|
71
71
|
|
|
72
|
-
/** The Claude-STYLE hosts — one manifest shape, one reversal. Spelled once. */
|
|
73
|
-
const CLAUDE_STYLE = ['claude', 'bob', 'openclaude'];
|
|
74
|
-
|
|
75
72
|
/**
|
|
76
73
|
* `shutil.rmtree(p, ignore_errors=True)`.
|
|
77
74
|
*
|
|
@@ -154,11 +151,15 @@ function pruneAncestors(start, stop) {
|
|
|
154
151
|
* failure strings) differ in the root and in the label, which are the two arguments. The
|
|
155
152
|
* `victim.is_file()` guard is load-bearing rather than defensive: a manifest entry naming a
|
|
156
153
|
* directory is SKIPPED, not recursed into, and `removed` counts only what actually went.
|
|
154
|
+
*
|
|
155
|
+
* `ownedWithin` first, the same containment boundary deactivate draws: the manifest is a file
|
|
156
|
+
* on disk, and without it an `owned` entry of `../../.bashrc` was unlinked — and the prune
|
|
157
|
+
* then climbed from ITS parent, which never meets `base`, removing empty directories as it went.
|
|
157
158
|
*/
|
|
158
159
|
function unlinkOwned(base, owned, label = '') {
|
|
159
160
|
let removed = 0;
|
|
160
161
|
const failed = [];
|
|
161
|
-
for (const rel of owned) {
|
|
162
|
+
for (const rel of ownedWithin(base, owned)) {
|
|
162
163
|
const victim = path.join(base, rel);
|
|
163
164
|
try {
|
|
164
165
|
if (isFile(victim)) {
|
|
@@ -177,6 +178,23 @@ function unlinkOwned(base, owned, label = '') {
|
|
|
177
178
|
return [removed, failed];
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
|
|
183
|
+
* `owned` goes through (uninstall, both deactivates).
|
|
184
|
+
*
|
|
185
|
+
* `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
|
|
186
|
+
* boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
|
|
187
|
+
* `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
|
|
188
|
+
* `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
|
|
189
|
+
* concatenated it into `<root>/C:/evil.md`, which passes containment and would then be acted
|
|
190
|
+
* on. `path.resolve` has pathlib's semantics for exactly this case, and collapses `..`
|
|
191
|
+
* besides, which is why the guard runs on the resolved path rather than the lexical one.
|
|
192
|
+
*/
|
|
193
|
+
function ownedWithin(base, rels) {
|
|
194
|
+
const rroot = resolvePath(base);
|
|
195
|
+
return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
|
|
196
|
+
}
|
|
197
|
+
|
|
180
198
|
/** The two WARNs every reversal prints when an owned file survives. Identical in all three. */
|
|
181
199
|
function warnSurvivors(failed) {
|
|
182
200
|
printErr('[uninstall] WARN: could not remove '
|
|
@@ -203,13 +221,9 @@ const REVERSAL_MARKERS = [GLOBAL_MANIFEST, '.geneseed-theme', '.geneseed-emit',
|
|
|
203
221
|
*/
|
|
204
222
|
export function unmergeOpencodeJson(p, entry) {
|
|
205
223
|
const target = opencodeTarget(p);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const text = readMaybe(target);
|
|
210
|
-
if (text === null) return false; // the Python's `except OSError: return False`
|
|
211
|
-
[cfg, hadComments] = readJsonc(text);
|
|
212
|
-
if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) return false;
|
|
224
|
+
// Absent, unreadable (the Python's `except OSError: return False`) or not an object: decline.
|
|
225
|
+
const { state, data: cfg, hadComments } = loadJsonObject(target);
|
|
226
|
+
if (state !== 'ok') return false;
|
|
213
227
|
const instr = cfg.instructions;
|
|
214
228
|
if (!Array.isArray(instr) || !instr.includes(entry)) return false;
|
|
215
229
|
if (path.extname(target) === '.jsonc' && hadComments) {
|
|
@@ -218,7 +232,7 @@ export function unmergeOpencodeJson(p, entry) {
|
|
|
218
232
|
return false;
|
|
219
233
|
}
|
|
220
234
|
cfg.instructions = instr.filter((i) => i !== entry);
|
|
221
|
-
|
|
235
|
+
atomicWriteJson(target, cfg);
|
|
222
236
|
return true;
|
|
223
237
|
}
|
|
224
238
|
|
|
@@ -269,8 +283,7 @@ function claudeMdPath(cfg, managed) {
|
|
|
269
283
|
|
|
270
284
|
/** A manifest's `managed` map, or `{}` — the Python's `isinstance(..., dict)` guard. */
|
|
271
285
|
function managedOf(man) {
|
|
272
|
-
|
|
273
|
-
return typeof mg === 'object' && mg !== null && !Array.isArray(mg) ? mg : {};
|
|
286
|
+
return isDict(man.managed) ? man.managed : {};
|
|
274
287
|
}
|
|
275
288
|
|
|
276
289
|
/** A manifest's `owned` list, or `[]`. */
|
|
@@ -339,22 +352,6 @@ export function uninstallGlobal(target, archiveMemory, host = 'opencode') {
|
|
|
339
352
|
return { removed, unmerged, archived, failed };
|
|
340
353
|
}
|
|
341
354
|
|
|
342
|
-
/**
|
|
343
|
-
* `_harness_mcp._mcp_load`, reduced to the OpenCode arm.
|
|
344
|
-
*
|
|
345
|
-
* The Claude arm (`.mcp.json`, `~/.claude.json`, parsed STRICTLY — see `mcpLoad` in
|
|
346
|
-
* js/hosts/mcp.mjs for why) lives with the MCP catalog. `installAgentEntry` is this port's only caller and passes
|
|
347
|
-
* no host, so porting the branch would ship an unreachable arm with no cell and no partition
|
|
348
|
-
* to be part of — the criterion this port decides keep-vs-delete by.
|
|
349
|
-
*/
|
|
350
|
-
function mcpLoadOpencode(p) {
|
|
351
|
-
if (!existsSync(p)) return {};
|
|
352
|
-
const text = readMaybe(p);
|
|
353
|
-
if (text === null) return {};
|
|
354
|
-
const [data] = readJsonc(text);
|
|
355
|
-
return typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
355
|
/**
|
|
359
356
|
* `_harness_mcp._install_agent_entry` — the `instructions` entry to drop.
|
|
360
357
|
*
|
|
@@ -367,7 +364,7 @@ export function installAgentEntry(root, kind) {
|
|
|
367
364
|
if (kind === 'global') {
|
|
368
365
|
return path.join(root, 'AGENT.md').split(path.sep).join('/');
|
|
369
366
|
}
|
|
370
|
-
const cfg =
|
|
367
|
+
const cfg = mcpLoad(opencodeTarget(path.join(root, 'opencode.json')));
|
|
371
368
|
return installAgentEntryOf(cfg.instructions);
|
|
372
369
|
}
|
|
373
370
|
|
|
@@ -377,7 +374,7 @@ export function installAgentEntry(root, kind) {
|
|
|
377
374
|
* A cell can only ever observe the entries a seeded `opencode.json` holds, and the shape
|
|
378
375
|
* that separates the two `is_absolute` rules is a Windows ROOTLESS path — which a cell CAN
|
|
379
376
|
* seed but whose answer then only differs on one platform, in one branch, with no other
|
|
380
|
-
* observable effect.
|
|
377
|
+
* observable effect. The former Python corpus drove the list directly; nothing does now.
|
|
381
378
|
*/
|
|
382
379
|
export function installAgentEntryOf(instr) {
|
|
383
380
|
if (Array.isArray(instr)) {
|
|
@@ -645,22 +642,6 @@ function printOtherHostHits(root, removedHost) {
|
|
|
645
642
|
}
|
|
646
643
|
}
|
|
647
644
|
|
|
648
|
-
/**
|
|
649
|
-
* `_harness_setup._confirm`, and the only interactive read this verb makes.
|
|
650
|
-
*
|
|
651
|
-
* MOVED TO `js/maintain/setup.mjs` IN P5i, where the Python's own owner is: `_confirm` lives in
|
|
652
|
-
* `_harness_setup` beside the `_ask` it is built on, and `setup` is its second caller. The
|
|
653
|
-
* body stayed here for one phase because a helper with one caller is not shared yet; the
|
|
654
|
-
* rule this port uses is that the SECOND owner is what moves it, and a byte gate is what
|
|
655
|
-
* licenses the move.
|
|
656
|
-
*
|
|
657
|
-
* UNREACHABLE FROM EVERY CELL, restated because moving it does not change that: `cmdUninstall`
|
|
658
|
-
* only reaches it when stdin is a TTY, and the harness gives every cell a pipe. What the
|
|
659
|
-
* cells DO gate is the branch beside it — the non-interactive refusal — which is why that one
|
|
660
|
-
* has an `expect` naming its wording. It is now gated positively too, by the stdin-seeded
|
|
661
|
-
* corpus P5i added for the wizard.
|
|
662
|
-
*/
|
|
663
|
-
|
|
664
645
|
/**
|
|
665
646
|
* `_harness_mcp.cmd_uninstall`.
|
|
666
647
|
*
|
|
@@ -690,7 +671,7 @@ export function cmdUninstall(args) {
|
|
|
690
671
|
const data = installDataDir(root, host, scope);
|
|
691
672
|
const stores = ['memory', 'notebook'].filter((n) => isDir(path.join(data, n)));
|
|
692
673
|
printOut(`[uninstall] target: ${root} (${host}:${scope})\n`);
|
|
693
|
-
if (
|
|
674
|
+
if (CLAUDE_STYLE.includes(host)) {
|
|
694
675
|
printOut('[uninstall] removes: agents/, skills/, markers, the '
|
|
695
676
|
+ `${hostSpec(host).agentFile} managed block, and Geneseed's `
|
|
696
677
|
+ 'settings.json hooks/excludes (your own keys/hooks are kept).\n');
|
|
@@ -751,9 +732,10 @@ export function cmdUninstall(args) {
|
|
|
751
732
|
// ALL-OR-NOTHING IS THE PROPERTY, and it is the one a port breaks silently. A move that fails
|
|
752
733
|
// puts every earlier move back and reports, leaving the install fully `active`; a port that
|
|
753
734
|
// merely stopped on the failure would leave a half-gutted config dir that neither state
|
|
754
|
-
// answers for. `tests/
|
|
755
|
-
// drives it
|
|
756
|
-
//
|
|
735
|
+
// answers for. `tests/unit/web_api.test.mjs`'s `deactivate rolls back on a failed move`
|
|
736
|
+
// drives it with a file planted where the stash dir belongs, which fails the FIRST move; the
|
|
737
|
+
// former web matrix's SECOND-move collision (a manifest naming a file AND its parent
|
|
738
|
+
// directory) has no successor.
|
|
757
739
|
|
|
758
740
|
/**
|
|
759
741
|
* `_harness_mcp._move_tree` — move a file or dir, refusing an existing destination.
|
|
@@ -810,17 +792,7 @@ function installRelive(root) {
|
|
|
810
792
|
return isFile(path.join(root, 'AGENT.md'));
|
|
811
793
|
}
|
|
812
794
|
|
|
813
|
-
/**
|
|
814
|
-
* `_harness_mcp._install_move_list` — the rels to move aside, `_within`-guarded.
|
|
815
|
-
*
|
|
816
|
-
* `path.resolve(rroot, r)` and NOT `path.join`, which is the one line here that is a security
|
|
817
|
-
* boundary rather than a translation. The Python is `(rroot / r).resolve()`, and pathlib's
|
|
818
|
-
* `/` REPLACES the base when the right operand is absolute — so a manifest entry naming
|
|
819
|
-
* `C:/evil.md` resolves outside the root and `_within` rejects it. `path.join` would have
|
|
820
|
-
* concatenated it into `<root>/C:/evil.md`, which passes containment and would then be MOVED.
|
|
821
|
-
* `path.resolve` has pathlib's semantics for exactly this case, and collapses `..` besides,
|
|
822
|
-
* which is why the guard runs on the resolved path rather than the lexical one.
|
|
823
|
-
*/
|
|
795
|
+
/** `_harness_mcp._install_move_list` — the rels to move aside, `ownedWithin`-guarded. */
|
|
824
796
|
function installMoveList(root, kind) {
|
|
825
797
|
let rels;
|
|
826
798
|
if (kind === 'project') {
|
|
@@ -828,8 +800,7 @@ function installMoveList(root, kind) {
|
|
|
828
800
|
} else {
|
|
829
801
|
rels = ownedOf(claudeReadManifest(root)).filter((r) => r !== VERSION_MARKER);
|
|
830
802
|
}
|
|
831
|
-
|
|
832
|
-
return rels.filter((r) => r && within(resolvePath(path.resolve(rroot, r)), rroot));
|
|
803
|
+
return ownedWithin(root, rels);
|
|
833
804
|
}
|
|
834
805
|
|
|
835
806
|
/**
|
|
@@ -844,16 +815,11 @@ function installMoveList(root, kind) {
|
|
|
844
815
|
*/
|
|
845
816
|
function installReaddEntry(target, entry) {
|
|
846
817
|
if (!existsSync(target)) {
|
|
847
|
-
|
|
848
|
-
writeText(target, `${jsonDumpsIndent({
|
|
849
|
-
$schema: 'https://opencode.ai/config.json', instructions: [entry],
|
|
850
|
-
})}\n`);
|
|
818
|
+
atomicWriteJson(target, { $schema: 'https://opencode.ai/config.json', instructions: [entry] });
|
|
851
819
|
return true;
|
|
852
820
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
const [cfg, hadComments] = readJsonc(raw);
|
|
856
|
-
if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) return false;
|
|
821
|
+
const { state, data: cfg, hadComments } = loadJsonObject(target);
|
|
822
|
+
if (state !== 'ok') return false; // unreadable (except OSError), or not an object
|
|
857
823
|
const instr = Array.isArray(cfg.instructions) ? cfg.instructions : [];
|
|
858
824
|
if (indexOfDeepEqual(instr, entry) >= 0) return false;
|
|
859
825
|
if (path.extname(target) === '.jsonc' && hadComments) {
|
|
@@ -862,7 +828,7 @@ function installReaddEntry(target, entry) {
|
|
|
862
828
|
return false;
|
|
863
829
|
}
|
|
864
830
|
cfg.instructions = [...instr, entry];
|
|
865
|
-
|
|
831
|
+
atomicWriteJson(target, cfg);
|
|
866
832
|
return true;
|
|
867
833
|
}
|
|
868
834
|
|
|
@@ -961,7 +927,7 @@ function restoreAll(stash, base, skip = null) {
|
|
|
961
927
|
* The config edit is the LAST step and the only non-move mutation, so a move failure rolls
|
|
962
928
|
* back cleanly with the `instructions` entry still intact. The prune climbs from each moved
|
|
963
929
|
* file's PARENT so a `skills/<name>/` husk goes too — the half of this function no file
|
|
964
|
-
* snapshot can see, and the reason
|
|
930
|
+
* snapshot can see, and the reason the former web matrix grew a `<dirs>` column in P6i.
|
|
965
931
|
*/
|
|
966
932
|
export function installDeactivate(root, host = 'opencode', scope = 'global') {
|
|
967
933
|
if (CLAUDE_STYLE.includes(host)) return claudeDeactivate(root, scope, host);
|
|
@@ -1067,16 +1033,14 @@ function remergeClaudeHooks(cfg, root = cfg, host = 'claude') {
|
|
|
1067
1033
|
const excluded = excludedRulesOfDir(root).length ? excludedRulesOfDir(root)
|
|
1068
1034
|
: excludedRulesOfDir(cfg);
|
|
1069
1035
|
// `host` reaches the group builder: a Bob reactivate must re-wire Gemini-named groups.
|
|
1070
|
-
const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
|
|
1036
|
+
const [, claims] = mergeClaudeSettings(settingsFile(cfg, managed),
|
|
1071
1037
|
managed.settings_hooks ?? null, hookRunnerEntry(), doctrines, excluded, host, cfg);
|
|
1072
1038
|
// `and data` — a manifest that did not parse is not one to write back.
|
|
1073
1039
|
if (!deepEquals(claims, managed.settings_hooks ?? null) && Object.keys(data).length > 0) {
|
|
1074
1040
|
managed.settings_hooks = claims;
|
|
1075
1041
|
data.managed = managed;
|
|
1076
|
-
const tmp = path.join(cfg, `${GLOBAL_MANIFEST}.tmp`);
|
|
1077
1042
|
try {
|
|
1078
|
-
|
|
1079
|
-
renameSync(tmp, path.join(cfg, GLOBAL_MANIFEST));
|
|
1043
|
+
atomicWriteJson(path.join(cfg, GLOBAL_MANIFEST), data);
|
|
1080
1044
|
} catch { /* except OSError: pass */ }
|
|
1081
1045
|
}
|
|
1082
1046
|
}
|
|
@@ -1093,9 +1057,7 @@ function claudeDeactivate(root, scope = 'global', host = 'claude') {
|
|
|
1093
1057
|
const man = claudeReadManifest(cfg);
|
|
1094
1058
|
const managed = managedOf(man);
|
|
1095
1059
|
const stash = path.join(cfg, DISABLED_STASH, host);
|
|
1096
|
-
const
|
|
1097
|
-
const rels = ownedOf(man).filter((r) => r && r !== VERSION_MARKER
|
|
1098
|
-
&& within(resolvePath(path.resolve(rroot, r)), rroot));
|
|
1060
|
+
const rels = ownedWithin(cfg, ownedOf(man).filter((r) => r !== VERSION_MARKER));
|
|
1099
1061
|
const { done, failed } = moveAll(cfg, stash, rels);
|
|
1100
1062
|
if (failed) {
|
|
1101
1063
|
cleanHostStash(cfg, host);
|
|
@@ -1129,9 +1091,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
|
|
|
1129
1091
|
const stash = path.join(cfg, DISABLED_STASH, host);
|
|
1130
1092
|
const blockFile = path.join(stash, '_claude_md_block.txt');
|
|
1131
1093
|
const reliveManifest = claudeReadManifest(cfg);
|
|
1132
|
-
|
|
1133
|
-
// observable on a manifest whose `managed` is `null`.
|
|
1134
|
-
const reliveManaged = reliveManifest.managed || {};
|
|
1094
|
+
const reliveManaged = managedOf(reliveManifest);
|
|
1135
1095
|
let relive = managedBlockRead(claudeMdPath(cfg, reliveManaged)) !== null;
|
|
1136
1096
|
// Bob GLOBAL writes no managed AGENTS.md block — its preamble carrier is the owned
|
|
1137
1097
|
// `rules/geneseed.md`, so a live copy of THAT is the relive signal there. Without this arm
|
|
@@ -1149,7 +1109,7 @@ function claudeReactivate(root, scope = 'global', host = 'claude') {
|
|
|
1149
1109
|
}
|
|
1150
1110
|
const { leftovers, moved } = restoreAll(stash, cfg, '_claude_md_block.txt');
|
|
1151
1111
|
if (leftovers.length) return { ok: false, failed: leftovers, moved };
|
|
1152
|
-
const managed = claudeReadManifest(cfg)
|
|
1112
|
+
const managed = managedOf(claudeReadManifest(cfg));
|
|
1153
1113
|
// ⚠ THE CARRIER IS RESTORED BEFORE THE HOOKS ARE RE-MERGED, AND THE ORDER IS LOAD-BEARING.
|
|
1154
1114
|
// `remergeClaudeHooks` reads the install's pack selection back off the carrier, and on a
|
|
1155
1115
|
// Claude-style host that selection lives inside the MANAGED BLOCK — which the deactivate
|
package/js/maintain/update.mjs
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Self-update: `git pull` the install's own origin, validate, rebuild.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
* release
|
|
6
|
-
* because a Node install that shelled out to the Python harness to update itself
|
|
7
|
-
* would have Python as a hard dependency of the only command a user MUST be able to run.
|
|
4
|
+
* The one operation a user MUST always be able to run: every other verb can be broken by a bad
|
|
5
|
+
* release, and this is the verb that fetches the fix.
|
|
8
6
|
*
|
|
9
7
|
* WHY THIS MODULE SPAWNS, AND WHY THE ARGUMENT IS NOT THE JOB RUNNER'S. `js/web/jobs.mjs`
|
|
10
8
|
* re-executes this program for ISOLATION — a synchronous verb in the daemon's event loop
|
|
@@ -15,21 +13,23 @@
|
|
|
15
13
|
* the modules this process loaded before the merge — it would validate the old source, pass,
|
|
16
14
|
* and then render the old source into the bundle, reporting success. That is not a slower
|
|
17
15
|
* version of the right answer, it is the wrong one. So `runDoctor`, `rebuildBundle` and
|
|
18
|
-
* `rebuildInstalls` each start a fresh interpreter over the tree as it now is,
|
|
19
|
-
*
|
|
16
|
+
* `rebuildInstalls` each start a fresh interpreter over the tree as it now is, and
|
|
17
|
+
* `ALLOWED_SPAWNS` in `tests/unit/hook_cli.test.mjs` carries the argv. The same reason is why
|
|
18
|
+
* the two modules this file reaches lazily (`js/build/generate.mjs`, `js/web/daemon.mjs`) are
|
|
19
|
+
* loaded BEFORE the pull and not at their call sites: an `import()` after the merge would mix
|
|
20
|
+
* pulled files with the ones already cached, and a renamed export kills the upgrade mid-way.
|
|
20
21
|
*
|
|
21
22
|
* `git` is the fourth spawn and the plainest: there is no git library in the Node stdlib,
|
|
22
23
|
* and the port will not vendor one. Every git call goes through the `gitRun` seam —
|
|
23
24
|
* which-guarded, credential-redacted, and it never throws.
|
|
24
25
|
*
|
|
25
|
-
* WHAT THE GATE CANNOT SEE, stated rather than implied. `
|
|
26
|
-
* `tests/
|
|
27
|
-
* fixture builds, so no
|
|
26
|
+
* WHAT THE GATE CANNOT SEE, stated rather than implied. `tests/unit/update_git.test.mjs` and
|
|
27
|
+
* `tests/unit/update_flow.test.mjs` run each arm against a private clone of a bare origin the
|
|
28
|
+
* fixture builds, so no test can reach a network — which is a safety property and a blind
|
|
28
29
|
* spot at once: `git fetch --progress` over a local path prints NOTHING, so
|
|
29
30
|
* `fetchStreaming`'s reader, its phase filter and its 15s heartbeat are reachable in every
|
|
30
|
-
*
|
|
31
|
-
* `tests/
|
|
32
|
-
* neither and are declared in the P8a handoff.
|
|
31
|
+
* run and varied in none. The phase filter is gated over a recorded transcript in
|
|
32
|
+
* `tests/unit/update_git.test.mjs`; the heartbeat and the hard deadline are gated by nothing.
|
|
33
33
|
*
|
|
34
34
|
* ALSO HERE, SINCE P8b: `syncSelf`/`cmdSyncSelf` and the bootstrap step runner
|
|
35
35
|
* (`cmdBootstrap`, `bootstrapPlain`) at the bottom of this file. Still NOT here: `main`, the
|
|
@@ -47,10 +47,9 @@ import { ROOT } from '../build/source.mjs';
|
|
|
47
47
|
import { exportImprovements, flushExportNotes } from '../inspect/diff.mjs';
|
|
48
48
|
import { opencodeConfigDir } from '../hosts/hosts.mjs';
|
|
49
49
|
import { appendText, copyFile, printOut, printErr, readText, writeText } from '../lib/fs.mjs';
|
|
50
|
-
import { toPlatformPath, which } from '../lib/paths.mjs';
|
|
50
|
+
import { comparePaths, toPlatformPath, which } from '../lib/paths.mjs';
|
|
51
51
|
import { WHITESPACE, parseIntStrict, stripWhitespace } from '../lib/text.mjs';
|
|
52
52
|
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
53
|
-
import { bounceDaemonIfRunning } from '../web/daemon.mjs';
|
|
54
53
|
|
|
55
54
|
/**
|
|
56
55
|
* `_CREDS_RE` / `_redact_url_creds` — strip a `user[:token]@` userinfo out of any URL in
|
|
@@ -248,8 +247,14 @@ export function countOccurrences(s) {
|
|
|
248
247
|
export function killTree(child) {
|
|
249
248
|
try {
|
|
250
249
|
if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL');
|
|
251
|
-
else
|
|
252
|
-
|
|
250
|
+
else {
|
|
251
|
+
// `which`, never a bare name (a bare name resolves against the cwd first on Windows);
|
|
252
|
+
// no taskkill on PATH falls through to the `child.kill()` below.
|
|
253
|
+
const taskkill = which('taskkill');
|
|
254
|
+
if (!taskkill) throw new Error('taskkill not on PATH');
|
|
255
|
+
spawnSync(taskkill, ['/F', '/T', '/PID', String(child.pid)],
|
|
256
|
+
{ encoding: 'utf8', timeout: 15000, ...NO_WINDOW });
|
|
257
|
+
}
|
|
253
258
|
} catch {
|
|
254
259
|
try { child.kill(); } catch { /* already gone */ }
|
|
255
260
|
}
|
|
@@ -262,7 +267,7 @@ export function killTree(child) {
|
|
|
262
267
|
* times and universal-newline decoding turns every `\r` repaint into its own line, so
|
|
263
268
|
* without this a 30-second fetch writes a thousand lines into the install log. No cell can
|
|
264
269
|
* reach it — a local-path fetch prints nothing at all — so
|
|
265
|
-
* `tests/
|
|
270
|
+
* `tests/unit/update_git.test.mjs` runs it over a recorded fetch transcript instead.
|
|
266
271
|
*
|
|
267
272
|
* Returns `[linesToLog, lastPhase]`, splitting the reference's loop from its side effects.
|
|
268
273
|
*/
|
|
@@ -366,13 +371,19 @@ export async function fetchStreaming(log = null) {
|
|
|
366
371
|
|
|
367
372
|
/**
|
|
368
373
|
* `_measure_upstream(log)` — Phase B: fetch (streamed), then classify. Returns
|
|
369
|
-
* `[code, behind, err]` with `code` ∈ {ready, fetch_failed, unrelated, diverged,
|
|
374
|
+
* `[code, behind, err]` with `code` ∈ {ready, fetch_failed, no_upstream, unrelated, diverged,
|
|
375
|
+
* uptodate}.
|
|
376
|
+
*
|
|
377
|
+
* `no_upstream` HERE, AFTER PREFLIGHT SAID THERE WAS ONE: the fetch itself can take it away.
|
|
378
|
+
* An upstream branch deleted on the remote, fetched with `fetch.prune`, leaves `@{u}` naming
|
|
379
|
+
* nothing — both counts then fail, and read as two zeros they said "already up to date".
|
|
370
380
|
*/
|
|
371
381
|
export async function measureUpstream(log = null) {
|
|
372
382
|
const [rc, err] = await fetchStreaming(log);
|
|
373
383
|
if (rc !== 0) return ['fetch_failed', 0, err];
|
|
374
|
-
const [, aheadRaw] = gitRun(['rev-list', '--count', '@{u}..HEAD']);
|
|
375
|
-
const [, behindRaw] = gitRun(['rev-list', '--count', 'HEAD..@{u}']);
|
|
384
|
+
const [arc, aheadRaw] = gitRun(['rev-list', '--count', '@{u}..HEAD']);
|
|
385
|
+
const [brc, behindRaw] = gitRun(['rev-list', '--count', 'HEAD..@{u}']);
|
|
386
|
+
if (arc !== 0 || brc !== 0) return ['no_upstream', 0, ''];
|
|
376
387
|
const ahead = countOccurrences(aheadRaw);
|
|
377
388
|
const behind = countOccurrences(behindRaw);
|
|
378
389
|
if (ahead > 0) {
|
|
@@ -419,16 +430,31 @@ export function runDoctor(cand) {
|
|
|
419
430
|
return [r.status === 0, universal(r.stdout) + universal(r.stderr)];
|
|
420
431
|
}
|
|
421
432
|
|
|
422
|
-
/**
|
|
433
|
+
/**
|
|
434
|
+
* `_pull_and_validate(log)` — fast-forward to `@{u}`, then doctor-gate with exact rollback.
|
|
435
|
+
*
|
|
436
|
+
* `collision` ONLY WHEN GIT SAYS SO. A fast-forward can fail for other reasons — a lock file, a
|
|
437
|
+
* permission, a merge refused outright — and calling every one an untracked-file collision
|
|
438
|
+
* sent the user hunting for a file that is not there, with an info exit (3) over an error.
|
|
439
|
+
*
|
|
440
|
+
* THE ROLLBACK IS `reset --keep`, NEVER `--hard`. The doctor window lasts up to 300 s, and an
|
|
441
|
+
* edit made in the checkout meanwhile is a tracked edit `--hard` would destroy without a word.
|
|
442
|
+
* `--keep` refuses instead when such an edit touches a file the rollback must change; that
|
|
443
|
+
* refusal is reported as itself — the user is left on the pulled commit, with the command to
|
|
444
|
+
* finish the rollback — rather than as a rollback that did not happen.
|
|
445
|
+
*/
|
|
423
446
|
export function pullAndValidate(log) {
|
|
424
447
|
const [rc0, old] = gitRun(['rev-parse', 'HEAD']);
|
|
425
448
|
if (rc0 !== 0 || !old) return [false, 'not_git', 'could not read HEAD'];
|
|
426
449
|
log('[geneseed] fast-forwarding to upstream ...');
|
|
427
450
|
const [rc1, , err] = gitRun(['merge', '--ff-only', '@{u}'], { timeout: 60 });
|
|
428
451
|
if (rc1 !== 0) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
452
|
+
if (/would be overwritten/.test(err)) {
|
|
453
|
+
return [false, 'collision',
|
|
454
|
+
'Update blocked — a new upstream file collides with a local untracked '
|
|
455
|
+
+ 'file. Move or remove it, then update.\n' + err];
|
|
456
|
+
}
|
|
457
|
+
return [false, 'merge_failed', `Update failed — could not fast-forward to upstream.\n${err}`];
|
|
432
458
|
}
|
|
433
459
|
const [rc2, pulled] = gitRun(['log', '--oneline', '--no-decorate', '-20', `${old}..HEAD`]);
|
|
434
460
|
if (rc2 === 0 && pulled) {
|
|
@@ -439,8 +465,15 @@ export function pullAndValidate(log) {
|
|
|
439
465
|
const [passed, output] = runDoctor(ROOT);
|
|
440
466
|
log(output.replace(/\n+$/, ''));
|
|
441
467
|
if (!passed) {
|
|
442
|
-
gitRun(['reset', '--
|
|
468
|
+
const [rc3, , rerr] = gitRun(['reset', '--keep', old], { timeout: 60 });
|
|
443
469
|
for (const line of DOCTOR_LEGEND) log(line);
|
|
470
|
+
if (rc3 !== 0) {
|
|
471
|
+
return [false, 'doctor_fail',
|
|
472
|
+
'the pulled source FAILS validation, and the rollback was REFUSED — a file it must '
|
|
473
|
+
+ 'restore was edited while the doctor ran, and those edits were kept. You are still '
|
|
474
|
+
+ `on the pulled commit; save your edits, then roll back with: git reset --keep ${old}\n`
|
|
475
|
+
+ rerr];
|
|
476
|
+
}
|
|
444
477
|
return [false, 'doctor_fail',
|
|
445
478
|
'the pulled source FAILS validation — rolled back to the previous commit. '
|
|
446
479
|
+ 'Fix the problems listed above.'];
|
|
@@ -505,8 +538,9 @@ export function markerTheme(cfg, out) {
|
|
|
505
538
|
}
|
|
506
539
|
|
|
507
540
|
/**
|
|
508
|
-
* `_config_theme(here)` — the theme the
|
|
509
|
-
*
|
|
541
|
+
* `_config_theme(here)` — the theme the checkout's `harness.config.json` asks for. Fallback
|
|
542
|
+
* only; a bundle marker still wins. Read before the pull, but nothing local is at stake: the
|
|
543
|
+
* file is tracked and `preflight` refuses a dirty tree, so the value read is the committed one.
|
|
510
544
|
*/
|
|
511
545
|
export function configTheme(here) {
|
|
512
546
|
try {
|
|
@@ -518,11 +552,16 @@ export function configTheme(here) {
|
|
|
518
552
|
/**
|
|
519
553
|
* `_migrate_stray_bundle` — move host state (context.json, memory/) from an OLD in-folder
|
|
520
554
|
* bundle to the canonical sibling `out` BEFORE rebuilding, then drop the stray.
|
|
555
|
+
*
|
|
556
|
+
* "IS `out` THE STRAY ITSELF?" IS A PATH COMPARISON, NOT A STRING ONE. `GENESEED_OUT` is typed
|
|
557
|
+
* by a user, so on Windows `c:\x\Geneseed\harness` is the same directory as the stray and was a
|
|
558
|
+
* different string: the rescue was skipped as "nothing to move into", and the `rmSync` at the
|
|
559
|
+
* bottom then deleted the LIVE bundle — memory/ and context.json with it.
|
|
521
560
|
*/
|
|
522
561
|
export function migrateStrayBundle(here, out, log) {
|
|
523
562
|
const stray = path.join(String(here), 'Harness');
|
|
524
563
|
const outStr = String(out);
|
|
525
|
-
if (outStr ===
|
|
564
|
+
if (comparePaths(path.resolve(outStr), path.resolve(stray)) === 0) return;
|
|
526
565
|
try { if (!statSync(stray).isDirectory()) return; } catch { return; }
|
|
527
566
|
mkdirSync(outStr, { recursive: true });
|
|
528
567
|
const ctx = path.join(stray, 'context.json');
|
|
@@ -559,15 +598,40 @@ async function drainStdout() {
|
|
|
559
598
|
await new Promise((resolve) => { process.stdout.write('', resolve); });
|
|
560
599
|
}
|
|
561
600
|
|
|
601
|
+
/**
|
|
602
|
+
* The flags that re-emit the install at `dir` on its OWN axes — footprint, posture, mode,
|
|
603
|
+
* trust, doctrine packs, excluded rules — or `[]` when no build ever wrote a marker there.
|
|
604
|
+
*
|
|
605
|
+
* WITHOUT THEM THE REBUILD RESETS THE INSTALL. The generator's defaults are not the install's:
|
|
606
|
+
* a missing `--footprint` is `lean`, and posture, mode, packs and excluded rules come from
|
|
607
|
+
* `harness.config.json`. So a `full`, mentor, narrowed or rule-excluded install was re-emitted
|
|
608
|
+
* as the config default — markers included — and `rebuild-all` right after reads those
|
|
609
|
+
* rewritten markers back through `installProfile`, so the loss stuck. `installProfile` is the
|
|
610
|
+
* one reader of an install's axes; this takes its answer rather than growing a second one.
|
|
611
|
+
*
|
|
612
|
+
* Only the TAIL of the argv is kept: theme and emit are `upgrade`'s to decide (a theme argument
|
|
613
|
+
* may override the marker), and `setupBuildArgs` opens with exactly those four tokens when it
|
|
614
|
+
* is given no out and no root.
|
|
615
|
+
*/
|
|
616
|
+
export async function installAxes(dir, { installProfile, setupBuildArgs }) {
|
|
617
|
+
if (!existsSync(path.join(String(dir), '.geneseed-emit'))) return [];
|
|
618
|
+
const p = installProfile('opencode', 'global', String(dir));
|
|
619
|
+
return setupBuildArgs('', 'files', null, null, p.footprint, p.posture, p.mode, p.doctrines,
|
|
620
|
+
undefined, p.excludeRules, p.trust).slice(4);
|
|
621
|
+
}
|
|
622
|
+
|
|
562
623
|
/**
|
|
563
624
|
* `_rebuild_bundle` — render the bundle from the (already-updated) source and bounce a
|
|
564
|
-
* running web daemon. Returns the build subprocess returncode (0 = ok).
|
|
625
|
+
* running web daemon. Returns the build subprocess returncode (0 = ok). `axes` is
|
|
626
|
+
* `installAxes` of the target, read before the pull; `bounce` is the daemon module's
|
|
627
|
+
* `bounceDaemonIfRunning`, loaded before the pull for the reason the file header gives.
|
|
565
628
|
*/
|
|
566
|
-
export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
|
|
629
|
+
export async function rebuildBundle(here, out, theme, emit, rootDir, log, axes, bounce) {
|
|
567
630
|
const buildArgs = ['--out', String(out)];
|
|
568
631
|
if (theme) buildArgs.push('--theme', theme);
|
|
569
632
|
if (emit === 'opencode') buildArgs.push('--emit', 'opencode', '--root', String(rootDir));
|
|
570
633
|
else if (emit === 'opencode-global') buildArgs.push('--emit', 'opencode-global');
|
|
634
|
+
buildArgs.push(...axes);
|
|
571
635
|
log(`[geneseed] rebuilding bundle -> ${out} (theme: ${theme || 'config default'}, emit: ${emit}) ...`);
|
|
572
636
|
await drainStdout();
|
|
573
637
|
// THE GENERATOR, RE-EXECUTED. `js/build/generate.mjs`'s `cmdBuild` calls `driverMain` in-process
|
|
@@ -580,7 +644,7 @@ export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
|
|
|
580
644
|
// If a web daemon is running, bounce it so the new source and the freshly rebuilt web/dist
|
|
581
645
|
// take effect — otherwise the open PWA keeps hitting the old code. The web-job exception
|
|
582
646
|
// (`upgrade/the-web-job-marker-...` gates it) lives with the helper.
|
|
583
|
-
await
|
|
647
|
+
await bounce(theme, log);
|
|
584
648
|
return 0;
|
|
585
649
|
}
|
|
586
650
|
|
|
@@ -636,6 +700,12 @@ export async function upgrade(ref = null, themeArg = null) {
|
|
|
636
700
|
return p.kind === 'info' ? 3 : 1;
|
|
637
701
|
}
|
|
638
702
|
|
|
703
|
+
// Loaded and read BEFORE the pull — see the file header for why the imports cannot wait, and
|
|
704
|
+
// `installAxes` for why the target's own axes must be carried into the rebuild.
|
|
705
|
+
const [generate, { bounceDaemonIfRunning }] = await Promise.all([
|
|
706
|
+
import('../build/generate.mjs'), import('../web/daemon.mjs')]);
|
|
707
|
+
const axes = await installAxes(emit === 'opencode-global' ? cfg : out, generate);
|
|
708
|
+
|
|
639
709
|
log(`[geneseed] fetching from origin (git: ${which('git') || 'git'}, `
|
|
640
710
|
+ `timeout: ${fetchTimeout()}s) ...`);
|
|
641
711
|
const [code, behind, err] = await measureUpstream(log);
|
|
@@ -647,6 +717,10 @@ export async function upgrade(ref = null, themeArg = null) {
|
|
|
647
717
|
+ 'Kerberos credentials). Restart it from that terminal: `geneseed web restart`.');
|
|
648
718
|
return 1;
|
|
649
719
|
}
|
|
720
|
+
if (code === 'no_upstream') {
|
|
721
|
+
log(`[geneseed] ${PRE_MSG.no_upstream[1]}`);
|
|
722
|
+
return 3;
|
|
723
|
+
}
|
|
650
724
|
if (code === 'unrelated') {
|
|
651
725
|
log('[geneseed] Upstream history was rewritten; back up local work, then re-clone '
|
|
652
726
|
+ 'or `git reset --hard @{u}`.');
|
|
@@ -676,7 +750,8 @@ export async function upgrade(ref = null, themeArg = null) {
|
|
|
676
750
|
} catch (e) {
|
|
677
751
|
log(`[geneseed] ⚠️ could not migrate the old in-folder bundle (${e && e.message ? e.message : e}) — continuing.`);
|
|
678
752
|
}
|
|
679
|
-
let rc = await rebuildBundle(here, out, theme, emit, rootDir, log
|
|
753
|
+
let rc = await rebuildBundle(here, out, theme, emit, rootDir, log, axes,
|
|
754
|
+
bounceDaemonIfRunning);
|
|
680
755
|
if (rc !== 0) {
|
|
681
756
|
log(`[geneseed][E-BUILD] ✗ the bundle build FAILED (theme: ${theme || 'default'}, emit: ${emit}).`);
|
|
682
757
|
return 1;
|
|
@@ -886,13 +961,19 @@ function reexec(argv) {
|
|
|
886
961
|
* `flushExportNotes()` runs BEFORE the handoff, deliberately: the re-exec replaces this process
|
|
887
962
|
* and the update step's own improvements notice has scrolled past (or, under the reference's
|
|
888
963
|
* curses arm, died with the alternate screen).
|
|
964
|
+
*
|
|
965
|
+
* A FAILED UPDATE STILL EXITS 1 AFTER THE HANDOFF. `setup` still runs — the user asked for it,
|
|
966
|
+
* and it can repair what the update could not — but its exit code used to become bootstrap's,
|
|
967
|
+
* so a script saw 0 over an update that never happened. `handoff` is the seam a test needs:
|
|
968
|
+
* the real `setup` exits 1 off a terminal, which would make the two answers indistinguishable.
|
|
889
969
|
*/
|
|
890
|
-
export async function cmdBootstrap(args) {
|
|
970
|
+
export async function cmdBootstrap(args, { handoff = reexec } = {}) {
|
|
891
971
|
const failed = await bootstrapPlain();
|
|
892
972
|
flushExportNotes();
|
|
893
973
|
if (!args.noSetup) {
|
|
894
|
-
|
|
974
|
+
const rc = handoff([process.execPath,
|
|
895
975
|
path.join(String(ROOT), 'bin', 'geneseed-cli.mjs'), 'setup']);
|
|
976
|
+
return failed ? 1 : rc;
|
|
896
977
|
}
|
|
897
978
|
// Scripted `bootstrap --no-setup` must not exit 0 over a failed update.
|
|
898
979
|
return failed ? 1 : 0;
|