@codedology/code-forge 0.2.0 → 0.2.2
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 +32 -0
- package/README.md +12 -5
- package/docs/reference/blocks.json +55 -3
- package/package.json +1 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/keys.mjs +39 -9
- package/src/cli/tools.mjs +221 -0
- package/src/doctor/index.mjs +10 -1
- package/src/doctor/local.mjs +29 -1
- package/src/install/tools.mjs +163 -0
- package/src/install/wizard/answers.mjs +102 -46
- package/src/install/wizard/flags.mjs +7 -5
- package/src/install/wizard/run.mjs +94 -31
- package/src/install/wizard/steps.mjs +247 -61
- package/src/install/wizard/summary.mjs +168 -0
- package/src/keys/onepassword.mjs +258 -15
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2] — 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 1Password item ID or item link accepted for the Jev key (init question, --jev-ref, keys set --op, keys test --ref):
|
|
14
|
+
code-forge finds the vault and key field and saves a full op:// reference of IDs; 1Password failures (CLI missing,
|
|
15
|
+
locked, timeout, not found, bad output, no key field) get one clear message, a retry/skip menu in init, and never
|
|
16
|
+
write a partial config
|
|
17
|
+
- code-forge tools: lists the recommended tools (claude, codex, gemini, grok, op, solo) as installed with their version
|
|
18
|
+
or missing with the install command; code-forge tools install [<id>...] [--yes] [--dry-run] installs the missing ones
|
|
19
|
+
after one yes. init step 1 and doctor use the same tool table; doctor adds an INFO line naming the missing tools.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Releases: CI no longer publishes to npm. Publish with `npm publish` from your machine, then push the tag; the tag push
|
|
24
|
+
only creates the GitHub release (`.github/workflows/release.yml`).
|
|
25
|
+
|
|
26
|
+
## [0.2.1] — 2026-10-01
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **Two publish paths:** publish by hand with `npm publish` before pushing the tag (the tag run sees
|
|
31
|
+
the version on npm, skips its own publish and still creates the GitHub release), or push only and
|
|
32
|
+
let CI publish with provenance (needs `NPM_TOKEN`). See `docs/releasing.md`.
|
|
33
|
+
- `init` no longer asks for gates or proof settings one by one: it reads them from the project
|
|
34
|
+
(gates from `detect`, `link_dirs` from the project's manifests, `copy_untracked` only for files
|
|
35
|
+
that exist, high-risk paths blank, isolation `export`), prints a "Project settings (detected)" summary that marks every blank
|
|
36
|
+
and names the keys to edit, then asks one choice: Use these, Customize now (the eight questions,
|
|
37
|
+
pre-filled) or Leave for later. `--no-interaction` asks nothing; agent JSON gains `settings` and
|
|
38
|
+
`blank`. Every question has a one-line hint; a re-run fills a gate that was blank and keeps
|
|
39
|
+
hand-set ones.
|
|
40
|
+
|
|
9
41
|
## [0.2.0] — 2026-09-26
|
|
10
42
|
|
|
11
43
|
### Added
|
package/README.md
CHANGED
|
@@ -30,15 +30,21 @@ code-forge init
|
|
|
30
30
|
The scoped name is used only to install; the command is `code-forge`. The unscoped npm package
|
|
31
31
|
`code-forge` is a different, unrelated tool.
|
|
32
32
|
|
|
33
|
-
The wizard asks
|
|
34
|
-
|
|
33
|
+
The wizard asks a few short questions (tools, harnesses, provider matrix, multimodel, keys,
|
|
34
|
+
engine), every one with a default on Enter. Gates and proof settings come from the project:
|
|
35
|
+
`init` prints what it found, marks what it left blank, and lets you use, customize or leave them
|
|
36
|
+
for later. Non-interactively:
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
39
|
code-forge init --no-interaction --no-jev
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
`--no-jev` skips the System-1 key for a first try; drop it and pass `--jev-ref
|
|
41
|
-
or
|
|
42
|
+
`--no-jev` skips the System-1 key for a first try; drop it and pass `--jev-ref <1Password item ID>`
|
|
43
|
+
(or the item link, or `op://vault/item/field`; code-forge finds the vault and the key field) or
|
|
44
|
+
`--jev-env MY_JEV_KEY` once you have one. A second run of `init` changes nothing it already wrote.
|
|
45
|
+
|
|
46
|
+
To upgrade: `npm install -g @codedology/code-forge@latest`, then `code-forge upgrade` (re-copies
|
|
47
|
+
skill installs made with `--copy`). See [docs/getting-started.md](docs/getting-started.md#keeping-it-up-to-date).
|
|
42
48
|
|
|
43
49
|
## Quickstart
|
|
44
50
|
|
|
@@ -77,8 +83,9 @@ usage line.
|
|
|
77
83
|
|
|
78
84
|
| Verb | Usage |
|
|
79
85
|
|---|---|
|
|
80
|
-
| `init` | `code-forge init [--no-interaction] [--tools recommended\|current] [--yes-tool <tool>]… [--harness a,b] [-g\|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]… [--refresh-models] [--multimodel on\|off] [--second-provider P] [--jev-ref op
|
|
86
|
+
| `init` | `code-forge init [--no-interaction] [--tools recommended\|current] [--yes-tool <tool>]… [--harness a,b] [-g\|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]… [--refresh-models] [--multimodel on\|off] [--second-provider P] [--jev-ref <item-id\|link\|op://…> \| --jev-env NAME \| --no-jev] [--engine auto\|solo\|harness] [--solo-project N] [--gate name=cmd]… [--proof isolation=export\|lock \| high=a,b \| link_dirs=a,b \| copy_untracked=a,b]… [--skip-doctor]` |
|
|
81
87
|
| `doctor` | `code-forge doctor [--quick] [--json] [--cwd <dir>]` |
|
|
88
|
+
| `tools` | `code-forge tools [--json]` · `code-forge tools install [<id>…] [--yes] [--dry-run]` — see and install the recommended tools (claude, codex, gemini, grok, op, solo) |
|
|
82
89
|
| `validate` | `code-forge validate [--file <path>]` |
|
|
83
90
|
| `resolve` | `code-forge resolve <L0\|L1\|L2\|L3>` |
|
|
84
91
|
| `run` | `code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]` · `run start --reattach --run <id>` · `run status --run <id>` · `run end --run <id>` |
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "hand-maintained; checked against plan §10.4 + NIGHT-LOG by test/docs-blocks.test.mjs. Ids = every `| **Bxx** |` row in plans/code-forge-plan-v1.3.md §10.4 Waves 2-6 (19 ids), plus the blocks the root orchestrator added during the night run of 2026-09-25 that are not in that table (plans/NIGHT-LOG.md): B1.2, B4.1, B11.1, B12c (B3.1 was already in the v1.3 §10.4 table); plus B22 (the review-only verb), which the root added on 2026-09-26 after the night run. Wave 0-1 blocks (B0-B8) are the pre-v1.3 baseline landed before this plan revision; they have no entry under blocks and are listed in baseline_ids so every depends_on id resolves; see plans/code-forge-plan-v1.3.md §10.2-§10.3 for their record. B10c is listed with deleted:true — the maintainer answered Q16 'cut' on 2026-09-25, so B10c (consumer-facing mutation-testing tools) never lands.",
|
|
2
|
+
"$comment": "hand-maintained; checked against plan §10.4 + NIGHT-LOG by test/docs-blocks.test.mjs. Ids = every `| **Bxx** |` row in plans/code-forge-plan-v1.3.md §10.4 Waves 2-6 (19 ids), plus the blocks the root orchestrator added during the night run of 2026-09-25 that are not in that table (plans/NIGHT-LOG.md): B1.2, B4.1, B11.1, B12c (B3.1 was already in the v1.3 §10.4 table); plus B22 (the review-only verb) and B23 (release tools), which the root added on 2026-09-26 after the night run, and B24 (init project settings) and B25 (1Password item IDs), added on 2026-10-01. Wave 0-1 blocks (B0-B8) are the pre-v1.3 baseline landed before this plan revision; they have no entry under blocks and are listed in baseline_ids so every depends_on id resolves; see plans/code-forge-plan-v1.3.md §10.2-§10.3 for their record. B10c is listed with deleted:true — the maintainer answered Q16 'cut' on 2026-09-25, so B10c (consumer-facing mutation-testing tools) never lands.",
|
|
3
3
|
"checked_against": [
|
|
4
4
|
"plans/code-forge-plan-v1.3.md §10.4",
|
|
5
5
|
"plans/NIGHT-LOG.md"
|
|
6
6
|
],
|
|
7
|
-
"block_count":
|
|
7
|
+
"block_count": 28,
|
|
8
8
|
"baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
|
|
9
9
|
"baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
|
|
10
10
|
"blocks": {
|
|
@@ -288,12 +288,64 @@
|
|
|
288
288
|
"title": "Review-only verb: code-forge review (base/file discovery, run start -> block open -> review-file -> block stop -> run end) and its docs",
|
|
289
289
|
"wave": 6,
|
|
290
290
|
"level": "L2",
|
|
291
|
-
"status": "
|
|
291
|
+
"status": "landed",
|
|
292
|
+
"commit": "1e9ea32",
|
|
292
293
|
"owned": [
|
|
293
294
|
"src/cli/review.mjs", "test/cli/review.test.mjs", "docs/review-only.md"
|
|
294
295
|
],
|
|
295
296
|
"depends_on": ["B11", "B12a", "B12c"],
|
|
296
297
|
"note": "root-added 2026-09-26 (after the night run); not in the v1.3 §10.4 table"
|
|
298
|
+
},
|
|
299
|
+
"B23": {
|
|
300
|
+
"title": "Release and changelog tools: npm run release (bump, CHANGELOG, checks, commit, tag), npm run changelog, GitHub Releases linked to the npm publish; both publish paths",
|
|
301
|
+
"wave": 6,
|
|
302
|
+
"level": "L2",
|
|
303
|
+
"status": "landed",
|
|
304
|
+
"commit": "805dca8",
|
|
305
|
+
"owned": [
|
|
306
|
+
"scripts/release.mjs", "scripts/changelog.mjs", ".github/workflows/release.yml",
|
|
307
|
+
"test/release.test.mjs", "docs/releasing.md"
|
|
308
|
+
],
|
|
309
|
+
"depends_on": ["B16"],
|
|
310
|
+
"note": "root-added 2026-09-26; not in the v1.3 §10.4 table. Both publish paths (by hand or CI) added in d61623f; on 2026-10-01 CI publishing was removed (publish.yml became release.yml: GitHub release only, npm publish by hand)"
|
|
311
|
+
},
|
|
312
|
+
"B24": {
|
|
313
|
+
"title": "init project settings: detect gates and proof settings, print a summary that marks every blank, one choice (Use these / Customize now / Leave for later)",
|
|
314
|
+
"wave": 6,
|
|
315
|
+
"level": "L2",
|
|
316
|
+
"status": "landed",
|
|
317
|
+
"commit": "fa915c7",
|
|
318
|
+
"owned": [
|
|
319
|
+
"src/install/wizard/answers.mjs", "src/install/wizard/run.mjs", "src/install/wizard/steps.mjs",
|
|
320
|
+
"src/install/wizard/summary.mjs", "test/init/settings.test.mjs", "docs/getting-started.md"
|
|
321
|
+
],
|
|
322
|
+
"depends_on": ["B13a"],
|
|
323
|
+
"note": "root-added 2026-10-01; not in the v1.3 §10.4 table. Released in 0.2.1"
|
|
324
|
+
},
|
|
325
|
+
"B25": {
|
|
326
|
+
"title": "1Password item ID or item link for the Jev key: resolve vault and key field with op item get, save an op:// reference of IDs; classified 1Password failures with a retry/skip menu",
|
|
327
|
+
"wave": 6,
|
|
328
|
+
"level": "L2",
|
|
329
|
+
"status": "landed",
|
|
330
|
+
"owned": [
|
|
331
|
+
"src/keys/onepassword.mjs", "src/cli/keys.mjs", "src/doctor/local.mjs",
|
|
332
|
+
"test/keys/onepassword.test.mjs", "test/init/onepassword.test.mjs"
|
|
333
|
+
],
|
|
334
|
+
"depends_on": ["B24"],
|
|
335
|
+
"note": "root-added 2026-10-01; not in the v1.3 §10.4 table"
|
|
336
|
+
},
|
|
337
|
+
"B26": {
|
|
338
|
+
"title": "code-forge tools: see and install the recommended tools from one shared table (status, versions, install plan with one yes); init step 1 and doctor use the same table",
|
|
339
|
+
"wave": 6,
|
|
340
|
+
"level": "L2",
|
|
341
|
+
"status": "landed",
|
|
342
|
+
"owned": [
|
|
343
|
+
"src/install/tools.mjs", "src/cli/tools.mjs", "src/install/wizard/run.mjs", "src/install/wizard/flags.mjs",
|
|
344
|
+
"src/doctor/local.mjs", "src/doctor/index.mjs",
|
|
345
|
+
"test/cli/tools.test.mjs", "test/init/tools.test.mjs"
|
|
346
|
+
],
|
|
347
|
+
"depends_on": ["B13a"],
|
|
348
|
+
"note": "root-added 2026-10-01; not in the v1.3 §10.4 table"
|
|
297
349
|
}
|
|
298
350
|
}
|
|
299
351
|
}
|
package/package.json
CHANGED
package/skill/scripts/forge
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
|
|
11
11
|
# equals package.json's version.
|
|
12
12
|
# The shim holds no logic of its own: every verb, flag and message is the CLI's.
|
|
13
|
-
PINNED_VERSION="0.2.
|
|
13
|
+
PINNED_VERSION="0.2.2"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
package/src/cli/keys.mjs
CHANGED
|
@@ -5,18 +5,21 @@
|
|
|
5
5
|
* message would be masked once the chain registered it.
|
|
6
6
|
*
|
|
7
7
|
* keys list
|
|
8
|
-
* keys set <name> [--op <
|
|
8
|
+
* keys set <name> [--op <ref>] hidden prompt, or read once from 1Password and cache 8 h
|
|
9
9
|
* keys test <name> [--ref <ref>] resolve through the chain (no prompt)
|
|
10
10
|
* keys remove <name>
|
|
11
11
|
*
|
|
12
|
+
* An `--op`/`--ref` value may be a 1Password item ID or item link instead of `op://vault/item/field`:
|
|
13
|
+
* it is resolved to `op://<vaultId>/<itemId>/<fieldId>` first (`op item get`, B25).
|
|
14
|
+
*
|
|
12
15
|
* Every name is validated before any store call, on every subcommand.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
import { writeSafe } from '../util/redact.mjs';
|
|
16
|
-
import { isOpRef } from '../keys/onepassword.mjs';
|
|
19
|
+
import { describeOpItem, isOpRef, OP_MESSAGES, opItemIdFromInput, toOpRef } from '../keys/onepassword.mjs';
|
|
17
20
|
import { assertKeyName, createDefaultKeyStore, parseRef, readOpAndCache, resolveKey } from '../keys/store.mjs';
|
|
18
21
|
|
|
19
|
-
const USAGE = 'usage: code-forge keys list | set <name> [--op <ref>] | test <name> [--ref <ref>] | remove <name>\n';
|
|
22
|
+
const USAGE = 'usage: code-forge keys list | set <name> [--op <item-id|link|op://ref>] | test <name> [--ref <ref>] | remove <name>\n';
|
|
20
23
|
|
|
21
24
|
/** The one flag each subcommand accepts (list and remove take none). */
|
|
22
25
|
const FLAGS = /** @type {Record<string, string|null>} */ ({ list: null, set: '--op', test: '--ref', remove: null });
|
|
@@ -76,11 +79,12 @@ async function askHidden(name) {
|
|
|
76
79
|
* @param {{write: (s: string) => unknown}} [deps.stderr]
|
|
77
80
|
* @param {(name: string) => Promise<string|null>} [deps.ask]
|
|
78
81
|
* @param {typeof import('../keys/onepassword.mjs').opRead} [deps.opRead]
|
|
82
|
+
* @param {typeof import('../util/exec.mjs').exec} [deps.opExec] - runs `op item get` for an item ID or link.
|
|
79
83
|
* @param {NodeJS.ProcessEnv} [deps.env]
|
|
80
84
|
* @param {number} [deps.now]
|
|
81
85
|
* @returns {Promise<number>}
|
|
82
86
|
*/
|
|
83
|
-
export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, env = process.env, now = Date.now() }) {
|
|
87
|
+
export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, opExec, env = process.env, now = Date.now() }) {
|
|
84
88
|
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
85
89
|
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
86
90
|
|
|
@@ -89,11 +93,41 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
|
|
|
89
93
|
err(USAGE);
|
|
90
94
|
return 2;
|
|
91
95
|
}
|
|
92
|
-
const { sub, name
|
|
96
|
+
const { sub, name } = parsed;
|
|
97
|
+
let { flagArg } = parsed;
|
|
93
98
|
try {
|
|
94
99
|
if (name !== undefined) {
|
|
95
100
|
assertKeyName(name);
|
|
96
101
|
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
err(`keys: ${e.message}\n`);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
const fullRef = flagArg !== undefined && isOpRef(flagArg);
|
|
107
|
+
const itemId = flagArg !== undefined && !fullRef ? opItemIdFromInput(flagArg) : null;
|
|
108
|
+
if (sub === 'set' && flagArg !== undefined && !fullRef && itemId === null) {
|
|
109
|
+
err(`${name}: --op must be a 1Password item ID, item link or op://vault/item/field reference\n`);
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
// A 1Password item ID or link becomes a full op:// reference before any store call (B25).
|
|
113
|
+
if (flagArg !== undefined && itemId !== null) {
|
|
114
|
+
/** @type {Awaited<ReturnType<typeof toOpRef>>} */
|
|
115
|
+
let res;
|
|
116
|
+
try {
|
|
117
|
+
res = await toOpRef(flagArg, opExec ? { exec: opExec } : {});
|
|
118
|
+
} catch {
|
|
119
|
+
// never the thrown message: it could carry op output
|
|
120
|
+
res = { ref: null, kind: 'op_failed', error: `${OP_MESSAGES.op_failed} (unexpected error); run \`op item get ${itemId}\` yourself to see why` };
|
|
121
|
+
}
|
|
122
|
+
if (res.ref === null) {
|
|
123
|
+
err(`${name}: ${res.error ?? OP_MESSAGES.op_failed}\n`);
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
const found = describeOpItem(res);
|
|
127
|
+
out(`${name}: ${found ? `${found} -> ` : ''}${res.ref}\n`);
|
|
128
|
+
flagArg = res.ref;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
97
131
|
if (sub === 'test' && flagArg !== undefined) {
|
|
98
132
|
parseRef(name, flagArg);
|
|
99
133
|
}
|
|
@@ -101,10 +135,6 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
|
|
|
101
135
|
err(`keys: ${e.message}\n`);
|
|
102
136
|
return 2;
|
|
103
137
|
}
|
|
104
|
-
if (sub === 'set' && flagArg !== undefined && !isOpRef(flagArg)) {
|
|
105
|
-
err(`${name}: --op must be an op://vault/item/field reference\n`);
|
|
106
|
-
return 2;
|
|
107
|
-
}
|
|
108
138
|
|
|
109
139
|
if (store.warning) {
|
|
110
140
|
err(`WARN ${store.warning}\n`);
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge tools` (block B26): see and install the recommended tools of the shared table
|
|
3
|
+
* (`src/install/tools.mjs`).
|
|
4
|
+
*
|
|
5
|
+
* tools [--json] one line per tool; exit 0 always
|
|
6
|
+
* tools install [<id>...] [--yes] [--dry-run] install the missing (or the named) tools
|
|
7
|
+
*
|
|
8
|
+
* `install` shows the plan (each command as argv, joined for display only), asks ONE yes (default
|
|
9
|
+
* no) unless `--yes`; without a terminal and without `--yes` it prints the plan, installs nothing
|
|
10
|
+
* and exits 2. Each install runs through B0 `exec` (argv, never a shell); a failure does not stop
|
|
11
|
+
* the others; presence is re-checked afterwards. The final table lists the tools acted on (and,
|
|
12
|
+
* with named ids, "already installed"); a failure says why: `failed (exit n | signal S | timed out
|
|
13
|
+
* | did not start)`. Exit 0 when every requested install succeeded, 1 when any failed or the yes
|
|
14
|
+
* was refused ("cancelled — nothing installed", exit 1, as in init), 2 on a usage error. Manual tools are never "failed"
|
|
15
|
+
* and no browser is ever opened.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { exec as realExec } from '../util/exec.mjs';
|
|
19
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
20
|
+
import { detectTool, failureReason, installPlan, TOOL_IDS, TOOLS, toolById, toolVersion } from '../install/tools.mjs';
|
|
21
|
+
import { parseFlags } from '../state/cli-args.mjs';
|
|
22
|
+
|
|
23
|
+
const USAGE = 'usage: code-forge tools [--json] | tools install [<id>...] [--yes] [--dry-run]\n (install asks one yes, default no; answering no prints "cancelled — nothing installed" and exits 1)\n';
|
|
24
|
+
|
|
25
|
+
/** An install may take a while (npm, brew); a hung one is killed after this. */
|
|
26
|
+
const INSTALL_TIMEOUT_MS = 600_000;
|
|
27
|
+
|
|
28
|
+
/** @typedef {{write: (s: string) => unknown}} Out */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} ToolsDeps
|
|
32
|
+
* @property {NodeJS.ProcessEnv} [env]
|
|
33
|
+
* @property {Out} [stdout]
|
|
34
|
+
* @property {Out} [stderr]
|
|
35
|
+
* @property {boolean} [isTTY] - whether stdin is a terminal (default `process.stdin.isTTY`).
|
|
36
|
+
* @property {{confirm: (o: {message: string, initialValue?: boolean}) => Promise<any>, isCancel: (v: unknown) => boolean}} [ui] - default `@clack/prompts`.
|
|
37
|
+
* @property {typeof realExec} [exec] - runs `--version` and the installs (tests: a fake).
|
|
38
|
+
* @property {NodeJS.Platform} [platform]
|
|
39
|
+
* @property {(p: string) => boolean} [exists] - the Solo app bundle check (tests: a fake).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {object} ToolStatus
|
|
44
|
+
* @property {string} id
|
|
45
|
+
* @property {'installed'|'missing'} status
|
|
46
|
+
* @property {string|null} version
|
|
47
|
+
* @property {string[]|null} install - the argv that would install it (missing only).
|
|
48
|
+
* @property {string|null} hint - the manual hint (missing only, when there is no argv).
|
|
49
|
+
* @property {string|null} note
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** @param {ToolsDeps} deps */
|
|
53
|
+
function toolEnv(deps) {
|
|
54
|
+
const env = deps.env ?? process.env;
|
|
55
|
+
return { pathEnv: env.PATH ?? '', platform: deps.platform ?? process.platform, home: env.HOME ?? '', ...(deps.exists ? { exists: deps.exists } : {}) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {ToolsDeps} deps
|
|
60
|
+
* @returns {Promise<ToolStatus[]>}
|
|
61
|
+
*/
|
|
62
|
+
export async function toolStatuses(deps = {}) {
|
|
63
|
+
const opts = toolEnv(deps);
|
|
64
|
+
const exec = deps.exec ?? realExec;
|
|
65
|
+
const env = deps.env ?? process.env;
|
|
66
|
+
/** @type {ToolStatus[]} */
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const tool of TOOLS) {
|
|
69
|
+
if (await detectTool(tool, opts)) {
|
|
70
|
+
out.push({ id: tool.id, status: 'installed', version: await toolVersion(tool, { exec, env }), install: null, hint: null, note: tool.note });
|
|
71
|
+
} else {
|
|
72
|
+
const plan = await installPlan(tool, opts);
|
|
73
|
+
out.push({ id: tool.id, status: 'missing', version: null, install: plan.kind === 'run' ? plan.argv : null, hint: plan.kind === 'manual' ? plan.hint : null, note: null });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {ToolStatus} s @returns {string} one line, no newline */
|
|
80
|
+
export function renderStatus(s) {
|
|
81
|
+
if (s.status === 'installed') return `${s.id.padEnd(7)} installed ${s.version ?? 'version unknown'}${s.note ? ` — ${s.note}` : ''}`;
|
|
82
|
+
return `${s.id.padEnd(7)} missing ${s.install ? `install: ${s.install.join(' ')}` : `manual: ${s.hint}`}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string[]} args
|
|
87
|
+
* @param {ToolsDeps} [deps]
|
|
88
|
+
* @returns {Promise<number>}
|
|
89
|
+
*/
|
|
90
|
+
export async function runTools(args, deps = {}) {
|
|
91
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
92
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
93
|
+
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
94
|
+
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
95
|
+
|
|
96
|
+
if (args[0] === 'install') return runInstall(args.slice(1), deps, out, err);
|
|
97
|
+
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = parseFlags(args, { booleans: ['json'] });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
err(`tools: ${e.message}\n${USAGE}`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
if (parsed.positionals.length > 0) {
|
|
106
|
+
err(`tools: unknown subcommand ${JSON.stringify(parsed.positionals[0])}\n${USAGE}`);
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
const statuses = await toolStatuses(deps);
|
|
110
|
+
if (parsed.flags.json) {
|
|
111
|
+
out(`${JSON.stringify({ tools: statuses })}\n`);
|
|
112
|
+
} else {
|
|
113
|
+
for (const s of statuses) out(`${renderStatus(s)}\n`);
|
|
114
|
+
}
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string[]} args @param {ToolsDeps} deps
|
|
120
|
+
* @param {(s: string) => void} out @param {(s: string) => void} err
|
|
121
|
+
* @returns {Promise<number>}
|
|
122
|
+
*/
|
|
123
|
+
async function runInstall(args, deps, out, err) {
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = parseFlags(args, { booleans: ['yes', 'dry-run'] });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
err(`tools: ${e.message}\n${USAGE}`);
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
const ids = [...new Set(parsed.positionals)];
|
|
132
|
+
const unknown = ids.filter((id) => !TOOL_IDS.includes(id));
|
|
133
|
+
if (unknown.length > 0) {
|
|
134
|
+
err(`tools: unknown tool id(s): ${unknown.join(', ')} — valid ids: ${TOOL_IDS.join(', ')}\n${USAGE}`);
|
|
135
|
+
return 2;
|
|
136
|
+
}
|
|
137
|
+
const opts = toolEnv(deps);
|
|
138
|
+
const env = deps.env ?? process.env;
|
|
139
|
+
const exec = deps.exec ?? realExec;
|
|
140
|
+
const named = ids.length > 0;
|
|
141
|
+
const wanted = named ? TOOLS.filter((t) => ids.includes(t.id)) : TOOLS;
|
|
142
|
+
|
|
143
|
+
/** @type {Array<{id: string, result: string}>} */
|
|
144
|
+
const table = [];
|
|
145
|
+
/** @type {Array<{id: string, argv: string[]}>} */
|
|
146
|
+
const runs = [];
|
|
147
|
+
/** @type {Array<{id: string, hint: string}>} */
|
|
148
|
+
const manual = [];
|
|
149
|
+
for (const tool of wanted) {
|
|
150
|
+
if (await detectTool(tool, opts)) {
|
|
151
|
+
if (named) table.push({ id: tool.id, result: 'already installed' });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const plan = await installPlan(tool, opts);
|
|
155
|
+
if (plan.kind === 'run') runs.push({ id: tool.id, argv: plan.argv });
|
|
156
|
+
else manual.push({ id: tool.id, hint: plan.hint });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (runs.length === 0 && manual.length === 0) {
|
|
160
|
+
out(named ? '' : 'all recommended tools are installed\n');
|
|
161
|
+
printTable(out, table);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
out('plan:\n');
|
|
166
|
+
for (const r of runs) out(` ${r.id}: ${r.argv.join(' ')}\n`);
|
|
167
|
+
for (const m of manual) out(` ${m.id}: manual — ${m.hint}\n`);
|
|
168
|
+
|
|
169
|
+
if (parsed.flags['dry-run']) {
|
|
170
|
+
out('dry run: nothing installed\n');
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
if (runs.length > 0 && !parsed.flags.yes) {
|
|
174
|
+
if (!(deps.isTTY ?? process.stdin.isTTY === true)) {
|
|
175
|
+
err('tools: no terminal to confirm; nothing installed — pass --yes to install the plan above\n');
|
|
176
|
+
return 2;
|
|
177
|
+
}
|
|
178
|
+
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
179
|
+
const go = await ui.confirm({ message: `Run ${runs.length} install command(s)?`, initialValue: false });
|
|
180
|
+
if (ui.isCancel(go) || go !== true) {
|
|
181
|
+
out('cancelled — nothing installed\n');
|
|
182
|
+
return 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let failed = 0;
|
|
187
|
+
for (const r of runs) {
|
|
188
|
+
out(`\n$ ${r.argv.join(' ')}\n`);
|
|
189
|
+
let res;
|
|
190
|
+
try {
|
|
191
|
+
res = await exec(r.argv, { timeoutMs: INSTALL_TIMEOUT_MS, env });
|
|
192
|
+
} catch (e) {
|
|
193
|
+
// a spawn that throws reads "did not start"; its message is never printed
|
|
194
|
+
res = { result: 'failed', code: null, signal: null, stdout: '', stderr: '', timedOut: false };
|
|
195
|
+
}
|
|
196
|
+
if (res.stdout) out(res.stdout.endsWith('\n') ? res.stdout : `${res.stdout}\n`);
|
|
197
|
+
if (res.stderr) err(res.stderr.endsWith('\n') ? res.stderr : `${res.stderr}\n`);
|
|
198
|
+
if (res.result === 'ok') {
|
|
199
|
+
const tool = /** @type {NonNullable<ReturnType<typeof toolById>>} */ (toolById(r.id));
|
|
200
|
+
const present = await detectTool(tool, opts);
|
|
201
|
+
table.push({ id: r.id, result: present ? 'installed' : 'installed (not on PATH yet — open a new shell)' });
|
|
202
|
+
} else {
|
|
203
|
+
failed += 1;
|
|
204
|
+
table.push({ id: r.id, result: `failed (${failureReason(res)})` });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const m of manual) table.push({ id: m.id, result: `skipped: manual — ${m.hint}` });
|
|
208
|
+
out('\n');
|
|
209
|
+
printTable(out, table);
|
|
210
|
+
return failed > 0 ? 1 : 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @param {(s: string) => void} out @param {Array<{id: string, result: string}>} table */
|
|
214
|
+
function printTable(out, table) {
|
|
215
|
+
for (const t of table) out(`${t.id.padEnd(7)} ${t.result}\n`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @param {string[]} args @returns {Promise<number>} */
|
|
219
|
+
export default async function tools(args) {
|
|
220
|
+
return runTools(args);
|
|
221
|
+
}
|
package/src/doctor/index.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
14
14
|
import { newRunId } from '../state/paths.mjs';
|
|
15
15
|
import { runRoot } from '../util/tmp.mjs';
|
|
16
|
-
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkTmp } from './local.mjs';
|
|
16
|
+
import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkTmp, checkTools } from './local.mjs';
|
|
17
17
|
import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
|
|
18
18
|
import { row } from './rows.mjs';
|
|
19
19
|
import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
|
|
@@ -28,6 +28,8 @@ import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
|
|
|
28
28
|
* @property {any} [opRead]
|
|
29
29
|
* @property {typeof import('../decide/jev-client.mjs').askJev} [askJev]
|
|
30
30
|
* @property {string} [canary] - the path-deny canary (tests).
|
|
31
|
+
* @property {NodeJS.Platform} [platform] - the recommended-tools row (default `process.platform`).
|
|
32
|
+
* @property {(p: string) => boolean} [toolExists] - the recommended-tools row's Solo app check (tests).
|
|
31
33
|
*/
|
|
32
34
|
|
|
33
35
|
/** @param {Record<string, any> | null} cfg @returns {string} a new, private run root for the probes. */
|
|
@@ -57,6 +59,13 @@ export async function runDoctor(opts = {}, deps = {}) {
|
|
|
57
59
|
rows.push(...(await checkCommands(cfg, env.PATH ?? '')));
|
|
58
60
|
rows.push(...(await checkJev(cfg, keys.key, { askJev: deps.askJev })));
|
|
59
61
|
}
|
|
62
|
+
const toolOpts = {
|
|
63
|
+
pathEnv: env.PATH ?? '',
|
|
64
|
+
platform: deps.platform ?? process.platform,
|
|
65
|
+
home: env.HOME || os.homedir(),
|
|
66
|
+
...(deps.toolExists ? { exists: deps.toolExists } : {}),
|
|
67
|
+
};
|
|
68
|
+
rows.push(...(await checkTools(toolOpts)));
|
|
60
69
|
rows.push(...(await checkLedger()));
|
|
61
70
|
rows.push(...checkTmp(cfg));
|
|
62
71
|
if (!cfg) {
|
package/src/doctor/local.mjs
CHANGED
|
@@ -13,6 +13,8 @@ import { askJev as realAskJev } from '../decide/jev-client.mjs';
|
|
|
13
13
|
import { buildQuestionPayload } from '../decide/questions.mjs';
|
|
14
14
|
import { commandOnPath } from '../install/detect.mjs';
|
|
15
15
|
import { installsPath, readInstalls, targetResolves } from '../install/link.mjs';
|
|
16
|
+
import { missingTools } from '../install/tools.mjs';
|
|
17
|
+
import { OP_MESSAGES } from '../keys/onepassword.mjs';
|
|
16
18
|
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
17
19
|
import { ledgerDir } from '../ledger/paths.mjs';
|
|
18
20
|
import { listEntries } from '../util/reaper.mjs';
|
|
@@ -71,7 +73,16 @@ export async function checkKeys(cfg, deps) {
|
|
|
71
73
|
const store = deps.store ?? (await createDefaultKeyStore(deps.env));
|
|
72
74
|
const ref = cfg?.system1?.key ?? cfg?.keys?.jev;
|
|
73
75
|
const res = await resolveKey('jev', { store, env: deps.env, ref: typeof ref === 'string' ? ref : undefined, ...(deps.opRead ? { opRead: deps.opRead } : {}) });
|
|
74
|
-
if (res.value === null)
|
|
76
|
+
if (res.value === null) {
|
|
77
|
+
// only fixed texts are shown: a 1Password failure by its classified message (B25), any other
|
|
78
|
+
// reason as a fixed "key store error" — never a reference, a path or tool output
|
|
79
|
+
const known = Object.values(OP_MESSAGES);
|
|
80
|
+
const reasons = Array.isArray(res.errors)
|
|
81
|
+
? [...new Set(res.errors.map((e) => known.find((m) => typeof e === 'string' && (e === m || e.startsWith(m))) ?? 'key store error'))]
|
|
82
|
+
: [];
|
|
83
|
+
const why = reasons.length > 0 ? ` (${reasons.join('; ')})` : '';
|
|
84
|
+
return { rows: [row('keys', 'WARN', 'keys', `jev key not resolved${why}; System 1 falls back to rules`)], key: null };
|
|
85
|
+
}
|
|
75
86
|
const exp = res.exp === null ? 'no expiry' : `expires ${new Date(res.exp).toISOString()}`;
|
|
76
87
|
return { rows: [row('keys', 'OK', 'keys', `jev key from ${res.source}, ${exp}`)], key: res.value };
|
|
77
88
|
} catch (err) {
|
|
@@ -101,6 +112,23 @@ export async function checkCommands(cfg, pathEnv) {
|
|
|
101
112
|
return rows;
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The recommended tools of the shared table (B26): one INFO row naming the missing ones, none
|
|
117
|
+
* when all are there. A missing optional tool never WARNs or FAILs.
|
|
118
|
+
* @param {import('../install/tools.mjs').ToolEnv} opts
|
|
119
|
+
* @returns {Promise<Row[]>}
|
|
120
|
+
*/
|
|
121
|
+
export async function checkTools(opts) {
|
|
122
|
+
let missing;
|
|
123
|
+
try {
|
|
124
|
+
missing = await missingTools(opts);
|
|
125
|
+
} catch {
|
|
126
|
+
return [row('tools', 'INFO', 'tools', 'could not check recommended tools')];
|
|
127
|
+
}
|
|
128
|
+
if (missing.length === 0) return [];
|
|
129
|
+
return [row('tools', 'INFO', 'tools', `missing recommended tools: ${missing.join(', ')} — run code-forge tools install`)];
|
|
130
|
+
}
|
|
131
|
+
|
|
104
132
|
/**
|
|
105
133
|
* One live Jev call (a `risk` question on a two-line state). Without a key it is skipped (WARN);
|
|
106
134
|
* a bad key or a malformed answer FAILs; a transient failure WARNs.
|