@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
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The interactive half of `code-forge init` (plan §2, block B13a): one question per answer that
|
|
3
|
-
* no flag already fixed, default on Enter
|
|
3
|
+
* no flag already fixed, default on Enter, each with a one-line hint (B24). Gates and proof
|
|
4
|
+
* settings come from the project and are shown in a summary (B24); they are asked only when the
|
|
5
|
+
* person picks "Customize now" at {@link askSettingsChoice}. `ui` has `@clack/prompts`' shape (`select`,
|
|
4
6
|
* `multiselect`, `text`, `confirm`, `password`, `isCancel`), so the real module is passed in
|
|
5
7
|
* production and a scripted fake in tests. Step 5's key question lives in {@link askJevSource}
|
|
6
8
|
* because it is asked only when the chain resolves nothing.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import { isOpRef } from '../../keys/onepassword.mjs';
|
|
11
|
+
import { isOpRef, OP_MESSAGES } from '../../keys/onepassword.mjs';
|
|
10
12
|
import { HARNESSES } from '../harnesses.mjs';
|
|
11
13
|
import { effectiveProvider, judgeCollision, proposeJudge } from './answers.mjs';
|
|
12
14
|
import { ENGINE_CHOICES, GATE_NAMES, PROVIDERS, parseLevel, shellWords, UsageError } from './flags.mjs';
|
|
@@ -15,7 +17,7 @@ export class CancelledError extends Error {}
|
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* @typedef {object} Ui
|
|
18
|
-
* @property {(o: {message: string, options: Array<{value: any, label?: string}>, initialValue?: any}) => Promise<any>} select
|
|
20
|
+
* @property {(o: {message: string, options: Array<{value: any, label?: string, hint?: string}>, initialValue?: any}) => Promise<any>} select
|
|
19
21
|
* @property {(o: {message: string, options: Array<{value: any, label?: string}>, initialValues?: any[], required?: boolean}) => Promise<any>} multiselect
|
|
20
22
|
* @property {(o: {message: string, initialValue?: string, placeholder?: string}) => Promise<any>} text
|
|
21
23
|
* @property {(o: {message: string, initialValue?: boolean}) => Promise<any>} confirm
|
|
@@ -30,6 +32,55 @@ async function answer(ui, pending) {
|
|
|
30
32
|
return value;
|
|
31
33
|
}
|
|
32
34
|
|
|
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>';
|
|
49
|
+
|
|
50
|
+
/** The one-sentence hint line under each question (B24). */
|
|
51
|
+
export const HINTS = Object.freeze({
|
|
52
|
+
tools: 'Recommended adds Solo and the Codex, Grok, Gemini and 1Password CLIs, each only after its own yes.',
|
|
53
|
+
harnesses: 'The skill is linked into each harness you pick; detected ones are marked.',
|
|
54
|
+
scope: 'project puts the link inside this repository; global puts it under your home folder.',
|
|
55
|
+
method: 'A symlink follows package updates; a copy stays as installed.',
|
|
56
|
+
provider: 'The model family every level uses unless a level names its own provider.',
|
|
57
|
+
levels: 'L0 is the cheapest model and L3 the strongest; Enter keeps all four.',
|
|
58
|
+
level: 'Enter keeps this level as shown.',
|
|
59
|
+
refresh_models: 'Reads only local cache files; nothing is sent anywhere.',
|
|
60
|
+
multimodel: 'Two providers review each block and a third one judges; it costs more per block.',
|
|
61
|
+
second_provider: 'This provider reviews each block next to the L2 reviewer.',
|
|
62
|
+
judge: 'The judge settles disagreements between the two reviewers.',
|
|
63
|
+
engine: 'auto uses Solo when present, otherwise the subagents of the current harness.',
|
|
64
|
+
solo_project: 'The Solo project that coder processes run in; leave blank for none.',
|
|
65
|
+
jev: 'Jev is the small fast classifier behind System 1; skip uses rules only.',
|
|
66
|
+
env: 'That variable must hold the key whenever code-forge runs.',
|
|
67
|
+
paste: 'The key goes to the OS key store; the config keeps only a reference.',
|
|
68
|
+
settings: 'Pick how to treat the gate and proof values shown above.',
|
|
69
|
+
apply: 'Only the listed keys change; your other edits stay.',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** The one-line hints of the "Customize now" questions, in the order they are asked (B24). */
|
|
73
|
+
export const SETTING_HINTS = Object.freeze({
|
|
74
|
+
test: 'the command that runs your tests; the block cannot close if it fails (blank = no test gate)',
|
|
75
|
+
lint: 'the command that runs your linter; the block cannot close if it fails (blank = no lint gate)',
|
|
76
|
+
types: 'the command that runs your type checker; the block cannot close if it fails (blank = no types gate)',
|
|
77
|
+
format: 'the command that runs your format check; the block cannot close if it fails (blank = no format gate)',
|
|
78
|
+
high: 'globs where mistakes are costly (auth, billing); these files get deeper reviews and need red→green proof to close (blank = none)',
|
|
79
|
+
isolation: 'export = run proof tests in a temporary copy (safe with parallel coders); lock = run in your folder and pause other coders',
|
|
80
|
+
link_dirs: 'big folders the copy links to instead of copying, e.g. node_modules, vendor',
|
|
81
|
+
copy_untracked: 'files git does not track but your tests need, e.g. .env, .env.testing',
|
|
82
|
+
});
|
|
83
|
+
|
|
33
84
|
/**
|
|
34
85
|
* A comma list split only at brace depth 0, so a glob like `app/**\/*.{php,vue}` stays one item
|
|
35
86
|
* and Enter on a pre-filled list gives the list back unchanged.
|
|
@@ -53,6 +104,14 @@ function csv(text) {
|
|
|
53
104
|
return items.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
54
105
|
}
|
|
55
106
|
|
|
107
|
+
/**
|
|
108
|
+
* A question with its hint on the line below (clack prints a multi-line message as is).
|
|
109
|
+
* @param {string} message @param {string} hint @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function withHint(message, hint) {
|
|
112
|
+
return `${message}\n${hint}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
56
115
|
/**
|
|
57
116
|
* The `model[:effort][@provider]` text a level is pre-filled with — the exact form `parseLevel`
|
|
58
117
|
* reads back, so Enter keeps the level unchanged (provider override included).
|
|
@@ -63,7 +122,8 @@ export function levelText(level) {
|
|
|
63
122
|
}
|
|
64
123
|
|
|
65
124
|
/**
|
|
66
|
-
* Asks every answer whose source is not `flag`, in step order
|
|
125
|
+
* Asks every answer whose source is not `flag`, in step order — gates and proof excepted (B24:
|
|
126
|
+
* see {@link askProjectSettings}). Mutates and returns `values`.
|
|
67
127
|
* @param {import('./answers.mjs').Answers} values
|
|
68
128
|
* @param {Record<string, string>} sources
|
|
69
129
|
* @param {import('./answers.mjs').WizardContext} ctx
|
|
@@ -75,52 +135,52 @@ export async function askAnswers(values, sources, ctx, ui) {
|
|
|
75
135
|
|
|
76
136
|
if (open('tools')) {
|
|
77
137
|
values.tools = await answer(ui, ui.select({
|
|
78
|
-
message: 'Install the recommended tools, or use only the current harness?',
|
|
138
|
+
message: withHint('Install the recommended tools, or use only the current harness?', HINTS.tools),
|
|
79
139
|
options: [{ value: 'current', label: 'current harness' }, { value: 'recommended', label: 'recommended tools' }],
|
|
80
140
|
initialValue: values.tools,
|
|
81
141
|
}));
|
|
82
142
|
}
|
|
83
143
|
if (open('harnesses')) {
|
|
84
144
|
values.harnesses = await answer(ui, ui.multiselect({
|
|
85
|
-
message: 'Install the skill into:',
|
|
145
|
+
message: withHint('Install the skill into:', HINTS.harnesses),
|
|
86
146
|
options: HARNESSES.map((h) => ({ value: h.id, label: `${h.label}${ctx.detectedHarnesses.includes(h.id) ? ' (detected)' : ''}` })),
|
|
87
147
|
initialValues: values.harnesses,
|
|
88
148
|
required: false,
|
|
89
149
|
}));
|
|
90
150
|
}
|
|
91
151
|
if (open('scope')) {
|
|
92
|
-
values.scope = await answer(ui, ui.select({ message: 'Scope', options: [{ value: 'project' }, { value: 'global' }], initialValue: values.scope }));
|
|
152
|
+
values.scope = await answer(ui, ui.select({ message: withHint('Scope', HINTS.scope), options: [{ value: 'project' }, { value: 'global' }], initialValue: values.scope }));
|
|
93
153
|
}
|
|
94
154
|
if (open('method')) {
|
|
95
|
-
values.method = await answer(ui, ui.select({ message: 'Method', options: [{ value: 'symlink' }, { value: 'copy' }], initialValue: values.method }));
|
|
155
|
+
values.method = await answer(ui, ui.select({ message: withHint('Method', HINTS.method), options: [{ value: 'symlink' }, { value: 'copy' }], initialValue: values.method }));
|
|
96
156
|
}
|
|
97
157
|
if (open('provider')) {
|
|
98
|
-
values.provider = await answer(ui, ui.select({ message: 'Default provider', options: PROVIDERS.map((p) => ({ value: p })), initialValue: values.provider }));
|
|
158
|
+
values.provider = await answer(ui, ui.select({ message: withHint('Default provider', HINTS.provider), options: PROVIDERS.map((p) => ({ value: p })), initialValue: values.provider }));
|
|
99
159
|
}
|
|
100
160
|
if (open('levels')) {
|
|
101
161
|
const summary = ['L0', 'L1', 'L2', 'L3'].map((l) => `${l}=${levelText(values.levels[l])}`).join(' ');
|
|
102
|
-
const keep = await answer(ui, ui.confirm({ message: `Keep the level matrix? ${summary}`, initialValue: true }));
|
|
162
|
+
const keep = await answer(ui, ui.confirm({ message: withHint(`Keep the level matrix? ${summary}`, HINTS.levels), initialValue: true }));
|
|
103
163
|
if (!keep) {
|
|
104
164
|
for (const level of ['L0', 'L1', 'L2', 'L3']) {
|
|
105
165
|
const shown = levelText(values.levels[level]);
|
|
106
|
-
const typed = await answer(ui, ui.text({ message: `${level} model[:effort][@provider]`, initialValue: shown }));
|
|
166
|
+
const typed = await answer(ui, ui.text({ message: withHint(`${level} model[:effort][@provider]`, HINTS.level), initialValue: shown }));
|
|
107
167
|
// Enter (unchanged text) keeps the level object as it is, `fallback` included
|
|
108
168
|
if (String(typed).trim() !== shown) values.levels[level] = parseLevel(`${level}=${String(typed).trim()}`).spec;
|
|
109
169
|
}
|
|
110
170
|
}
|
|
111
171
|
}
|
|
112
172
|
if (open('refresh_models')) {
|
|
113
|
-
values.refresh_models = await answer(ui, ui.confirm({ message: 'Read model ids from the Codex/Grok CLI caches on this machine?', initialValue: false }));
|
|
173
|
+
values.refresh_models = await answer(ui, ui.confirm({ message: withHint('Read model ids from the Codex/Grok CLI caches on this machine?', HINTS.refresh_models), initialValue: false }));
|
|
114
174
|
}
|
|
115
175
|
if (open('multimodel')) {
|
|
116
|
-
values.multimodel = await answer(ui, ui.confirm({ message: 'Multimodel review (consensus)?', initialValue: values.multimodel }));
|
|
176
|
+
values.multimodel = await answer(ui, ui.confirm({ message: withHint('Multimodel review (consensus)?', HINTS.multimodel), initialValue: values.multimodel }));
|
|
117
177
|
}
|
|
118
178
|
if (values.multimodel && open('second_provider')) {
|
|
119
179
|
// R5: the second reviewer must differ from the provider L2 actually runs on, not the default one
|
|
120
180
|
const reviewer1 = effectiveProvider(values, 'L2');
|
|
121
181
|
const others = PROVIDERS.filter((p) => p !== reviewer1);
|
|
122
182
|
values.second_provider = await answer(ui, ui.select({
|
|
123
|
-
message: `Second provider (must differ from the effective L2 provider, ${reviewer1})`,
|
|
183
|
+
message: withHint(`Second provider (must differ from the effective L2 provider, ${reviewer1})`, HINTS.second_provider),
|
|
124
184
|
options: others.map((p) => ({ value: p })),
|
|
125
185
|
initialValue: others.includes(values.second_provider) ? values.second_provider : others[0],
|
|
126
186
|
}));
|
|
@@ -132,70 +192,196 @@ export async function askAnswers(values, sources, ctx, ui) {
|
|
|
132
192
|
for (let collision = reviewersDiffer ? judgeCollision(values) : null; collision; collision = judgeCollision(values)) {
|
|
133
193
|
const proposal = collision.third ? levelText(proposeJudge(collision.third)) : '';
|
|
134
194
|
const typed = await answer(ui, ui.text({
|
|
135
|
-
message: `L3 judge — must come from a third provider (reviewers: ${collision.reviewers.join(', ')}); model[:effort][@provider]`,
|
|
195
|
+
message: withHint(`L3 judge — must come from a third provider (reviewers: ${collision.reviewers.join(', ')}); model[:effort][@provider]`, HINTS.judge),
|
|
136
196
|
initialValue: proposal,
|
|
137
197
|
}));
|
|
138
198
|
values.levels.L3 = parseLevel(`L3=${String(typed).trim()}`).spec;
|
|
139
199
|
}
|
|
140
200
|
// `subprocess` is never offered (R2); an existing `engine: subprocess` is kept, not asked.
|
|
141
201
|
if (open('engine') && values.engine !== 'subprocess') {
|
|
142
|
-
values.engine = await answer(ui, ui.select({ message: 'Engine', options: ENGINE_CHOICES.map((e) => ({ value: e })), initialValue: values.engine }));
|
|
202
|
+
values.engine = await answer(ui, ui.select({ message: withHint('Engine', HINTS.engine), options: ENGINE_CHOICES.map((e) => ({ value: e })), initialValue: values.engine }));
|
|
143
203
|
}
|
|
144
204
|
if (open('solo_project') && ctx.solo && values.engine !== 'harness') {
|
|
145
|
-
const typed = await answer(ui, ui.text({ message: 'Solo project id (blank: none)', initialValue: values.solo_project === null ? '' : String(values.solo_project) }));
|
|
205
|
+
const typed = await answer(ui, ui.text({ message: withHint('Solo project id (blank: none)', HINTS.solo_project), initialValue: values.solo_project === null ? '' : String(values.solo_project) }));
|
|
146
206
|
values.solo_project = /^[1-9]\d{0,9}$/.test(String(typed).trim()) ? Number(String(typed).trim()) : null;
|
|
147
207
|
}
|
|
148
|
-
if (open('gates')) {
|
|
149
|
-
for (const name of GATE_NAMES) {
|
|
150
|
-
const current = values.gates[name];
|
|
151
|
-
const typed = await answer(ui, ui.text({ message: `Gate ${name} (blank or "none": no gate)`, initialValue: current ? current.join(' ') : '' }));
|
|
152
|
-
const text = String(typed ?? '').trim();
|
|
153
|
-
values.gates[name] = text === '' || text === 'none' ? null : shellWords(text);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (open('proof')) {
|
|
157
|
-
values.proof.high = csv(await answer(ui, ui.text({ message: 'High-risk paths (comma list of globs)', initialValue: values.proof.high.join(',') })));
|
|
158
|
-
values.proof.isolation = await answer(ui, ui.select({ message: 'Proof isolation', options: [{ value: 'export' }, { value: 'lock' }], initialValue: values.proof.isolation }));
|
|
159
|
-
values.proof.link_dirs = csv(await answer(ui, ui.text({ message: 'Export: directories to link', initialValue: values.proof.link_dirs.join(',') })));
|
|
160
|
-
values.proof.copy_untracked = csv(await answer(ui, ui.text({ message: 'Export: untracked files to copy', initialValue: values.proof.copy_untracked.join(',') })));
|
|
161
|
-
}
|
|
162
208
|
return values;
|
|
163
209
|
}
|
|
164
210
|
|
|
165
211
|
/**
|
|
166
|
-
*
|
|
167
|
-
* input and handed straight to `store`; it is never returned, echoed or logged.
|
|
212
|
+
* The one question after the settings summary (B24), asked every time in interactive mode.
|
|
168
213
|
* @param {Ui} ui
|
|
169
|
-
* @
|
|
170
|
-
* @returns {Promise<{ref: string|null, source: string}>}
|
|
214
|
+
* @returns {Promise<'use'|'customize'|'later'>}
|
|
171
215
|
*/
|
|
172
|
-
export async function
|
|
173
|
-
|
|
174
|
-
message: '
|
|
216
|
+
export async function askSettingsChoice(ui) {
|
|
217
|
+
return answer(ui, ui.select({
|
|
218
|
+
message: withHint('Gates and proof settings:', HINTS.settings),
|
|
175
219
|
options: [
|
|
176
|
-
{ value: '
|
|
177
|
-
{ value: '
|
|
178
|
-
{ value: '
|
|
179
|
-
{ value: 'skip', label: 'skip (rules-only System 1)' },
|
|
220
|
+
{ value: 'use', label: 'Use these', hint: 'keep the detected values; blanks stay blank' },
|
|
221
|
+
{ value: 'customize', label: 'Customize now', hint: 'ask each gate and proof value, pre-filled with what was detected' },
|
|
222
|
+
{ value: 'later', label: 'Leave for later', hint: 'keep the detected values and print the keys to edit later' },
|
|
180
223
|
],
|
|
181
|
-
initialValue: '
|
|
224
|
+
initialValue: 'use',
|
|
182
225
|
}));
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* "Customize now": the gate and proof questions, each pre-filled with the value shown in the
|
|
230
|
+
* summary (Enter keeps it), in the order test, lint, types, format, high-risk paths, isolation,
|
|
231
|
+
* link_dirs, copy_untracked. A value a flag set is not asked (flags override). Mutates `values`
|
|
232
|
+
* and returns the config keys whose value changed.
|
|
233
|
+
* @param {import('./answers.mjs').Answers} values
|
|
234
|
+
* @param {import('./answers.mjs').Origins} origins
|
|
235
|
+
* @param {Ui} ui
|
|
236
|
+
* @returns {Promise<string[]>}
|
|
237
|
+
*/
|
|
238
|
+
export async function askProjectSettings(values, origins, ui) {
|
|
239
|
+
/** @type {string[]} */
|
|
240
|
+
const changed = [];
|
|
241
|
+
for (const name of GATE_NAMES) {
|
|
242
|
+
if (origins.gates[name] === 'flag') continue;
|
|
243
|
+
const gates = /** @type {Record<string, string[]|null>} */ (values.gates);
|
|
244
|
+
const current = gates[name];
|
|
245
|
+
const typed = await answer(ui, ui.text({
|
|
246
|
+
message: withHint(`Gate ${name} (blank or "none": no gate)`, SETTING_HINTS[/** @type {keyof typeof SETTING_HINTS} */ (name)]),
|
|
247
|
+
initialValue: current ? current.join(' ') : '',
|
|
248
|
+
}));
|
|
249
|
+
const text = String(typed ?? '').trim();
|
|
250
|
+
const next = text === '' || text === 'none' ? null : shellWords(text);
|
|
251
|
+
if (JSON.stringify(next) !== JSON.stringify(current)) changed.push(`gates.${name}`);
|
|
252
|
+
gates[name] = next;
|
|
253
|
+
}
|
|
254
|
+
const proof = values.proof;
|
|
255
|
+
/** @param {'high'|'link_dirs'|'copy_untracked'} key @param {string} message @param {string} path */
|
|
256
|
+
const list = async (key, message, path) => {
|
|
257
|
+
if (origins.proof[key] === 'flag') return;
|
|
258
|
+
const next = csv(await answer(ui, ui.text({ message: withHint(message, SETTING_HINTS[key]), initialValue: proof[key].join(',') })));
|
|
259
|
+
if (JSON.stringify(next) !== JSON.stringify(proof[key])) changed.push(path);
|
|
260
|
+
proof[key] = next;
|
|
261
|
+
};
|
|
262
|
+
await list('high', 'High-risk paths (comma list of globs)', 'proof.tiers.high.paths');
|
|
263
|
+
if (origins.proof.isolation !== 'flag') {
|
|
264
|
+
const next = await answer(ui, ui.select({
|
|
265
|
+
message: withHint('Proof isolation', SETTING_HINTS.isolation),
|
|
266
|
+
options: [{ value: 'export' }, { value: 'lock' }],
|
|
267
|
+
initialValue: proof.isolation,
|
|
268
|
+
}));
|
|
269
|
+
if (next !== proof.isolation) changed.push('proof.isolation');
|
|
270
|
+
proof.isolation = next;
|
|
271
|
+
}
|
|
272
|
+
await list('link_dirs', 'Export: directories to link', 'proof.export.link_dirs');
|
|
273
|
+
await list('copy_untracked', 'Export: untracked files to copy', 'proof.export.copy_untracked');
|
|
274
|
+
return changed;
|
|
275
|
+
}
|
|
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
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Step 5 when the chain resolved nothing: where is the Jev key? A pasted key is read with hidden
|
|
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).
|
|
346
|
+
* @param {Ui} ui
|
|
347
|
+
* @param {() => Promise<{put: (name: string, value: string, meta: {source: string, exp: number|null}) => Promise<void>}>} getStore
|
|
348
|
+
* @param {{resolve?: OpResolver, pending?: {input: string, error: string}|null}} [opts]
|
|
349
|
+
* @returns {Promise<JevAnswer>}
|
|
350
|
+
*/
|
|
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;
|
|
356
|
+
}
|
|
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' };
|
|
386
|
+
}
|
|
201
387
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Project settings (detected)" block `code-forge init` prints instead of asking for gates and
|
|
3
|
+
* proof settings (B24). Every gate and proof value is listed with where it came from; a blank value
|
|
4
|
+
* says so and why, and the block ends with the one line that says how to fill the blanks later.
|
|
5
|
+
* Agent/JSON mode gets the same facts as `settings` + `blank`. Pure apart from `existsSync` reads
|
|
6
|
+
* that name the file a detected gate came from.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { LINK_DIR_MANIFESTS, copyCandidates, linkDirsFromManifests } from './answers.mjs';
|
|
12
|
+
import { GATE_NAMES } from './flags.mjs';
|
|
13
|
+
|
|
14
|
+
export const SUMMARY_TITLE = 'Project settings (detected)';
|
|
15
|
+
|
|
16
|
+
/** The config key each summarised value is written to. */
|
|
17
|
+
export const SETTING_KEYS = Object.freeze({
|
|
18
|
+
test: 'gates.test',
|
|
19
|
+
lint: 'gates.lint',
|
|
20
|
+
types: 'gates.types',
|
|
21
|
+
format: 'gates.format',
|
|
22
|
+
high: 'proof.tiers.high.paths',
|
|
23
|
+
isolation: 'proof.isolation',
|
|
24
|
+
link_dirs: 'proof.export.link_dirs',
|
|
25
|
+
copy_untracked: 'proof.export.copy_untracked',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** What a blank gate means, in the words the summary prints. */
|
|
29
|
+
const GATE_MISSING = Object.freeze({
|
|
30
|
+
test: 'no test command found',
|
|
31
|
+
lint: 'no linter found',
|
|
32
|
+
types: 'no type checker found',
|
|
33
|
+
format: 'no formatter found',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** The summary's label for each proof value. */
|
|
37
|
+
const PROOF_LABELS = Object.freeze({
|
|
38
|
+
high: 'high-risk paths',
|
|
39
|
+
isolation: 'isolation',
|
|
40
|
+
link_dirs: 'link dirs',
|
|
41
|
+
copy_untracked: 'copy untracked',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** The marker file that decides each non-Node stack in `detectGates`. */
|
|
45
|
+
const STACK_MARKER = Object.freeze({ rust: 'Cargo.toml', python: 'pyproject.toml', go: 'go.mod' });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} Setting
|
|
49
|
+
* @property {unknown} value - what is written to the config.
|
|
50
|
+
* @property {'flag'|'existing'|'detected'|'default'} source
|
|
51
|
+
* @property {string} note - where it came from, or why it is blank, in the summary's words.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {object} SettingsSummary
|
|
56
|
+
* @property {Record<string, Setting>} settings - keyed by config path (`gates.test`, …).
|
|
57
|
+
* @property {string[]} blank - the config paths left blank, in summary order.
|
|
58
|
+
* @property {string[]} lines - the printed block, title first, the "set later" line last.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** @param {string[]} argv @returns {string} */
|
|
62
|
+
function shown(argv) {
|
|
63
|
+
return argv.map((w) => (/[\s"'\\]/.test(w) || w === '' ? JSON.stringify(w) : w)).join(' ');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} cwd @param {string[]} names @returns {string|undefined} */
|
|
67
|
+
function firstPresent(cwd, names) {
|
|
68
|
+
return names.find((n) => existsSync(path.join(cwd, n)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The file a detected gate came from (the evidence `detectGates` read).
|
|
73
|
+
* @param {string} stack @param {string} name @param {string[]} argv @param {string} cwd
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function gateEvidence(stack, name, argv, cwd) {
|
|
77
|
+
if (stack.startsWith('php')) {
|
|
78
|
+
const tool = path.basename(argv[0] ?? '');
|
|
79
|
+
if (tool === 'phpstan') return firstPresent(cwd, ['phpstan.neon', 'phpstan.neon.dist']) ?? 'vendor/bin/phpstan';
|
|
80
|
+
if (tool === 'pint') return firstPresent(cwd, ['pint.json']) ?? 'vendor/bin/pint';
|
|
81
|
+
if (tool === 'php' && argv[1] === 'artisan') return 'artisan';
|
|
82
|
+
if (tool === 'pest') return 'vendor/bin/pest';
|
|
83
|
+
return 'composer.json';
|
|
84
|
+
}
|
|
85
|
+
if (stack === 'node') {
|
|
86
|
+
if (argv[0] === 'npx' && argv[1] === 'tsc') return 'tsconfig.json';
|
|
87
|
+
if (argv[0] === 'npx' && argv[1] === 'prettier') return firstPresent(cwd, ['.prettierrc', '.prettierrc.json', '.prettierrc.js']) ?? 'package.json';
|
|
88
|
+
return 'package.json';
|
|
89
|
+
}
|
|
90
|
+
return STACK_MARKER[/** @type {keyof typeof STACK_MARKER} */ (stack)] ?? 'the project';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {unknown} value @returns {boolean} an empty proof value: null, undefined, '' or [] */
|
|
94
|
+
function isBlankValue(value) {
|
|
95
|
+
return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {'flag'|'existing'|'detected'|'default'} source @param {string} flag @returns {string} */
|
|
99
|
+
function keptFrom(source, flag) {
|
|
100
|
+
return source === 'flag' ? flag : source === 'existing' ? '.code-forge.yml' : 'default';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {import('./answers.mjs').Answers} values
|
|
105
|
+
* @param {import('./answers.mjs').Origins} origins
|
|
106
|
+
* @param {{cwd: string, profile: string, detected: {stack: string}}} ctx
|
|
107
|
+
* @returns {SettingsSummary}
|
|
108
|
+
*/
|
|
109
|
+
export function summarizeSettings(values, origins, ctx) {
|
|
110
|
+
/** @type {Record<string, Setting>} */
|
|
111
|
+
const settings = {};
|
|
112
|
+
/** @type {string[]} */
|
|
113
|
+
const blank = [];
|
|
114
|
+
const lines = [SUMMARY_TITLE];
|
|
115
|
+
|
|
116
|
+
for (const name of GATE_NAMES) {
|
|
117
|
+
const key = SETTING_KEYS[/** @type {keyof typeof SETTING_KEYS} */ (name)];
|
|
118
|
+
const argv = values.gates[/** @type {keyof typeof values.gates} */ (name)];
|
|
119
|
+
const source = origins.gates[name] ?? 'detected';
|
|
120
|
+
let note;
|
|
121
|
+
if (argv === null || argv === undefined || argv.length === 0) {
|
|
122
|
+
if (source === 'flag') note = 'set blank by --gate';
|
|
123
|
+
else if (source === 'existing') note = 'empty in .code-forge.yml';
|
|
124
|
+
else note = GATE_MISSING[/** @type {keyof typeof GATE_MISSING} */ (name)];
|
|
125
|
+
blank.push(key);
|
|
126
|
+
lines.push(` ${name}: blank — ${note}`);
|
|
127
|
+
} else {
|
|
128
|
+
note = source === 'detected' ? gateEvidence(ctx.detected.stack, name, argv, ctx.cwd) : keptFrom(source, '--gate');
|
|
129
|
+
lines.push(` ${name}: ${shown(argv)} (from ${note})`);
|
|
130
|
+
}
|
|
131
|
+
settings[key] = { value: argv, source, note };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const found = new Map(linkDirsFromManifests(ctx.cwd).map((e) => [e.dir, e.manifests]));
|
|
135
|
+
for (const pk of /** @type {Array<keyof typeof PROOF_LABELS>} */ (Object.keys(PROOF_LABELS))) {
|
|
136
|
+
const key = SETTING_KEYS[pk];
|
|
137
|
+
const value = values.proof[pk];
|
|
138
|
+
const source = origins.proof[pk] ?? 'default';
|
|
139
|
+
const label = PROOF_LABELS[pk];
|
|
140
|
+
let note;
|
|
141
|
+
if (isBlankValue(value)) {
|
|
142
|
+
if (source === 'flag') note = 'set blank by --proof';
|
|
143
|
+
else if (source === 'existing') note = 'empty in .code-forge.yml';
|
|
144
|
+
else if (pk === 'high') note = 'none set (all files light tier by path)';
|
|
145
|
+
else if (pk === 'link_dirs') note = 'no dependency manifest found';
|
|
146
|
+
else {
|
|
147
|
+
const looked = copyCandidates(ctx.profile);
|
|
148
|
+
note = looked.length > 0 ? `none found (looked for ${looked.join(', ')})` : 'none for this stack';
|
|
149
|
+
}
|
|
150
|
+
blank.push(key);
|
|
151
|
+
lines.push(` ${label}: blank — ${note}`);
|
|
152
|
+
} else {
|
|
153
|
+
const list = Array.isArray(value) ? value : [String(value)];
|
|
154
|
+
// copied files are evidence themselves; everything else names where it came from
|
|
155
|
+
const foundOnDisk = source === 'detected' && pk === 'copy_untracked';
|
|
156
|
+
if (foundOnDisk) note = 'found in the project';
|
|
157
|
+
else if (source !== 'detected') note = keptFrom(source, '--proof');
|
|
158
|
+
else note = [...new Set(list.flatMap((d) => found.get(d) ?? LINK_DIR_MANIFESTS[/** @type {keyof typeof LINK_DIR_MANIFESTS} */ (d)] ?? []))].join(', ');
|
|
159
|
+
lines.push(` ${label}: ${list.join(', ')} (${foundOnDisk ? note : `from ${note}`})`);
|
|
160
|
+
}
|
|
161
|
+
settings[key] = { value, source, note };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
lines.push(blank.length > 0
|
|
165
|
+
? `Set the blanks later: edit .code-forge.yml (${blank.join(', ')}), then run \`code-forge validate\`.`
|
|
166
|
+
: 'Change any of these later: edit .code-forge.yml, then run `code-forge validate`.');
|
|
167
|
+
return { settings, blank, lines };
|
|
168
|
+
}
|