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
package/js/loop/state.mjs
CHANGED
|
@@ -43,9 +43,9 @@ import { iterationLoop, ENGINE_MAX_ITERATIONS } from './graph.mjs';
|
|
|
43
43
|
|
|
44
44
|
const BEGIN = '<!-- loop-state:begin -->';
|
|
45
45
|
const END = '<!-- loop-state:end -->';
|
|
46
|
-
|
|
46
|
+
const VERIFY_COMMAND = 'git add -A && git diff --cached --numstat | geneseed loop score --diff';
|
|
47
47
|
export const LOOP_FILE = 'LOOP.md';
|
|
48
|
-
|
|
48
|
+
const LOOP_FILE_TMP = `${LOOP_FILE}.tmp`;
|
|
49
49
|
const slash = (p) => String(p).replaceAll('\\', '/');
|
|
50
50
|
|
|
51
51
|
export function renderLoopFile(state) {
|
|
@@ -101,7 +101,11 @@ export function parseLoopFile(text) {
|
|
|
101
101
|
const head = (state) => iterationLoop(state.graph).nodes[0];
|
|
102
102
|
const fmt = (n) => Number(n).toFixed(1);
|
|
103
103
|
|
|
104
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* `contracts`: extra globs (`loop init --contracts`), appended after the graph's own. Every
|
|
106
|
+
* field the engine ever writes is listed here, `gateAmends` included (`decideGate` still reads
|
|
107
|
+
* it with `?.`, for a LOOP.md written before it was listed).
|
|
108
|
+
*/
|
|
105
109
|
export function initState({ title, requirement, graph, preset = DEFAULT_PRESET, contracts = [] }) {
|
|
106
110
|
if (!Object.hasOwn(PRESETS, preset)) throw new Error(`unknown preset ${JSON.stringify(preset)}`);
|
|
107
111
|
const loop = iterationLoop(graph);
|
|
@@ -111,7 +115,7 @@ export function initState({ title, requirement, graph, preset = DEFAULT_PRESET,
|
|
|
111
115
|
iteration: loop.nodes.includes(graph.start) ? 1 : 0,
|
|
112
116
|
card: null, validated: false, pendingVerify: false, closing: false, counters: {},
|
|
113
117
|
resplit: false, emptyStreak: 0, snapshot: '', visited: [], tests: 'n/a', current: {},
|
|
114
|
-
notes: [], history: [],
|
|
118
|
+
notes: [], history: [], gateAmends: {},
|
|
115
119
|
};
|
|
116
120
|
if (preset === 'prudent') { state.status = 'awaiting'; state.awaiting = { kind: 'launch' }; }
|
|
117
121
|
return state;
|
|
@@ -127,12 +131,14 @@ function resetUnit(state) {
|
|
|
127
131
|
state.current = {}; state.tests = 'n/a';
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/** The soft-decision iterations of `state.history` — what the end-of-run summary and the
|
|
135
|
+
* registry's Active-tab row both call `review`, so the two read the same fields. */
|
|
136
|
+
export function reviewOf(state) {
|
|
137
|
+
return state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent }));
|
|
138
|
+
}
|
|
139
|
+
|
|
130
140
|
function summary(state) {
|
|
131
|
-
return {
|
|
132
|
-
iterations: state.history.length,
|
|
133
|
-
review: state.history.filter((h) => h.decision === 'soft').map((h) => ({ iteration: h.iteration, intent: h.intent })),
|
|
134
|
-
notes: state.notes,
|
|
135
|
-
};
|
|
141
|
+
return { iterations: state.history.length, review: reviewOf(state), notes: state.notes };
|
|
136
142
|
}
|
|
137
143
|
|
|
138
144
|
export function trailers(state) {
|
|
@@ -296,13 +302,31 @@ function filterPorcelain(porcelain) {
|
|
|
296
302
|
|
|
297
303
|
// Setup work (iteration 0, graph.start outside the iteration loop) is its own unit; rejecting
|
|
298
304
|
// it has nowhere to retry back into, so it stops the loop instead of looping to the head.
|
|
305
|
+
// Iteration 0 alone says so: `initState` starts at 0 only when graph.start is outside the
|
|
306
|
+
// iteration loop, and `iteration` only ever counts up from there.
|
|
299
307
|
function isSetupUnit(state) {
|
|
300
|
-
return state.iteration === 0
|
|
308
|
+
return state.iteration === 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
312
|
+
|
|
313
|
+
// A card arrives from the model as JSON (`record --card`), so its shape is checked before
|
|
314
|
+
// anything is stored: a string `writeSet` stored here only throws later, inside `score --diff`,
|
|
315
|
+
// with `pendingVerify` already set — and `score --declared` refuses while that is set, so
|
|
316
|
+
// nothing could get the loop moving again.
|
|
317
|
+
function cardProblem(card) {
|
|
318
|
+
if (typeof card !== 'object' || Array.isArray(card)) return 'the card must be a JSON object';
|
|
319
|
+
if (!isStringList(card.actions)) return "the card's actions must be a list of strings";
|
|
320
|
+
if (!isStringList(card.writeSet)) return "the card's writeSet must be a list of strings";
|
|
321
|
+
if (card.intent != null && typeof card.intent !== 'string') return "the card's intent must be a string";
|
|
322
|
+
return null;
|
|
301
323
|
}
|
|
302
324
|
|
|
303
325
|
export function recordOutcome(state, bricks, outcome, { card = null, porcelain = null, note = '' } = {}) {
|
|
304
326
|
assertRunning(state);
|
|
305
327
|
if (state.pendingVerify) throw new Error('the iteration is closed: run `geneseed loop score --diff` first');
|
|
328
|
+
const problem = card === null ? null : cardProblem(card);
|
|
329
|
+
if (problem) throw new Error(problem);
|
|
306
330
|
const from = state.node;
|
|
307
331
|
const brick = bricks.get(from);
|
|
308
332
|
if (!brick) throw new Error(`no brick named ${from} in the catalogue`);
|
|
@@ -323,7 +347,7 @@ export function recordOutcome(state, bricks, outcome, { card = null, porcelain =
|
|
|
323
347
|
// `fail` findings) — pushed before the transition below, so it lands in `notes` even when
|
|
324
348
|
// this call closes the unit, re-splits, or stops the loop. `notes` is never reset mid-run
|
|
325
349
|
// (only `exhaust`'s own push and `decideAwaiting` add to it otherwise), so it survives
|
|
326
|
-
// `resetUnit`/`finishUnit`
|
|
350
|
+
// `resetUnit`/`finishUnit` — which a card does not: `resetUnit` clears it.
|
|
327
351
|
if (note) state.notes.push(`iteration ${state.iteration} (${from}): ${note}`);
|
|
328
352
|
state.visited.push(from);
|
|
329
353
|
|
package/js/maintain/memory.mjs
CHANGED
|
@@ -17,12 +17,11 @@
|
|
|
17
17
|
* beside the cwd, then the global config dir — with `--memory` as the explicit override.
|
|
18
18
|
* `status` prints the answer of that same resolver, so the two agree about which store
|
|
19
19
|
* is "the" store.
|
|
20
|
-
* * THE RESERVED-NAME CHECK IS CASE-INSENSITIVE,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* divergence in this file that is not about resolution.
|
|
20
|
+
* * THE RESERVED-NAME CHECK IS CASE-INSENSITIVE, and the web endpoints use this one. On a
|
|
21
|
+
* case-insensitive filesystem — every Windows install and the default macOS one — an
|
|
22
|
+
* exact `MEMORY`/`README` compare let `rm memory` resolve to the index file, pass the
|
|
23
|
+
* guard, and delete the thing the agent reads at session start. The endpoint once had
|
|
24
|
+
* that exact compare; it now calls `memoryDelete` from here.
|
|
26
25
|
*
|
|
27
26
|
* `memoryDropIndex` is the hook module's, not a copy: writing the fact and unwriting its
|
|
28
27
|
* index line are two halves of one format, and the half that writes lives there.
|
|
@@ -30,7 +29,7 @@
|
|
|
30
29
|
import { readdirSync, unlinkSync } from 'node:fs';
|
|
31
30
|
import path from 'node:path';
|
|
32
31
|
|
|
33
|
-
import { memoryDropIndex } from '../hosts/
|
|
32
|
+
import { memoryDropIndex } from '../hosts/memory-files.mjs';
|
|
34
33
|
import { resolveMemoryDir } from '../hosts/hosts.mjs';
|
|
35
34
|
import { printOut, printErr, isFile } from '../lib/fs.mjs';
|
|
36
35
|
|
package/js/maintain/migrate.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* P10d, AND IT IS NOT A PORT. Every other module here reproduces a Python original byte for
|
|
5
5
|
* byte; this verb is new behaviour on both sides, written twice in the same change so that
|
|
6
|
-
*
|
|
7
|
-
* Node-only verb would have nothing to compare against, and `
|
|
8
|
-
*
|
|
6
|
+
* the former Python CLI matrix — a CROSS-IMPLEMENTATION gate — had a reference side at all. A
|
|
7
|
+
* Node-only verb would have had nothing to compare against, and that suite's `test_every_entry_
|
|
8
|
+
* verb_is_a_real_harness_subcommand` plus `test_the_matrix_covers_every_verb_it_claims` would
|
|
9
|
+
* both have failed it.
|
|
9
10
|
*
|
|
10
11
|
* WHAT IT IS NOT: `rebuild-all`. That was the first answer and it was checked before being
|
|
11
12
|
* discarded, because the re-emit really is the same engine and is reused rather than rewritten
|
|
@@ -35,17 +36,14 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:
|
|
|
35
36
|
import path from 'node:path';
|
|
36
37
|
|
|
37
38
|
import { main as driverMain } from '../build/driver.mjs';
|
|
38
|
-
import {
|
|
39
|
+
import { ROOT } from '../build/source.mjs';
|
|
40
|
+
import { CLAUDE_STYLE } from '../hosts/hosts.mjs';
|
|
41
|
+
import { EMIT_HOST_SCOPE, installState, installTargets, readMaybe } from '../hosts/installs.mjs';
|
|
42
|
+
import { installProfile } from '../build/generate.mjs';
|
|
39
43
|
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} from '../hosts/installs.mjs';
|
|
44
|
-
import { DEFAULT_EMIT, setupBuildArgs } from '../build/generate.mjs';
|
|
45
|
-
import { DEFAULT_PRESET } from '../loop/score.mjs';
|
|
46
|
-
import {
|
|
47
|
-
autostartPaths, autostartStale, hookShimPath, migrateShape, readJsonc, shimHome,
|
|
48
|
-
} from '../hosts/settings.mjs';
|
|
44
|
+
autostartPaths, autostartStale, hookShimPath, migrateShape, shimHome,
|
|
45
|
+
} from '../hosts/shim.mjs';
|
|
46
|
+
import { loadJsonObject } from '../hosts/settings.mjs';
|
|
49
47
|
import { writeText, printOut, printErr } from '../lib/fs.mjs';
|
|
50
48
|
|
|
51
49
|
/** Every emit name the generator answers to — the set an unrecognised marker is NOT in. */
|
|
@@ -61,21 +59,20 @@ const KNOWN_EMITS = new Set(EMIT_HOST_SCOPE.keys());
|
|
|
61
59
|
* host-parity rule asks for.
|
|
62
60
|
*/
|
|
63
61
|
function hookSettingsFile(root, host, scope) {
|
|
64
|
-
if (!
|
|
62
|
+
if (!CLAUDE_STYLE.includes(host)) return null;
|
|
65
63
|
const name = scope === 'project' && host !== 'bob' ? 'settings.local.json' : 'settings.json';
|
|
66
64
|
return path.join(root, name);
|
|
67
65
|
}
|
|
68
66
|
|
|
69
67
|
/** Every hook command string in a settings file, or [] when it cannot be read or parsed. */
|
|
70
68
|
function hookCommandsOf(file) {
|
|
71
|
-
// `
|
|
72
|
-
// comma is the one shape no machine in this project writes, and strict JSON rejects it —
|
|
69
|
+
// `loadJsonObject` (JSONC), NOT `JSON.parse`. A hand-edited settings.json with comments and a
|
|
70
|
+
// trailing comma is the one shape no machine in this project writes, and strict JSON rejects it —
|
|
73
71
|
// which would classify a wired install as `none` and make `migrate` skip the machine
|
|
74
72
|
// silently. `mergeClaudeSettings` already reads through comments for exactly this reason.
|
|
75
73
|
// Found by the JSONC corpus fixture; a generated fixture would have hidden it.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!loaded || typeof loaded !== 'object') return [];
|
|
74
|
+
const { state, data: loaded } = loadJsonObject(file);
|
|
75
|
+
if (state !== 'ok') return [];
|
|
79
76
|
const hooks = loaded.hooks;
|
|
80
77
|
if (!hooks || typeof hooks !== 'object') return [];
|
|
81
78
|
const out = [];
|
|
@@ -210,21 +207,13 @@ export function cmdMigrate(args = {}) {
|
|
|
210
207
|
try { rmSync(stash, { recursive: true, force: true }); } catch { /* nothing to undo */ }
|
|
211
208
|
};
|
|
212
209
|
|
|
213
|
-
// Step 3 — re-emit, in each install's OWN
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// `doctrinesForBuild`, never to `harness.config.json`, which is what makes it fail-closed.
|
|
210
|
+
// Step 3 — re-emit, in each install's OWN values: `installProfile`, the one reading
|
|
211
|
+
// `rebuild-all` and `status` share. This loop hand-copied it once and drifted twice — it
|
|
212
|
+
// passed the excluded rules as `null` (re-admitting every rule an owner switched off,
|
|
213
|
+
// `process 5` among them) and dropped `--config-dir`, so a global found through
|
|
214
|
+
// `CLAUDE_CONFIG_DIR`/`OPENCODE_CONFIG_DIR` re-emitted into the DEFAULT dir instead.
|
|
219
215
|
for (const r of rows) {
|
|
220
|
-
const
|
|
221
|
-
? r.marker : '';
|
|
222
|
-
const emit = marker || DEFAULT_EMIT.get(`${r.host} ${r.scope}`) || 'opencode-global';
|
|
223
|
-
const theme = themeOfDir(r.root) || defaultTheme();
|
|
224
|
-
const out = r.scope === 'global' ? null : r.root;
|
|
225
|
-
const argv = setupBuildArgs(theme, emit, out, out, footprintOfDir(r.root),
|
|
226
|
-
postureOfDir(r.root) || defaultPosture(), modeOfDir(r.root) || defaultMode(),
|
|
227
|
-
doctrinesForBuild(r.root), PACK_ORDER, null, trustOfDir(r.root) || DEFAULT_PRESET);
|
|
216
|
+
const { emit, theme, argv } = installProfile(r.host, r.scope, r.root);
|
|
228
217
|
const label = `${r.host}:${r.scope} (${r.root})`;
|
|
229
218
|
printOut(`[migrate] re-emitting ${label}: theme=${theme} emit=${emit}\n`);
|
|
230
219
|
let rc = 1;
|
package/js/maintain/setup.mjs
CHANGED
|
@@ -31,17 +31,17 @@
|
|
|
31
31
|
* and a cell's stdin is a pipe. So the whole wizard is unreachable from the acceptance
|
|
32
32
|
* matrix in the same way `_confirm` was in P5h — except that here it is not one branch, it
|
|
33
33
|
* is the verb. The `setup/*` cells therefore gate the REFUSAL, absolutely and on both sides,
|
|
34
|
-
* and everything past it is gated as a CORPUS in `tests/
|
|
34
|
+
* and everything past it is gated as a CORPUS in `tests/unit/wizard.test.mjs`.
|
|
35
35
|
*
|
|
36
36
|
* The corpus is a new shape and the brief predicted the reason: a corpus over a stdin reader
|
|
37
|
-
* needs a SEEDED FD, not a string. `tests/fixtures/pure_probe.
|
|
38
|
-
* redirected from a file of answers and
|
|
37
|
+
* needs a SEEDED FD, not a string. `tests/fixtures/pure_probe.mjs` is run with stdin
|
|
38
|
+
* redirected from a file of answers and its WHOLE stdout compared — so the prompts, the
|
|
39
39
|
* numbered menus, the default markers, the `About to run:` line and the returned selection
|
|
40
40
|
* are all one byte comparison. That is a stricter gate than a cell would have been, because
|
|
41
41
|
* a cell could not have varied the answers at all.
|
|
42
42
|
*/
|
|
43
43
|
import { spawnSync } from 'node:child_process';
|
|
44
|
-
import { existsSync
|
|
44
|
+
import { existsSync } from 'node:fs';
|
|
45
45
|
import path from 'node:path';
|
|
46
46
|
|
|
47
47
|
import { main as driverMain, resolveOut } from '../build/driver.mjs';
|
|
@@ -57,132 +57,10 @@ import {
|
|
|
57
57
|
} from '../hosts/installs.mjs';
|
|
58
58
|
import { printOut, printErr } from '../lib/fs.mjs';
|
|
59
59
|
import { which } from '../lib/paths.mjs';
|
|
60
|
+
import { ask, askChoice, confirm } from '../lib/prompt.mjs';
|
|
60
61
|
import { parseIntStrict } from '../lib/text.mjs';
|
|
61
62
|
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
62
63
|
|
|
63
|
-
// --------------------------------------------------------------------------------------
|
|
64
|
-
// the three readers
|
|
65
|
-
// --------------------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* `_harness_setup._ask` — `input(f"{prompt}{suffix}: ").strip()`, or the default at EOF.
|
|
69
|
-
*
|
|
70
|
-
* WHY IT READS A BYTE AT A TIME. Node has no synchronous line read: `readFileSync(0)` blocks
|
|
71
|
-
* until EOF rather than until Enter, so a wizard built on it would hang on a real terminal
|
|
72
|
-
* until the user pressed Ctrl-D. Reading fd 0 one byte at a time to the first `\n` is what
|
|
73
|
-
* `input()` does, and it is the only synchronous shape that stops where Enter stops. The
|
|
74
|
-
* body arrived in P5h inside `js/maintain/uninstall.mjs`'s `confirm`; `setup` is the second caller,
|
|
75
|
-
* so it moved to where the Python's own owner is — `_harness_setup` — and `confirm` is built
|
|
76
|
-
* on it here exactly as `_confirm` is built on `_ask` there.
|
|
77
|
-
*
|
|
78
|
-
* The EOF branch is the one a piped caller sees, and it agrees with Python by two different
|
|
79
|
-
* routes that happen to produce one answer: `input()` RAISES `EOFError` and the `except`
|
|
80
|
-
* returns the default, while a zero-length read here leaves `line` empty and `|| dflt` does
|
|
81
|
-
* the same. A partial line with no trailing newline is not EOF to either — Python returns
|
|
82
|
-
* the characters and so does this.
|
|
83
|
-
*
|
|
84
|
-
* `.trim()` is `str.strip()` in every case but U+FEFF, which JS counts as whitespace and
|
|
85
|
-
* Python does not; that is a standing item of this port and not this function's to settle.
|
|
86
|
-
*/
|
|
87
|
-
export function ask(prompt, dflt = '') {
|
|
88
|
-
const suffix = dflt ? ` [${dflt}]` : '';
|
|
89
|
-
// `input()` writes its prompt to stdout with no newline — nothing for `printOut` to
|
|
90
|
-
// translate, and it must NOT gain one.
|
|
91
|
-
process.stdout.write(`${prompt}${suffix}: `);
|
|
92
|
-
// `?? ''` because `readLine` reports EOF-with-nothing-read as null since P6h; `_ask`
|
|
93
|
-
// catches `EOFError` and returns the default, which is what the empty string produces
|
|
94
|
-
// here through `|| dflt`. The two routes to one answer are the docblock's own point.
|
|
95
|
-
const ans = (readLine() ?? '').trim();
|
|
96
|
-
return ans || dflt;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* `input(prompt)` with EOF told apart from an empty line — `null` where Python RAISES.
|
|
101
|
-
*
|
|
102
|
-
* `ask` above cannot express that difference and does not need to: its EOF and its empty
|
|
103
|
-
* answer both mean "take the default". `_web_server.serve`'s npm prompt is the first
|
|
104
|
-
* caller where they differ, and they differ dangerously — `""` is in its accepted set, so
|
|
105
|
-
* a port that read EOF as an empty line would answer YES to "run npm install now?" on
|
|
106
|
-
* every non-interactive run. The reference catches `(EOFError, KeyboardInterrupt)` and
|
|
107
|
-
* answers "n"; this returns null so the caller can.
|
|
108
|
-
*
|
|
109
|
-
* No `.trim()`: `input()` does not strip, and both callers strip for themselves.
|
|
110
|
-
*/
|
|
111
|
-
export function promptLine(prompt) {
|
|
112
|
-
process.stdout.write(prompt);
|
|
113
|
-
return readLine();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Fd 0 to the first `\n`, dropping a `\r` before it — the CRLF a Windows console sends.
|
|
118
|
-
*
|
|
119
|
-
* THE BYTES ARE COLLECTED AND DECODED ONCE, and that is a fix rather than a style. The
|
|
120
|
-
* version this arrived as decoded EACH BYTE on its own — `buf.toString('utf8', 0, n)` inside
|
|
121
|
-
* the loop — which turns every multi-byte character into two or three U+FFFD. It was
|
|
122
|
-
* invisible for a phase because its only caller was `confirm`, whose answer is `y` or `n`;
|
|
123
|
-
* the wizard's first non-ASCII answer is what exposed it, and the corpus caught it on the
|
|
124
|
-
* first run. Scanning for the terminators a byte at a time is still correct: `\n` and `\r`
|
|
125
|
-
* are ASCII, and no continuation byte of a UTF-8 sequence is below 0x80.
|
|
126
|
-
*/
|
|
127
|
-
function readLine() {
|
|
128
|
-
const buf = Buffer.alloc(1);
|
|
129
|
-
const bytes = [];
|
|
130
|
-
let eof = false;
|
|
131
|
-
for (;;) {
|
|
132
|
-
let n = 0;
|
|
133
|
-
try { n = readSync(0, buf, 0, 1, null); } catch { eof = true; break; }
|
|
134
|
-
if (n === 0) { eof = true; break; }
|
|
135
|
-
if (buf[0] === 0x0a) break;
|
|
136
|
-
if (buf[0] !== 0x0d) bytes.push(buf[0]);
|
|
137
|
-
}
|
|
138
|
-
// NULL ONLY FOR THE EMPTY EOF, which is the one case `input()` raises on. A partial line
|
|
139
|
-
// with no trailing newline is not EOF to Python — it returns the characters — and the
|
|
140
|
-
// `bytes.length` test is what keeps that arm returning a string here too.
|
|
141
|
-
if (eof && bytes.length === 0) return null;
|
|
142
|
-
return Buffer.from(bytes).toString('utf8');
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** `_harness_setup._confirm` — `_ask` with a Y/n suffix; the default on an empty answer. */
|
|
146
|
-
export function confirm(prompt, dflt = true) {
|
|
147
|
-
const ans = ask(`${prompt} (${dflt ? 'Y/n' : 'y/N'})`).toLowerCase();
|
|
148
|
-
return ans === '' ? dflt : ans[0] === 'y';
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* `_harness_setup._ask_choice` — a numbered menu; the chosen KEY, or the default.
|
|
153
|
-
*
|
|
154
|
-
* THREE THINGS ABOUT THE FALLBACK ORDER, all of them observable and none of them obvious:
|
|
155
|
-
*
|
|
156
|
-
* * The key match lives in Python's `except ValueError`, so an answer that PARSES as an
|
|
157
|
-
* integer never reaches it. `99` against a five-option menu returns the default; it does
|
|
158
|
-
* not go looking for an option literally named `99`.
|
|
159
|
-
* * An in-range index wins, a parsed out-of-range index falls to the default, and only an
|
|
160
|
-
* UNPARSEABLE answer is matched against the keys. `parseIntStrict` is what separates the second
|
|
161
|
-
* case from the third, and it is not `Number()` — see its own docblock.
|
|
162
|
-
* * The default's index is computed with `next(...)` and no fallback, so a default that is
|
|
163
|
-
* not in `options` raises `StopIteration` on the reference. That is reachable (a
|
|
164
|
-
* deployed install can carry a theme this checkout no longer ships) and it is mirrored
|
|
165
|
-
* as a throw rather than smoothed over: the two implementations must fail in the same
|
|
166
|
-
* place, and a silent `-1` here would be a divergence dressed as robustness.
|
|
167
|
-
*/
|
|
168
|
-
export function askChoice(prompt, options, dflt) {
|
|
169
|
-
printOut(`\n${prompt}:\n`);
|
|
170
|
-
options.forEach(([key, desc], i) => {
|
|
171
|
-
const label = desc ? `${key} — ${desc}` : key;
|
|
172
|
-
printOut(` ${i + 1}) ${label}${key === dflt ? ' (default)' : ''}\n`);
|
|
173
|
-
});
|
|
174
|
-
const defaultIdx = options.findIndex(([k]) => k === dflt);
|
|
175
|
-
if (defaultIdx < 0) throw new Error(`StopIteration: no option named ${dflt}`);
|
|
176
|
-
const raw = ask('Choose', String(defaultIdx + 1));
|
|
177
|
-
const idx = parseIntStrict(raw);
|
|
178
|
-
if (idx !== null) {
|
|
179
|
-
if (idx >= 1 && idx <= options.length) return options[idx - 1][0];
|
|
180
|
-
} else {
|
|
181
|
-
for (const [key] of options) if (raw === key) return key;
|
|
182
|
-
}
|
|
183
|
-
return dflt;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
64
|
// --------------------------------------------------------------------------------------
|
|
187
65
|
// the option tables
|
|
188
66
|
// --------------------------------------------------------------------------------------
|
|
@@ -372,7 +250,7 @@ export function collectSetupLines() {
|
|
|
372
250
|
const mode = askChoice('Mode', modeOptions(), inst.mode || defaultMode());
|
|
373
251
|
const doctrines = askDoctrines(inst.doctrines);
|
|
374
252
|
const emit = askChoice('Install mode', EMIT_OPTIONS, inst.emit || 'opencode-global');
|
|
375
|
-
const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS
|
|
253
|
+
const footprint = askChoice('Footprint', FOOTPRINT_OPTIONS,
|
|
376
254
|
inst.footprint || 'lean');
|
|
377
255
|
let out = null;
|
|
378
256
|
let root = null;
|
|
@@ -430,8 +308,8 @@ export function javaMajorOk(versionOutput, minimum = 21) {
|
|
|
430
308
|
* `_harness_setup._lsp_prereqs` — `(label, present, hint)` for what OpenCode cannot install
|
|
431
309
|
* for itself. Today that is a JDK 21+ for `jdtls`; the JS-runtime servers self-download.
|
|
432
310
|
*
|
|
433
|
-
* THE SECOND SPAWN ON THIS ENTRY POINT, and the reason `
|
|
434
|
-
*
|
|
311
|
+
* THE SECOND SPAWN ON THIS ENTRY POINT, and the reason `ALLOWED_SPAWNS` in
|
|
312
|
+
* `tests/unit/hook_cli.test.mjs` became a TABLE. `js/inspect/doctor.mjs` was the sole allowed importer
|
|
435
313
|
* for one binding and one argv; this is the second, and the port's rule is that the second
|
|
436
314
|
* instance of anything turns a special case into a table cross-checked against the source.
|
|
437
315
|
* The argv is asserted literally there, so an allow-list entry cannot be reused to start an
|
|
@@ -572,7 +450,7 @@ export function setupSummaryLines(theme, emit, out, root, ok) {
|
|
|
572
450
|
* command the reader can type, and names one that survives the deletion.
|
|
573
451
|
*
|
|
574
452
|
* `theme_anim.play_line` USED TO BE THE SECOND, and P7c crossed it — `js/ui/anim.mjs`, gated by
|
|
575
|
-
*
|
|
453
|
+
* the theme-animation jobs in `tests/unit/wizard.test.mjs`. The bare `catch` around it
|
|
576
454
|
* is the reference's own, labelled there "cosmetic only — never block a successful install",
|
|
577
455
|
* and it is kept for the same reason: the animation runs AFTER the build has already
|
|
578
456
|
* succeeded, so nothing it can do may change the exit code.
|