@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.
@@ -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 { isOpRef } from '../../keys/onepassword.mjs';
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 const TOOL_IDS = Object.freeze(['solo', 'codex', 'grok', 'gemini', 'op']);
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
- if (!isOpRef(value)) throw new UsageError('--jev-ref must be an op://vault/item/field reference');
170
- return value;
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 { commandOnPath } from '../detect.mjs';
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
- /** Step 1's recommended tools: detect, print the install command, install only after a per-tool yes. */
51
- export const TOOLS = Object.freeze([
52
- { id: 'solo', command: null, install: null, hint: 'install the Solo app (https://soloterm.com) and add its MCP entry' },
53
- { id: 'codex', command: 'codex', install: ['npm', 'install', '-g', '@openai/codex'], hint: null },
54
- { id: 'grok', command: 'grok', install: null, hint: 'install the Grok CLI from its vendor page' },
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://… | --jev-env NAME | --no-jev] [--engine auto|solo|harness] [--solo-project N]',
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
- jevSource = jevRef.startsWith('env:') ? 'env' : jevRef.startsWith('op://') ? 'op' : 'keychain';
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
- ({ ref: jevRef, source: jevSource } = await askJevSource(ui, getStore));
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.command === null ? ctx.solo : await commandOnPath(tool.command, { pathEnv: env.PATH ?? '' });
353
+ const present = (tool.id === 'solo' && ctx.solo) || (await detectTool(tool, toolOpts));
324
354
  if (present) {
325
355
  say(`tool ${tool.id}: present`);
326
- } else if (tool.install && values.yes_tools.includes(tool.id)) {
327
- const res = await (deps.installTool ?? ((argv) => exec(argv, { timeoutMs: 600_000, env })))(tool.install);
328
- say(`tool ${tool.id}: ${res.result === 'ok' ? 'installed' : 'install failed'} (${tool.install.join(' ')})`);
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 — ${tool.install ? `install with: ${tool.install.join(' ')}` : tool.hint} (doctor WARNs)`);
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 = 'In the 1Password app, right-click the key field → Copy Secret Reference (op://vault/item/field).';
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
- * @returns {Promise<{ref: string|null, source: string}>}
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
- const choice = await answer(ui, ui.select({
274
- message: withHint('Where is the Jev key?', HINTS.jev),
275
- options: [
276
- { value: 'op', label: '1Password reference', hint: OP_REF_HINT },
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
- if (choice === 'env') {
289
- const name = String(await answer(ui, ui.text({ message: withHint('Environment variable name', HINTS.env) }))).trim();
290
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new UsageError('not an environment variable name');
291
- return { ref: `env:${name}`, source: 'env' };
292
- }
293
- if (choice === 'paste') {
294
- const typed = await answer(ui, ui.password({ message: withHint('Jev key (input hidden)', HINTS.paste) }));
295
- if (typeof typed !== 'string' || typed.length === 0) return { ref: null, source: 'none' };
296
- const store = await getStore();
297
- await store.put('jev', typed, { source: 'user', exp: null });
298
- return { ref: 'user', source: 'keychain' };
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
  }