@codedology/code-forge 0.2.1 → 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 +17 -0
- package/README.md +8 -3
- 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/flags.mjs +7 -5
- package/src/install/wizard/run.mjs +58 -25
- package/src/install/wizard/steps.mjs +118 -32
- package/src/keys/onepassword.mjs +258 -15
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recommended tools (block B26): ONE table shared by `code-forge tools`, `init` step 1 and
|
|
3
|
+
* `doctor`. Each row says how to detect the tool and how to install it on this OS:
|
|
4
|
+
* - an npm package installs with `npm install -g <pkg>` when `npm` is on PATH, else it is manual;
|
|
5
|
+
* - a Homebrew formula/cask installs with `brew install …` on macOS when `brew` is on PATH
|
|
6
|
+
* ("install Homebrew first" when it is not), and is manual (a vendor link) on other systems;
|
|
7
|
+
* - Solo is always manual (a desktop app, then its MCP entry).
|
|
8
|
+
* Detection never spawns a process: `commandOnPath` scans PATH, and Solo's app bundle is a plain
|
|
9
|
+
* existence check. Installs are argv arrays only — the caller runs them through B0 `exec`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { commandOnPath } from './detect.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} ToolRow
|
|
18
|
+
* @property {string} id
|
|
19
|
+
* @property {string} command - the binary looked up on PATH (and asked for `--version`).
|
|
20
|
+
* @property {string|null} npm - the npm package, installed globally.
|
|
21
|
+
* @property {ReadonlyArray<string>|null} brew - the args after `brew install` (macOS only).
|
|
22
|
+
* @property {string|null} manual - the link (or text) when there is no automatic install.
|
|
23
|
+
* @property {string|null} app - a macOS app bundle name that also counts as installed.
|
|
24
|
+
* @property {string|null} note - printed next to the tool when it is installed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @type {ReadonlyArray<Readonly<ToolRow>>} */
|
|
28
|
+
export const TOOLS = Object.freeze([
|
|
29
|
+
Object.freeze({ id: 'claude', command: 'claude', npm: '@anthropic-ai/claude-code', brew: null, manual: null, app: null, note: null }),
|
|
30
|
+
Object.freeze({ id: 'codex', command: 'codex', npm: '@openai/codex', brew: null, manual: null, app: null, note: null }),
|
|
31
|
+
Object.freeze({ id: 'gemini', command: 'gemini', npm: '@google/gemini-cli', brew: null, manual: null, app: null, note: null }),
|
|
32
|
+
Object.freeze({ id: 'grok', command: 'grok', npm: null, brew: Object.freeze(['--cask', 'grok-build']), manual: 'https://x.ai/build', app: null, note: null }),
|
|
33
|
+
Object.freeze({
|
|
34
|
+
id: 'op',
|
|
35
|
+
command: 'op',
|
|
36
|
+
npm: null,
|
|
37
|
+
brew: Object.freeze(['1password-cli']),
|
|
38
|
+
manual: 'https://developer.1password.com/docs/cli/get-started/',
|
|
39
|
+
app: null,
|
|
40
|
+
note: 'turn on 1Password app → Settings → Developer → "Integrate with 1Password CLI"',
|
|
41
|
+
}),
|
|
42
|
+
Object.freeze({
|
|
43
|
+
id: 'solo',
|
|
44
|
+
command: 'solo',
|
|
45
|
+
npm: null,
|
|
46
|
+
brew: null,
|
|
47
|
+
manual: 'https://soloterm.com (desktop app, then add its MCP entry)',
|
|
48
|
+
app: 'Solo.app',
|
|
49
|
+
note: null,
|
|
50
|
+
}),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** Every tool id, in table order. */
|
|
54
|
+
export const TOOL_IDS = Object.freeze(TOOLS.map((t) => t.id));
|
|
55
|
+
|
|
56
|
+
/** The hint when a brew install is due but `brew` is not on PATH. */
|
|
57
|
+
export const NO_BREW_HINT = 'install Homebrew first: https://brew.sh';
|
|
58
|
+
|
|
59
|
+
/** The version timeout (`<cmd> --version`). */
|
|
60
|
+
export const VERSION_TIMEOUT_MS = 10_000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} ToolEnv
|
|
64
|
+
* @property {string} [pathEnv] - a PATH-shaped string (default `process.env.PATH`).
|
|
65
|
+
* @property {NodeJS.Platform} [platform] - default `process.platform`.
|
|
66
|
+
* @property {string} [home] - for `~/Applications` (default `process.env.HOME`).
|
|
67
|
+
* @property {(p: string) => boolean} [exists] - default `existsSync` (tests: a fake).
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** @param {string} id @returns {Readonly<ToolRow>|undefined} */
|
|
71
|
+
export function toolById(id) {
|
|
72
|
+
return TOOLS.find((t) => t.id === id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether a tool is installed: its command on PATH, or (macOS) its app bundle in `/Applications`
|
|
77
|
+
* or `~/Applications`.
|
|
78
|
+
* @param {Readonly<ToolRow>} tool @param {ToolEnv} [opts]
|
|
79
|
+
* @returns {Promise<boolean>}
|
|
80
|
+
*/
|
|
81
|
+
export async function detectTool(tool, opts = {}) {
|
|
82
|
+
const platform = opts.platform ?? process.platform;
|
|
83
|
+
if (await commandOnPath(tool.command, { pathEnv: opts.pathEnv ?? process.env.PATH ?? '', platform })) return true;
|
|
84
|
+
if (tool.app === null || platform !== 'darwin') return false;
|
|
85
|
+
const exists = opts.exists ?? existsSync;
|
|
86
|
+
const home = opts.home ?? process.env.HOME ?? '';
|
|
87
|
+
const places = [path.join('/Applications', tool.app), ...(home ? [path.join(home, 'Applications', tool.app)] : [])];
|
|
88
|
+
return places.some((p) => exists(p));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {{kind: 'run', argv: string[]} | {kind: 'manual', hint: string}} InstallPlan
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How to install a tool on this machine: an argv to run, or a manual hint.
|
|
97
|
+
* @param {Readonly<ToolRow>} tool @param {ToolEnv} [opts]
|
|
98
|
+
* @returns {Promise<InstallPlan>}
|
|
99
|
+
*/
|
|
100
|
+
export async function installPlan(tool, opts = {}) {
|
|
101
|
+
const platform = opts.platform ?? process.platform;
|
|
102
|
+
const onPath = (/** @type {string} */ cmd) => commandOnPath(cmd, { pathEnv: opts.pathEnv ?? process.env.PATH ?? '', platform });
|
|
103
|
+
if (tool.npm !== null) {
|
|
104
|
+
const argv = ['npm', 'install', '-g', tool.npm];
|
|
105
|
+
// Windows: npm is `npm.cmd`, which only a shell runs — never spawned here, the user runs it
|
|
106
|
+
if (platform === 'win32') return { kind: 'manual', hint: `run: ${argv.join(' ')}` };
|
|
107
|
+
return (await onPath('npm')) ? { kind: 'run', argv } : { kind: 'manual', hint: `install Node.js/npm first, then: ${argv.join(' ')}` };
|
|
108
|
+
}
|
|
109
|
+
if (tool.brew !== null && platform === 'darwin') {
|
|
110
|
+
return (await onPath('brew')) ? { kind: 'run', argv: ['brew', 'install', ...tool.brew] } : { kind: 'manual', hint: NO_BREW_HINT };
|
|
111
|
+
}
|
|
112
|
+
return { kind: 'manual', hint: tool.manual ?? 'no install known' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `<cmd> --version` with a 10 s timeout: the first non-empty line of stdout (else of stderr), cut
|
|
117
|
+
* to 120 chars. Any failure (missing, non-zero, timeout, empty) → null.
|
|
118
|
+
* @param {Readonly<ToolRow>} tool
|
|
119
|
+
* @param {{exec: typeof import('../util/exec.mjs').exec, env?: NodeJS.ProcessEnv}} deps
|
|
120
|
+
* @returns {Promise<string|null>}
|
|
121
|
+
*/
|
|
122
|
+
export async function toolVersion(tool, { exec, env }) {
|
|
123
|
+
try {
|
|
124
|
+
const res = await exec([tool.command, '--version'], { timeoutMs: VERSION_TIMEOUT_MS, ...(env ? { env } : {}) });
|
|
125
|
+
if (res.result !== 'ok' || res.timedOut) return null;
|
|
126
|
+
const firstLine = (/** @type {unknown} */ text) => String(text ?? '').split('\n').map((l) => l.trim()).find((l) => l.length > 0);
|
|
127
|
+
const first = firstLine(res.stdout) ?? firstLine(res.stderr);
|
|
128
|
+
return first ? first.slice(0, 120) : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Why an install failed, as a fixed phrase — never the child's error text.
|
|
136
|
+
* @param {{code?: number|null, signal?: string|null, timedOut?: boolean}} res
|
|
137
|
+
* @returns {string} `timed out`, `signal S`, `exit n` or `did not start`
|
|
138
|
+
*/
|
|
139
|
+
export function failureReason(res) {
|
|
140
|
+
if (res.timedOut) return 'timed out';
|
|
141
|
+
if (typeof res.signal === 'string' && res.signal.length > 0) return `signal ${res.signal}`;
|
|
142
|
+
if (typeof res.code === 'number') return `exit ${res.code}`;
|
|
143
|
+
return 'did not start';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Never rejects: a tool whose check throws counts as missing.
|
|
148
|
+
* @param {ToolEnv} [opts]
|
|
149
|
+
* @returns {Promise<string[]>} the ids of the tools that are not installed, in table order.
|
|
150
|
+
*/
|
|
151
|
+
export async function missingTools(opts = {}) {
|
|
152
|
+
const out = [];
|
|
153
|
+
for (const tool of TOOLS) {
|
|
154
|
+
let present = false;
|
|
155
|
+
try {
|
|
156
|
+
present = await detectTool(tool, opts);
|
|
157
|
+
} catch {
|
|
158
|
+
present = false;
|
|
159
|
+
}
|
|
160
|
+
if (!present) out.push(tool.id);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
@@ -13,16 +13,17 @@
|
|
|
13
13
|
* into any flag by mistake).
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { isOpInput } from '../../keys/onepassword.mjs';
|
|
17
17
|
import { HARNESSES } from '../harnesses.mjs';
|
|
18
|
+
import { TOOL_IDS } from '../tools.mjs';
|
|
18
19
|
|
|
19
20
|
export const PROVIDERS = Object.freeze(['anthropic', 'openai', 'xai']);
|
|
20
21
|
/** The engines the wizard offers. `subprocess` is never proposed (R2): a power user types it. */
|
|
21
22
|
export const ENGINE_CHOICES = Object.freeze(['auto', 'solo', 'harness']);
|
|
22
23
|
export const GATE_NAMES = Object.freeze(['test', 'lint', 'types', 'format']);
|
|
23
24
|
export const PROOF_KEYS = Object.freeze(['isolation', 'high', 'link_dirs', 'copy_untracked']);
|
|
24
|
-
/** Tools step 1 knows how to detect (and, for some, install after a per-tool yes). */
|
|
25
|
-
export
|
|
25
|
+
/** Tools step 1 knows how to detect (and, for some, install after a per-tool yes): the shared table's ids (B26). */
|
|
26
|
+
export { TOOL_IDS };
|
|
26
27
|
|
|
27
28
|
/** flag (as typed) -> the one answer key it overrides. */
|
|
28
29
|
export const ANSWER_FLAGS = Object.freeze({
|
|
@@ -166,8 +167,9 @@ function convert(flag, value) {
|
|
|
166
167
|
if (value !== 'on' && value !== 'off') throw new UsageError('--multimodel must be on or off');
|
|
167
168
|
return value === 'on';
|
|
168
169
|
case '--jev-ref':
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
// shape only here (parsing is sync); an item ID or link is resolved to op:// in run.mjs (B25)
|
|
171
|
+
if (!isOpInput(value)) throw new UsageError('--jev-ref must be a 1Password item ID, item link or op://vault/item/field reference');
|
|
172
|
+
return value.trim();
|
|
171
173
|
case '--jev-env':
|
|
172
174
|
if (!ENV_NAME.test(value)) throw new UsageError('--jev-env must be an environment variable name');
|
|
173
175
|
return value;
|
|
@@ -26,17 +26,18 @@ import { parse as parseYAML } from 'yaml';
|
|
|
26
26
|
import { DEFAULT_CONFIG_FILENAME, loadConfigFile } from '../../config/load.mjs';
|
|
27
27
|
import { refreshFromCliCaches } from '../../config/refresh.mjs';
|
|
28
28
|
import { validateConfig } from '../../config/validate.mjs';
|
|
29
|
+
import { describeOpItem, isOpRef, OP_MESSAGES, toOpRef } from '../../keys/onepassword.mjs';
|
|
29
30
|
import { createDefaultKeyStore, resolveKey } from '../../keys/store.mjs';
|
|
30
31
|
import { exec } from '../../util/exec.mjs';
|
|
31
32
|
import { redact } from '../../util/redact.mjs';
|
|
32
33
|
import { writeAgentJson } from '../agent-env.mjs';
|
|
33
|
-
import {
|
|
34
|
+
import { detectTool, failureReason, installPlan, TOOLS } from '../tools.mjs';
|
|
34
35
|
import { getHarness, globalSkillPath, projectSkillPath } from '../harnesses.mjs';
|
|
35
36
|
import { install, installsPath } from '../link.mjs';
|
|
36
37
|
import { currentHarness, effectiveProvider, gatherContext, judgeCollision, proposeJudge, resolveAnswers, SUBAGENT_HARNESSES } from './answers.mjs';
|
|
37
38
|
import { changedPaths, generatedConfig, mergeConfig, mergeInto, serializeConfig } from './project-file.mjs';
|
|
38
39
|
import { parseInitArgs, UsageError } from './flags.mjs';
|
|
39
|
-
import { askAnswers, askJevSource, askProjectSettings, askSettingsChoice, CancelledError, HINTS, withHint } from './steps.mjs';
|
|
40
|
+
import { askAnswers, askJevSource, askProjectSettings, askSettingsChoice, CancelledError, HINTS, JEV_LATER_TEXT, withHint } from './steps.mjs';
|
|
40
41
|
import { summarizeSettings } from './summary.mjs';
|
|
41
42
|
|
|
42
43
|
/** §5.1's stop text, printed at setup (not at the first run) when there is no engine to use. */
|
|
@@ -47,20 +48,17 @@ export const NO_ENGINE_STOP_TEXT = [
|
|
|
47
48
|
' Then re-run. (R2: this engine is never selected automatically.)',
|
|
48
49
|
].join('\n');
|
|
49
50
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{ id: 'gemini', command: 'gemini', install: ['npm', 'install', '-g', '@google/gemini-cli'], hint: null },
|
|
56
|
-
{ id: 'op', command: 'op', install: ['brew', 'install', '1password-cli'], hint: null },
|
|
57
|
-
]);
|
|
51
|
+
/**
|
|
52
|
+
* Step 1's recommended tools: the shared table (B26, `src/install/tools.mjs`) — detect, print the
|
|
53
|
+
* install command, install only after a per-tool yes.
|
|
54
|
+
*/
|
|
55
|
+
export { TOOLS };
|
|
58
56
|
|
|
59
57
|
export const USAGE = [
|
|
60
58
|
'usage: code-forge init [--no-interaction] [--tools recommended|current] [--yes-tool <tool>]…',
|
|
61
59
|
' [--harness a,b] [-g|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]…',
|
|
62
60
|
' [--refresh-models] [--multimodel on|off] [--second-provider P]',
|
|
63
|
-
' [--jev-ref op
|
|
61
|
+
' [--jev-ref <item-id|link|op://…> | --jev-env NAME | --no-jev] [--engine auto|solo|harness] [--solo-project N]',
|
|
64
62
|
' [--gate name=cmd]… [--proof isolation=export|lock | high=a,b | link_dirs=a,b | copy_untracked=a,b]… [--skip-doctor]',
|
|
65
63
|
].join('\n');
|
|
66
64
|
|
|
@@ -80,10 +78,11 @@ const JEV_ENV_NAME = 'CODE_FORGE_KEY_JEV';
|
|
|
80
78
|
|
|
81
79
|
/** A failure after the flags parsed: carries the exit code; the message names key paths, never values. */
|
|
82
80
|
class InitFailure extends Error {
|
|
83
|
-
/** @param {number} code @param {string} message */
|
|
84
|
-
constructor(code, message) {
|
|
81
|
+
/** @param {number} code @param {string} message @param {string|null} [kind] - a 1Password failure kind (B25) */
|
|
82
|
+
constructor(code, message, kind = null) {
|
|
85
83
|
super(message);
|
|
86
84
|
this.code = code;
|
|
85
|
+
this.kind = kind;
|
|
87
86
|
}
|
|
88
87
|
}
|
|
89
88
|
|
|
@@ -100,9 +99,12 @@ class InitFailure extends Error {
|
|
|
100
99
|
* @property {import('./steps.mjs').Ui} [ui] - default `@clack/prompts`.
|
|
101
100
|
* @property {() => Promise<any>} [getStore] - the B2 key store (default: the production chain).
|
|
102
101
|
* @property {(opts: {cwd: string, env: NodeJS.ProcessEnv}) => Promise<Array<{status: string, label: string, detail: string}>>} [doctor]
|
|
103
|
-
* @property {(argv: string[]) => Promise<{result: string}>} [installTool]
|
|
102
|
+
* @property {(argv: string[]) => Promise<{result: string, code?: number|null, signal?: string|null, timedOut?: boolean}>} [installTool]
|
|
104
103
|
* @property {string} [skillSource]
|
|
105
104
|
* @property {() => Date} [now]
|
|
105
|
+
* @property {typeof exec} [opExec] - runs `op` to resolve a 1Password item ID (default B0 `exec`).
|
|
106
|
+
* @property {NodeJS.Platform} [platform] - step 1's install plan (brew on macOS only; default `process.platform`).
|
|
107
|
+
* @property {(p: string) => boolean} [toolExists] - step 1's Solo app check (tests: a fake).
|
|
106
108
|
*/
|
|
107
109
|
|
|
108
110
|
/** @param {string} file @returns {Promise<Record<string, any>|null>} a missing or unreadable user config reads as null */
|
|
@@ -174,10 +176,10 @@ export async function runInit(args, deps = {}) {
|
|
|
174
176
|
|
|
175
177
|
/**
|
|
176
178
|
* The one exit: writes the log, then either the success shape or `{ok: false, error, wrote, …}`.
|
|
177
|
-
* @param {number} code @param {string|null} error
|
|
179
|
+
* @param {number} code @param {string|null} error @param {string|null} [kind] - a 1Password failure kind
|
|
178
180
|
* @returns {Promise<number>}
|
|
179
181
|
*/
|
|
180
|
-
const finish = async (code, error) => {
|
|
182
|
+
const finish = async (code, error, kind = null) => {
|
|
181
183
|
if (error !== null) {
|
|
182
184
|
say(`error: ${error}`);
|
|
183
185
|
stderr.write(redact(`init: ${error}\n`));
|
|
@@ -194,7 +196,7 @@ export async function runInit(args, deps = {}) {
|
|
|
194
196
|
if (jsonMode) {
|
|
195
197
|
const body = error === null
|
|
196
198
|
? { ok: code === 0, wrote, harnesses: linked, engine_stop: engineStop, doctor, settings: summary?.settings ?? null, blank: summary?.blank ?? [], log, log_tail: tail }
|
|
197
|
-
: { ok: false, error, wrote, log, log_tail: tail };
|
|
199
|
+
: { ok: false, error, ...(kind ? { kind } : {}), wrote, log, log_tail: tail };
|
|
198
200
|
writeAgentJson(JSON.parse(redact(JSON.stringify(body))), { stdout });
|
|
199
201
|
} else if (error === null) {
|
|
200
202
|
const rest = lines.slice(printed);
|
|
@@ -259,16 +261,43 @@ export async function runInit(args, deps = {}) {
|
|
|
259
261
|
const missing = [];
|
|
260
262
|
let jevRef = null;
|
|
261
263
|
let jevSource = 'none';
|
|
264
|
+
const resolveOp = (/** @type {string} */ input) => toOpRef(input, deps.opExec ? { exec: deps.opExec } : {});
|
|
265
|
+
/** @param {import('./steps.mjs').JevAnswer} a */
|
|
266
|
+
const takeAnswer = (a) => {
|
|
267
|
+
jevRef = a.ref;
|
|
268
|
+
jevSource = a.source;
|
|
269
|
+
const found = a.found ? describeOpItem(a.found) : null;
|
|
270
|
+
if (found) say(`keys: jev from ${found}`);
|
|
271
|
+
if (a.ref === null) say(JEV_LATER_TEXT);
|
|
272
|
+
};
|
|
262
273
|
if (values.jev.mode === 'ref') {
|
|
263
274
|
jevRef = values.jev.ref;
|
|
264
|
-
|
|
275
|
+
// B25: a --jev-ref item ID or link becomes a full op:// reference before anything is written
|
|
276
|
+
if (!jevRef.startsWith('env:') && !isOpRef(jevRef) && jevRef !== 'user' && !jevRef.startsWith('keychain:')) {
|
|
277
|
+
/** @type {Awaited<ReturnType<typeof toOpRef>>} */
|
|
278
|
+
let res;
|
|
279
|
+
try {
|
|
280
|
+
res = await resolveOp(jevRef);
|
|
281
|
+
} catch {
|
|
282
|
+
// never the thrown message: it could carry op output
|
|
283
|
+
res = { ref: null, kind: 'op_failed', error: `${OP_MESSAGES.op_failed} (unexpected error)` };
|
|
284
|
+
}
|
|
285
|
+
if (res.ref !== null) {
|
|
286
|
+
takeAnswer({ ref: res.ref, source: 'op', found: { title: res.title, vault: res.vault, field: res.field } });
|
|
287
|
+
} else {
|
|
288
|
+
const error = res.error ?? OP_MESSAGES.op_failed;
|
|
289
|
+
if (interactive) takeAnswer(await askJevSource(ui, getStore, { resolve: resolveOp, pending: { input: jevRef, error } }));
|
|
290
|
+
else throw new InitFailure(2, `--jev-ref: ${error}. Nothing written.`, res.kind ?? 'op_failed');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (jevRef !== null) jevSource = jevRef.startsWith('env:') ? 'env' : jevRef.startsWith('op://') ? 'op' : 'keychain';
|
|
265
294
|
} else if (values.jev.mode === 'auto') {
|
|
266
295
|
const res = await resolveKey('jev', { store: await getStore(), env });
|
|
267
296
|
if (res.value !== null && typeof res.source === 'string') {
|
|
268
297
|
jevRef = res.source === 'env' ? `env:${JEV_ENV_NAME}` : 'user';
|
|
269
298
|
jevSource = res.source;
|
|
270
299
|
} else if (interactive) {
|
|
271
|
-
(
|
|
300
|
+
takeAnswer(await askJevSource(ui, getStore, { resolve: resolveOp }));
|
|
272
301
|
} else {
|
|
273
302
|
missing.push('keys.jev (pass --jev-ref, --jev-env or --no-jev)');
|
|
274
303
|
}
|
|
@@ -319,15 +348,19 @@ export async function runInit(args, deps = {}) {
|
|
|
319
348
|
|
|
320
349
|
// Step 1 — tools.
|
|
321
350
|
if (values.tools === 'recommended') {
|
|
351
|
+
const toolOpts = { pathEnv: env.PATH ?? '', platform: deps.platform ?? process.platform, home, ...(deps.toolExists ? { exists: deps.toolExists } : {}) };
|
|
322
352
|
for (const tool of TOOLS) {
|
|
323
|
-
const present = tool.
|
|
353
|
+
const present = (tool.id === 'solo' && ctx.solo) || (await detectTool(tool, toolOpts));
|
|
324
354
|
if (present) {
|
|
325
355
|
say(`tool ${tool.id}: present`);
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const plan = await installPlan(tool, toolOpts);
|
|
359
|
+
if (plan.kind === 'run' && values.yes_tools.includes(tool.id)) {
|
|
360
|
+
const res = await (deps.installTool ?? ((argv) => exec(argv, { timeoutMs: 600_000, env })))(plan.argv);
|
|
361
|
+
say(`tool ${tool.id}: ${res.result === 'ok' ? 'installed' : `install failed (${failureReason(res)})`} (${plan.argv.join(' ')})`);
|
|
329
362
|
} else {
|
|
330
|
-
say(`tool ${tool.id}: missing — ${
|
|
363
|
+
say(`tool ${tool.id}: missing — ${plan.kind === 'run' ? `install with: ${plan.argv.join(' ')}` : plan.hint} (or: code-forge tools install)`);
|
|
331
364
|
}
|
|
332
365
|
}
|
|
333
366
|
}
|
|
@@ -385,7 +418,7 @@ export async function runInit(args, deps = {}) {
|
|
|
385
418
|
|
|
386
419
|
return await finish(doctor === null || doctor.ok ? 0 : 1, null);
|
|
387
420
|
} catch (err) {
|
|
388
|
-
if (err instanceof InitFailure) return finish(err.code, err.message);
|
|
421
|
+
if (err instanceof InitFailure) return finish(err.code, err.message, err.kind);
|
|
389
422
|
if (err instanceof UsageError) return finish(2, err.message);
|
|
390
423
|
if (err instanceof CancelledError) return finish(1, err.message);
|
|
391
424
|
// an unexpected throw (an installer, a link, the doctor…): the log keeps the stack, redacted
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* because it is asked only when the chain resolves nothing.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { isOpRef } from '../../keys/onepassword.mjs';
|
|
11
|
+
import { isOpRef, OP_MESSAGES } from '../../keys/onepassword.mjs';
|
|
12
12
|
import { HARNESSES } from '../harnesses.mjs';
|
|
13
13
|
import { effectiveProvider, judgeCollision, proposeJudge } from './answers.mjs';
|
|
14
14
|
import { ENGINE_CHOICES, GATE_NAMES, PROVIDERS, parseLevel, shellWords, UsageError } from './flags.mjs';
|
|
@@ -32,8 +32,20 @@ async function answer(ui, pending) {
|
|
|
32
32
|
return value;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** The 1Password option's hint: where to find the reference in the app. */
|
|
36
|
-
export const OP_REF_HINT =
|
|
35
|
+
/** The 1Password option's hint: where to find the item ID, link or reference in the app. */
|
|
36
|
+
export const OP_REF_HINT = "Paste the item ID (from `op item list`), the item's link, or its op:// secret reference.";
|
|
37
|
+
|
|
38
|
+
/** The 1Password question (B25): an item ID or link is resolved to a full `op://` reference. */
|
|
39
|
+
export const OP_REF_QUESTION = '1Password item ID, item link, or op:// reference';
|
|
40
|
+
|
|
41
|
+
/** The question after "Enter a full op:// reference": op:// only, no lookup. */
|
|
42
|
+
export const OP_FULL_REF_QUESTION = 'Full op://vault/item/field reference';
|
|
43
|
+
|
|
44
|
+
/** The menu message when the resolver itself threw (its message is never shown). */
|
|
45
|
+
export const OP_RESOLVE_FAILED = '1Password CLI failed (unexpected error); run `op item get <item-id>` yourself to see why';
|
|
46
|
+
|
|
47
|
+
/** Printed when the Jev key is left unset, so the person knows how to set it later. */
|
|
48
|
+
export const JEV_LATER_TEXT = 'keys: jev not set — set it later with: code-forge keys set jev --op <item-id>';
|
|
37
49
|
|
|
38
50
|
/** The one-sentence hint line under each question (B24). */
|
|
39
51
|
export const HINTS = Object.freeze({
|
|
@@ -262,40 +274,114 @@ export async function askProjectSettings(values, origins, ui) {
|
|
|
262
274
|
return changed;
|
|
263
275
|
}
|
|
264
276
|
|
|
277
|
+
/**
|
|
278
|
+
* @typedef {(input: string) => Promise<{ref: string|null, error?: string, kind?: string, title?: string, vault?: string, field?: string}>} OpResolver
|
|
279
|
+
* @typedef {{ref: string|null, source: string, found?: {title?: string, vault?: string, field?: string}}} JevAnswer
|
|
280
|
+
*/
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The 1Password branch: ask for an item ID, link or reference, resolve it, and on a failure offer
|
|
284
|
+
* Try again · Enter a full op:// reference · Choose another key source · Skip for now. Never throws
|
|
285
|
+
* on a 1Password failure. Returns null for "choose another key source".
|
|
286
|
+
* @param {Ui} ui
|
|
287
|
+
* @param {OpResolver} resolve
|
|
288
|
+
* @param {{input: string, error: string}|null} pending - start at the failure menu (a flag value that failed).
|
|
289
|
+
* @returns {Promise<JevAnswer|null>}
|
|
290
|
+
*/
|
|
291
|
+
async function askOpRef(ui, resolve, pending) {
|
|
292
|
+
let input = pending?.input ?? '';
|
|
293
|
+
let error = pending?.error ?? null;
|
|
294
|
+
let question = OP_REF_QUESTION;
|
|
295
|
+
for (;;) {
|
|
296
|
+
if (error === null) {
|
|
297
|
+
input = String(await answer(ui, ui.text({ message: withHint(question, OP_REF_HINT), ...(input ? { initialValue: input } : {}) }))).trim();
|
|
298
|
+
/** @type {Awaited<ReturnType<OpResolver>>} */
|
|
299
|
+
let res;
|
|
300
|
+
if (question !== OP_REF_QUESTION && !isOpRef(input)) {
|
|
301
|
+
// "full reference" mode takes op:// only; nothing is looked up
|
|
302
|
+
res = { ref: null, kind: 'op_bad_input', error: OP_MESSAGES.op_bad_input };
|
|
303
|
+
} else {
|
|
304
|
+
try {
|
|
305
|
+
const got = await resolve(input);
|
|
306
|
+
// a resolver that answers with anything but the documented shape counts as a failure
|
|
307
|
+
res = got && typeof got === 'object' && (got.ref === null || typeof got.ref === 'string')
|
|
308
|
+
? got
|
|
309
|
+
: { ref: null, kind: 'op_failed', error: OP_RESOLVE_FAILED };
|
|
310
|
+
} catch {
|
|
311
|
+
// never the thrown message: it could carry op output
|
|
312
|
+
res = { ref: null, kind: 'op_failed', error: OP_RESOLVE_FAILED };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (res.ref !== null) {
|
|
316
|
+
return { ref: res.ref, source: 'op', found: { title: res.title, vault: res.vault, field: res.field } };
|
|
317
|
+
}
|
|
318
|
+
error = res.error ?? OP_RESOLVE_FAILED;
|
|
319
|
+
}
|
|
320
|
+
const next = await answer(ui, ui.select({
|
|
321
|
+
message: withHint('1Password lookup failed. What next?', error),
|
|
322
|
+
options: [
|
|
323
|
+
{ value: 'retry', label: 'Try again' },
|
|
324
|
+
{ value: 'full', label: 'Enter a full op:// reference' },
|
|
325
|
+
{ value: 'other', label: 'Choose another key source' },
|
|
326
|
+
{ value: 'skip', label: 'Skip for now' },
|
|
327
|
+
],
|
|
328
|
+
initialValue: 'retry',
|
|
329
|
+
}));
|
|
330
|
+
error = null;
|
|
331
|
+
if (next === 'other') return null;
|
|
332
|
+
if (next === 'skip') return { ref: null, source: 'none' };
|
|
333
|
+
if (next === 'full') {
|
|
334
|
+
question = OP_FULL_REF_QUESTION;
|
|
335
|
+
input = '';
|
|
336
|
+
} else {
|
|
337
|
+
question = OP_REF_QUESTION;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
265
342
|
/**
|
|
266
343
|
* Step 5 when the chain resolved nothing: where is the Jev key? A pasted key is read with hidden
|
|
267
|
-
* input and handed straight to `store`; it is never returned, echoed or logged.
|
|
344
|
+
* input and handed straight to `store`; it is never returned, echoed or logged. A 1Password item ID
|
|
345
|
+
* or link is resolved to `op://<vaultId>/<itemId>/<fieldId>` through `opts.resolve` (B25).
|
|
268
346
|
* @param {Ui} ui
|
|
269
347
|
* @param {() => Promise<{put: (name: string, value: string, meta: {source: string, exp: number|null}) => Promise<void>}>} getStore
|
|
270
|
-
* @
|
|
348
|
+
* @param {{resolve?: OpResolver, pending?: {input: string, error: string}|null}} [opts]
|
|
349
|
+
* @returns {Promise<JevAnswer>}
|
|
271
350
|
*/
|
|
272
|
-
export async function askJevSource(ui, getStore) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
{ value: 'env', label: 'environment variable name' },
|
|
278
|
-
{ value: 'paste', label: 'paste now (hidden)' },
|
|
279
|
-
{ value: 'skip', label: 'skip (rules-only System 1)' },
|
|
280
|
-
],
|
|
281
|
-
initialValue: 'skip',
|
|
282
|
-
}));
|
|
283
|
-
if (choice === 'op') {
|
|
284
|
-
const ref = String(await answer(ui, ui.text({ message: withHint('1Password reference (op://vault/item/field)', OP_REF_HINT) }))).trim();
|
|
285
|
-
if (!isOpRef(ref)) throw new UsageError('not an op://vault/item/field reference');
|
|
286
|
-
return { ref, source: 'op' };
|
|
351
|
+
export async function askJevSource(ui, getStore, { resolve, pending = null } = {}) {
|
|
352
|
+
if (typeof resolve !== 'function') throw new TypeError('askJevSource: opts.resolve (the 1Password resolver) is required');
|
|
353
|
+
if (pending) {
|
|
354
|
+
const fromOp = await askOpRef(ui, resolve, pending);
|
|
355
|
+
if (fromOp) return fromOp;
|
|
287
356
|
}
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
357
|
+
for (;;) {
|
|
358
|
+
const choice = await answer(ui, ui.select({
|
|
359
|
+
message: withHint('Where is the Jev key?', HINTS.jev),
|
|
360
|
+
options: [
|
|
361
|
+
{ value: 'op', label: '1Password item or reference', hint: OP_REF_HINT },
|
|
362
|
+
{ value: 'env', label: 'environment variable name' },
|
|
363
|
+
{ value: 'paste', label: 'paste now (hidden)' },
|
|
364
|
+
{ value: 'skip', label: 'skip (rules-only System 1)' },
|
|
365
|
+
],
|
|
366
|
+
initialValue: 'skip',
|
|
367
|
+
}));
|
|
368
|
+
if (choice === 'op') {
|
|
369
|
+
const fromOp = await askOpRef(ui, resolve, null);
|
|
370
|
+
if (fromOp) return fromOp;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (choice === 'env') {
|
|
374
|
+
const name = String(await answer(ui, ui.text({ message: withHint('Environment variable name', HINTS.env) }))).trim();
|
|
375
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new UsageError('not an environment variable name');
|
|
376
|
+
return { ref: `env:${name}`, source: 'env' };
|
|
377
|
+
}
|
|
378
|
+
if (choice === 'paste') {
|
|
379
|
+
const typed = await answer(ui, ui.password({ message: withHint('Jev key (input hidden)', HINTS.paste) }));
|
|
380
|
+
if (typeof typed !== 'string' || typed.length === 0) return { ref: null, source: 'none' };
|
|
381
|
+
const store = await getStore();
|
|
382
|
+
await store.put('jev', typed, { source: 'user', exp: null });
|
|
383
|
+
return { ref: 'user', source: 'keychain' };
|
|
384
|
+
}
|
|
385
|
+
return { ref: null, source: 'none' };
|
|
299
386
|
}
|
|
300
|
-
return { ref: null, source: 'none' };
|
|
301
387
|
}
|