geneseed 3.14.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 +54 -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 +37 -11
- 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 +22 -20
- package/bin/geneseed-hook.mjs +6 -3
- package/harness.config.json +1 -1
- package/js/README.md +9 -5
- package/js/build/args.mjs +4 -3
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +8 -7
- package/js/build/emit-claude.mjs +3 -2
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +15 -11
- package/js/build/render.mjs +1 -1
- package/js/build/source.mjs +6 -6
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +2 -1
- package/js/hosts/hooks-prims.mjs +2 -2
- package/js/hosts/hosts.mjs +24 -26
- package/js/hosts/installs.mjs +33 -14
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/memory-files.mjs +13 -4
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/shim.mjs +4 -2
- package/js/inspect/diff.mjs +5 -6
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/status.mjs +18 -21
- package/js/inspect/validate.mjs +2 -2
- 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/maintain/memory.mjs +5 -6
- package/js/maintain/migrate.mjs +4 -3
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +10 -9
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +29 -35
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +82 -386
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +33 -717
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- 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-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
- package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
- package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
- package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
- package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
- package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
- package/web/dist/assets/Tabs-B0cdf8k6.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-ujKG1ZNf.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
- package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
- package/web/dist/assets/index-KHt3wE_K.js +0 -2
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line-mode stdin readers — `ask`, `promptLine`, `confirm`, `askChoice` over one `readLine`.
|
|
3
|
+
*
|
|
4
|
+
* MOVED OUT OF `js/maintain/setup.mjs`, which is where the Python's own owner (`_harness_setup`)
|
|
5
|
+
* put them, because two callers that are not the wizard need them: `uninstall`'s `confirm` and
|
|
6
|
+
* the web server's npm prompt. Importing them from `setup.mjs` dragged the whole wizard closure
|
|
7
|
+
* — the driver, the doctor, the diff exporter — into `uninstall` and `web` for four functions
|
|
8
|
+
* whose only dependencies are fd 0, `printOut` and `parseIntStrict`.
|
|
9
|
+
*/
|
|
10
|
+
import { readSync } from 'node:fs';
|
|
11
|
+
|
|
12
|
+
import { printOut } from './fs.mjs';
|
|
13
|
+
import { parseIntStrict } from './text.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `_harness_setup._ask` — `input(f"{prompt}{suffix}: ").strip()`, or the default at EOF.
|
|
17
|
+
*
|
|
18
|
+
* WHY IT READS A BYTE AT A TIME. Node has no synchronous line read: `readFileSync(0)` blocks
|
|
19
|
+
* until EOF rather than until Enter, so a wizard built on it would hang on a real terminal
|
|
20
|
+
* until the user pressed Ctrl-D. Reading fd 0 one byte at a time to the first `\n` is what
|
|
21
|
+
* `input()` does, and it is the only synchronous shape that stops where Enter stops. The
|
|
22
|
+
* body arrived in P5h inside `js/maintain/uninstall.mjs`'s `confirm`, moved to `setup` when that
|
|
23
|
+
* became the second caller, and lives here now that the callers outnumber the wizard;
|
|
24
|
+
* `confirm` is built on it exactly as `_confirm` is built on `_ask` in the Python.
|
|
25
|
+
*
|
|
26
|
+
* The EOF branch is the one a piped caller sees, and it agrees with Python by two different
|
|
27
|
+
* routes that happen to produce one answer: `input()` RAISES `EOFError` and the `except`
|
|
28
|
+
* returns the default, while a zero-length read here leaves `line` empty and `|| dflt` does
|
|
29
|
+
* the same. A partial line with no trailing newline is not EOF to either — Python returns
|
|
30
|
+
* the characters and so does this.
|
|
31
|
+
*
|
|
32
|
+
* `.trim()` is `str.strip()` in every case but U+FEFF, which JS counts as whitespace and
|
|
33
|
+
* Python does not; that is a standing item of this port and not this function's to settle.
|
|
34
|
+
*/
|
|
35
|
+
export function ask(prompt, dflt = '') {
|
|
36
|
+
const suffix = dflt ? ` [${dflt}]` : '';
|
|
37
|
+
// `input()` writes its prompt to stdout with no newline — nothing for `printOut` to
|
|
38
|
+
// translate, and it must NOT gain one.
|
|
39
|
+
process.stdout.write(`${prompt}${suffix}: `);
|
|
40
|
+
// `?? ''` because `readLine` reports EOF-with-nothing-read as null since P6h; `_ask`
|
|
41
|
+
// catches `EOFError` and returns the default, which is what the empty string produces
|
|
42
|
+
// here through `|| dflt`. The two routes to one answer are the docblock's own point.
|
|
43
|
+
const ans = (readLine() ?? '').trim();
|
|
44
|
+
return ans || dflt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `input(prompt)` with EOF told apart from an empty line — `null` where Python RAISES.
|
|
49
|
+
*
|
|
50
|
+
* `ask` above cannot express that difference and does not need to: its EOF and its empty
|
|
51
|
+
* answer both mean "take the default". `_web_server.serve`'s npm prompt is the first
|
|
52
|
+
* caller where they differ, and they differ dangerously — `""` is in its accepted set, so
|
|
53
|
+
* a port that read EOF as an empty line would answer YES to "run npm install now?" on
|
|
54
|
+
* every non-interactive run. The reference catches `(EOFError, KeyboardInterrupt)` and
|
|
55
|
+
* answers "n"; this returns null so the caller can.
|
|
56
|
+
*
|
|
57
|
+
* No `.trim()`: `input()` does not strip, and both callers strip for themselves.
|
|
58
|
+
*/
|
|
59
|
+
export function promptLine(prompt) {
|
|
60
|
+
process.stdout.write(prompt);
|
|
61
|
+
return readLine();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fd 0 to the first `\n`, dropping a `\r` before it — the CRLF a Windows console sends.
|
|
66
|
+
*
|
|
67
|
+
* THE BYTES ARE COLLECTED AND DECODED ONCE, and that is a fix rather than a style. The
|
|
68
|
+
* version this arrived as decoded EACH BYTE on its own — `buf.toString('utf8', 0, n)` inside
|
|
69
|
+
* the loop — which turns every multi-byte character into two or three U+FFFD. It was
|
|
70
|
+
* invisible for a phase because its only caller was `confirm`, whose answer is `y` or `n`;
|
|
71
|
+
* the wizard's first non-ASCII answer is what exposed it, and the corpus caught it on the
|
|
72
|
+
* first run. Scanning for the terminators a byte at a time is still correct: `\n` and `\r`
|
|
73
|
+
* are ASCII, and no continuation byte of a UTF-8 sequence is below 0x80.
|
|
74
|
+
*/
|
|
75
|
+
function readLine() {
|
|
76
|
+
const buf = Buffer.alloc(1);
|
|
77
|
+
const bytes = [];
|
|
78
|
+
let eof = false;
|
|
79
|
+
for (;;) {
|
|
80
|
+
let n = 0;
|
|
81
|
+
try { n = readSync(0, buf, 0, 1, null); } catch { eof = true; break; }
|
|
82
|
+
if (n === 0) { eof = true; break; }
|
|
83
|
+
if (buf[0] === 0x0a) break;
|
|
84
|
+
if (buf[0] !== 0x0d) bytes.push(buf[0]);
|
|
85
|
+
}
|
|
86
|
+
// NULL ONLY FOR THE EMPTY EOF, which is the one case `input()` raises on. A partial line
|
|
87
|
+
// with no trailing newline is not EOF to Python — it returns the characters — and the
|
|
88
|
+
// `bytes.length` test is what keeps that arm returning a string here too.
|
|
89
|
+
if (eof && bytes.length === 0) return null;
|
|
90
|
+
return Buffer.from(bytes).toString('utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `_harness_setup._confirm` — `_ask` with a Y/n suffix; the default on an empty answer. */
|
|
94
|
+
export function confirm(prompt, dflt = true) {
|
|
95
|
+
const ans = ask(`${prompt} (${dflt ? 'Y/n' : 'y/N'})`).toLowerCase();
|
|
96
|
+
return ans === '' ? dflt : ans[0] === 'y';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `_harness_setup._ask_choice` — a numbered menu; the chosen KEY, or the default.
|
|
101
|
+
*
|
|
102
|
+
* THREE THINGS ABOUT THE FALLBACK ORDER, all of them observable and none of them obvious:
|
|
103
|
+
*
|
|
104
|
+
* * The key match lives in Python's `except ValueError`, so an answer that PARSES as an
|
|
105
|
+
* integer never reaches it. `99` against a five-option menu returns the default; it does
|
|
106
|
+
* not go looking for an option literally named `99`.
|
|
107
|
+
* * An in-range index wins, a parsed out-of-range index falls to the default, and only an
|
|
108
|
+
* UNPARSEABLE answer is matched against the keys. `parseIntStrict` is what separates the second
|
|
109
|
+
* case from the third, and it is not `Number()` — see its own docblock.
|
|
110
|
+
* * A default that is NOT in `options` falls back to option 1. It is reachable — a deployed
|
|
111
|
+
* `.geneseed-theme` can name a theme this checkout no longer ships — and it used to throw,
|
|
112
|
+
* mirroring the Python reference's `StopIteration`, which crashed `geneseed setup` before
|
|
113
|
+
* its first question. The reference is gone, so there is nothing left to fail in step
|
|
114
|
+
* with, and returning a key that is not on the menu would hand the build a stale value.
|
|
115
|
+
*/
|
|
116
|
+
export function askChoice(prompt, options, dflt) {
|
|
117
|
+
if (!options.some(([k]) => k === dflt)) dflt = options[0][0];
|
|
118
|
+
printOut(`\n${prompt}:\n`);
|
|
119
|
+
options.forEach(([key, desc], i) => {
|
|
120
|
+
const label = desc ? `${key} — ${desc}` : key;
|
|
121
|
+
printOut(` ${i + 1}) ${label}${key === dflt ? ' (default)' : ''}\n`);
|
|
122
|
+
});
|
|
123
|
+
const defaultIdx = options.findIndex(([k]) => k === dflt);
|
|
124
|
+
const raw = ask('Choose', String(defaultIdx + 1));
|
|
125
|
+
const idx = parseIntStrict(raw);
|
|
126
|
+
if (idx !== null) {
|
|
127
|
+
if (idx >= 1 && idx <= options.length) return options[idx - 1][0];
|
|
128
|
+
} else {
|
|
129
|
+
for (const [key] of options) if (raw === key) return key;
|
|
130
|
+
}
|
|
131
|
+
return dflt;
|
|
132
|
+
}
|
package/js/lib/text.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* set than `String.trim`'s, `parseIntStrict` accepts what `Number()` does not and rejects what
|
|
10
10
|
* it does, and `percentDecode` leaves a bad escape alone where `decodeURIComponent` throws.
|
|
11
11
|
*/
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* `len(s)` — CODE POINTS, where `String.length` counts UTF-16 units.
|
|
14
16
|
*
|
|
@@ -50,8 +52,10 @@ export const padEndToWidth = (s, width) => s + ' '.repeat(Math.max(0, width - co
|
|
|
50
52
|
* raw environment value do it — `js/ui/cli.mjs`'s `helpWidth` and `js/web/activity.mjs` wrap
|
|
51
53
|
* the argument in `stripWhitespace` first.
|
|
52
54
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
+
* Its own corpus retired with the Python suite — a primitive reproduction gets one owner and
|
|
56
|
+
* a corpus, never a cell (P5c) — so what reaches it now is only its callers' gates:
|
|
57
|
+
* `tests/unit/wizard.test.mjs`'s choice corpus and `tests/unit/web_daemon.test.mjs`'s
|
|
58
|
+
* non-integer port.
|
|
55
59
|
*/
|
|
56
60
|
export function parseIntStrict(s) {
|
|
57
61
|
if (typeof s !== 'string' || s === '') return null;
|
|
@@ -146,8 +150,8 @@ export function stripWhitespaceEnd(s) {
|
|
|
146
150
|
* `Buffer.toString('utf-8')` substitutes U+FFFD for an undecodable sequence, which is
|
|
147
151
|
* `errors='replace'`. The two decoders can disagree on HOW MANY replacement characters a
|
|
148
152
|
* truncated multi-byte sequence yields — Python's maximal-subpart rule against WHATWG's —
|
|
149
|
-
* and the
|
|
150
|
-
*
|
|
153
|
+
* and the former Python corpus is where that was measured rather than assumed; nothing
|
|
154
|
+
* measures it now.
|
|
151
155
|
*/
|
|
152
156
|
const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
|
|
153
157
|
|
|
@@ -179,3 +183,13 @@ export function percentDecode(s) {
|
|
|
179
183
|
return res;
|
|
180
184
|
}
|
|
181
185
|
|
|
186
|
+
/**
|
|
187
|
+
* `Path.relative_to(base).as_posix()` — `target` relative to `base`, always `/`-separated.
|
|
188
|
+
*
|
|
189
|
+
* Here, not in `js/build/emit-common.mjs`, because the hook path (`js/hosts/hooks-context.mjs`)
|
|
190
|
+
* needs it too and must not pay for the render module to get it; this module imports nothing
|
|
191
|
+
* but `node:path`. It had been defined four times.
|
|
192
|
+
*/
|
|
193
|
+
export function relPosix(base, target) {
|
|
194
|
+
return path.relative(base, target).split(path.sep).join('/');
|
|
195
|
+
}
|
package/js/lib/udiff.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* WHY A HAND-WRITTEN MATCHER RATHER THAN A LIBRARY. The diff is the ENTIRE user-visible
|
|
5
5
|
* payload of `geneseed diff`: the hunks in `--full` and in the exported improvements file
|
|
6
6
|
* are this file's output and nothing else's. A generic JS diff would produce a CORRECT diff
|
|
7
|
-
* and a DIFFERENT one, and
|
|
8
|
-
*
|
|
7
|
+
* and a DIFFERENT one, and the former Python suite's corpus compared the hunks character for
|
|
8
|
+
* character, so "also a valid diff" failed every case (today only the `diff/--full-…` CLI cell
|
|
9
|
+
* compares hunks, and only one; see `js/inspect/diff.mjs`). The alignment below IS the
|
|
9
10
|
* specification of what this tool prints, and three of its choices are choices no other
|
|
10
11
|
* algorithm makes:
|
|
11
12
|
*
|
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.
|
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
|
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.
|
|
@@ -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`
|
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
import path from 'node:path';
|
|
49
49
|
|
|
50
50
|
import { hookRunnerEntry } from '../hosts/shim.mjs';
|
|
51
|
-
import { confirm } from '
|
|
51
|
+
import { confirm } from '../lib/prompt.mjs';
|
|
52
52
|
import {
|
|
53
53
|
claudeCfg, claudeReadManifest, doctrinesOfDir, emitHostScopeOf, excludedRulesOfDir, installKind,
|
|
54
54
|
installState,
|
|
@@ -374,7 +374,7 @@ export function installAgentEntry(root, kind) {
|
|
|
374
374
|
* A cell can only ever observe the entries a seeded `opencode.json` holds, and the shape
|
|
375
375
|
* that separates the two `is_absolute` rules is a Windows ROOTLESS path — which a cell CAN
|
|
376
376
|
* seed but whose answer then only differs on one platform, in one branch, with no other
|
|
377
|
-
* observable effect.
|
|
377
|
+
* observable effect. The former Python corpus drove the list directly; nothing does now.
|
|
378
378
|
*/
|
|
379
379
|
export function installAgentEntryOf(instr) {
|
|
380
380
|
if (Array.isArray(instr)) {
|
|
@@ -732,9 +732,10 @@ export function cmdUninstall(args) {
|
|
|
732
732
|
// ALL-OR-NOTHING IS THE PROPERTY, and it is the one a port breaks silently. A move that fails
|
|
733
733
|
// puts every earlier move back and reports, leaving the install fully `active`; a port that
|
|
734
734
|
// merely stopped on the failure would leave a half-gutted config dir that neither state
|
|
735
|
-
// answers for. `tests/
|
|
736
|
-
// drives it
|
|
737
|
-
//
|
|
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.
|
|
738
739
|
|
|
739
740
|
/**
|
|
740
741
|
* `_harness_mcp._move_tree` — move a file or dir, refusing an existing destination.
|
|
@@ -926,7 +927,7 @@ function restoreAll(stash, base, skip = null) {
|
|
|
926
927
|
* The config edit is the LAST step and the only non-move mutation, so a move failure rolls
|
|
927
928
|
* back cleanly with the `instructions` entry still intact. The prune climbs from each moved
|
|
928
929
|
* file's PARENT so a `skills/<name>/` husk goes too — the half of this function no file
|
|
929
|
-
* snapshot can see, and the reason
|
|
930
|
+
* snapshot can see, and the reason the former web matrix grew a `<dirs>` column in P6i.
|
|
930
931
|
*/
|
|
931
932
|
export function installDeactivate(root, host = 'opencode', scope = 'global') {
|
|
932
933
|
if (CLAUDE_STYLE.includes(host)) return claudeDeactivate(root, scope, host);
|