@codedology/code-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
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. `ui` has `@clack/prompts`' shape (`select`,
|
|
4
|
+
* `multiselect`, `text`, `confirm`, `password`, `isCancel`), so the real module is passed in
|
|
5
|
+
* production and a scripted fake in tests. Step 5's key question lives in {@link askJevSource}
|
|
6
|
+
* because it is asked only when the chain resolves nothing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isOpRef } from '../../keys/onepassword.mjs';
|
|
10
|
+
import { HARNESSES } from '../harnesses.mjs';
|
|
11
|
+
import { effectiveProvider, judgeCollision, proposeJudge } from './answers.mjs';
|
|
12
|
+
import { ENGINE_CHOICES, GATE_NAMES, PROVIDERS, parseLevel, shellWords, UsageError } from './flags.mjs';
|
|
13
|
+
|
|
14
|
+
export class CancelledError extends Error {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} Ui
|
|
18
|
+
* @property {(o: {message: string, options: Array<{value: any, label?: string}>, initialValue?: any}) => Promise<any>} select
|
|
19
|
+
* @property {(o: {message: string, options: Array<{value: any, label?: string}>, initialValues?: any[], required?: boolean}) => Promise<any>} multiselect
|
|
20
|
+
* @property {(o: {message: string, initialValue?: string, placeholder?: string}) => Promise<any>} text
|
|
21
|
+
* @property {(o: {message: string, initialValue?: boolean}) => Promise<any>} confirm
|
|
22
|
+
* @property {(o: {message: string}) => Promise<any>} password
|
|
23
|
+
* @property {(v: unknown) => boolean} isCancel
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @param {Ui} ui @param {Promise<any>} pending @returns {Promise<any>} */
|
|
27
|
+
async function answer(ui, pending) {
|
|
28
|
+
const value = await pending;
|
|
29
|
+
if (ui.isCancel(value)) throw new CancelledError('cancelled — nothing written');
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A comma list split only at brace depth 0, so a glob like `app/**\/*.{php,vue}` stays one item
|
|
35
|
+
* and Enter on a pre-filled list gives the list back unchanged.
|
|
36
|
+
* @param {string} text @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
function csv(text) {
|
|
39
|
+
const items = [];
|
|
40
|
+
let current = '';
|
|
41
|
+
let depth = 0;
|
|
42
|
+
for (const ch of String(text ?? '')) {
|
|
43
|
+
if (ch === '{') depth += 1;
|
|
44
|
+
else if (ch === '}' && depth > 0) depth -= 1;
|
|
45
|
+
if (ch === ',' && depth === 0) {
|
|
46
|
+
items.push(current);
|
|
47
|
+
current = '';
|
|
48
|
+
} else {
|
|
49
|
+
current += ch;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
items.push(current);
|
|
53
|
+
return items.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `model[:effort][@provider]` text a level is pre-filled with — the exact form `parseLevel`
|
|
58
|
+
* reads back, so Enter keeps the level unchanged (provider override included).
|
|
59
|
+
* @param {{model: string, effort?: string, provider?: string}} level @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function levelText(level) {
|
|
62
|
+
return `${level.model}${level.effort ? `:${level.effort}` : ''}${level.provider ? `@${level.provider}` : ''}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Asks every answer whose source is not `flag`, in step order. Mutates and returns `values`.
|
|
67
|
+
* @param {import('./answers.mjs').Answers} values
|
|
68
|
+
* @param {Record<string, string>} sources
|
|
69
|
+
* @param {import('./answers.mjs').WizardContext} ctx
|
|
70
|
+
* @param {Ui} ui
|
|
71
|
+
* @returns {Promise<import('./answers.mjs').Answers>}
|
|
72
|
+
*/
|
|
73
|
+
export async function askAnswers(values, sources, ctx, ui) {
|
|
74
|
+
const open = (/** @type {string} */ key) => sources[key] !== 'flag';
|
|
75
|
+
|
|
76
|
+
if (open('tools')) {
|
|
77
|
+
values.tools = await answer(ui, ui.select({
|
|
78
|
+
message: 'Install the recommended tools, or use only the current harness?',
|
|
79
|
+
options: [{ value: 'current', label: 'current harness' }, { value: 'recommended', label: 'recommended tools' }],
|
|
80
|
+
initialValue: values.tools,
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
if (open('harnesses')) {
|
|
84
|
+
values.harnesses = await answer(ui, ui.multiselect({
|
|
85
|
+
message: 'Install the skill into:',
|
|
86
|
+
options: HARNESSES.map((h) => ({ value: h.id, label: `${h.label}${ctx.detectedHarnesses.includes(h.id) ? ' (detected)' : ''}` })),
|
|
87
|
+
initialValues: values.harnesses,
|
|
88
|
+
required: false,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
if (open('scope')) {
|
|
92
|
+
values.scope = await answer(ui, ui.select({ message: 'Scope', options: [{ value: 'project' }, { value: 'global' }], initialValue: values.scope }));
|
|
93
|
+
}
|
|
94
|
+
if (open('method')) {
|
|
95
|
+
values.method = await answer(ui, ui.select({ message: 'Method', options: [{ value: 'symlink' }, { value: 'copy' }], initialValue: values.method }));
|
|
96
|
+
}
|
|
97
|
+
if (open('provider')) {
|
|
98
|
+
values.provider = await answer(ui, ui.select({ message: 'Default provider', options: PROVIDERS.map((p) => ({ value: p })), initialValue: values.provider }));
|
|
99
|
+
}
|
|
100
|
+
if (open('levels')) {
|
|
101
|
+
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 }));
|
|
103
|
+
if (!keep) {
|
|
104
|
+
for (const level of ['L0', 'L1', 'L2', 'L3']) {
|
|
105
|
+
const shown = levelText(values.levels[level]);
|
|
106
|
+
const typed = await answer(ui, ui.text({ message: `${level} model[:effort][@provider]`, initialValue: shown }));
|
|
107
|
+
// Enter (unchanged text) keeps the level object as it is, `fallback` included
|
|
108
|
+
if (String(typed).trim() !== shown) values.levels[level] = parseLevel(`${level}=${String(typed).trim()}`).spec;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
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 }));
|
|
114
|
+
}
|
|
115
|
+
if (open('multimodel')) {
|
|
116
|
+
values.multimodel = await answer(ui, ui.confirm({ message: 'Multimodel review (consensus)?', initialValue: values.multimodel }));
|
|
117
|
+
}
|
|
118
|
+
if (values.multimodel && open('second_provider')) {
|
|
119
|
+
// R5: the second reviewer must differ from the provider L2 actually runs on, not the default one
|
|
120
|
+
const reviewer1 = effectiveProvider(values, 'L2');
|
|
121
|
+
const others = PROVIDERS.filter((p) => p !== reviewer1);
|
|
122
|
+
values.second_provider = await answer(ui, ui.select({
|
|
123
|
+
message: `Second provider (must differ from the effective L2 provider, ${reviewer1})`,
|
|
124
|
+
options: others.map((p) => ({ value: p })),
|
|
125
|
+
initialValue: others.includes(values.second_provider) ? values.second_provider : others[0],
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
// Consensus judge (§4.4, validator rule 7): the effective L3 must share a provider with neither
|
|
129
|
+
// reviewer. Proposes the third provider's default L3; asked again until the answer differs. Not
|
|
130
|
+
// asked when a flag-supplied second provider equals the effective L2 — `runInit` refuses that.
|
|
131
|
+
const reviewersDiffer = values.second_provider !== effectiveProvider(values, 'L2');
|
|
132
|
+
for (let collision = reviewersDiffer ? judgeCollision(values) : null; collision; collision = judgeCollision(values)) {
|
|
133
|
+
const proposal = collision.third ? levelText(proposeJudge(collision.third)) : '';
|
|
134
|
+
const typed = await answer(ui, ui.text({
|
|
135
|
+
message: `L3 judge — must come from a third provider (reviewers: ${collision.reviewers.join(', ')}); model[:effort][@provider]`,
|
|
136
|
+
initialValue: proposal,
|
|
137
|
+
}));
|
|
138
|
+
values.levels.L3 = parseLevel(`L3=${String(typed).trim()}`).spec;
|
|
139
|
+
}
|
|
140
|
+
// `subprocess` is never offered (R2); an existing `engine: subprocess` is kept, not asked.
|
|
141
|
+
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 }));
|
|
143
|
+
}
|
|
144
|
+
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) }));
|
|
146
|
+
values.solo_project = /^[1-9]\d{0,9}$/.test(String(typed).trim()) ? Number(String(typed).trim()) : null;
|
|
147
|
+
}
|
|
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
|
+
return values;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Step 5 when the chain resolved nothing: where is the Jev key? A pasted key is read with hidden
|
|
167
|
+
* input and handed straight to `store`; it is never returned, echoed or logged.
|
|
168
|
+
* @param {Ui} ui
|
|
169
|
+
* @param {() => Promise<{put: (name: string, value: string, meta: {source: string, exp: number|null}) => Promise<void>}>} getStore
|
|
170
|
+
* @returns {Promise<{ref: string|null, source: string}>}
|
|
171
|
+
*/
|
|
172
|
+
export async function askJevSource(ui, getStore) {
|
|
173
|
+
const choice = await answer(ui, ui.select({
|
|
174
|
+
message: 'Where is the Jev key?',
|
|
175
|
+
options: [
|
|
176
|
+
{ value: 'op', label: '1Password reference' },
|
|
177
|
+
{ value: 'env', label: 'environment variable name' },
|
|
178
|
+
{ value: 'paste', label: 'paste now (hidden)' },
|
|
179
|
+
{ value: 'skip', label: 'skip (rules-only System 1)' },
|
|
180
|
+
],
|
|
181
|
+
initialValue: 'skip',
|
|
182
|
+
}));
|
|
183
|
+
if (choice === 'op') {
|
|
184
|
+
const ref = String(await answer(ui, ui.text({ message: '1Password reference (op://vault/item/field)' }))).trim();
|
|
185
|
+
if (!isOpRef(ref)) throw new UsageError('not an op://vault/item/field reference');
|
|
186
|
+
return { ref, source: 'op' };
|
|
187
|
+
}
|
|
188
|
+
if (choice === 'env') {
|
|
189
|
+
const name = String(await answer(ui, ui.text({ message: 'Environment variable name' }))).trim();
|
|
190
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new UsageError('not an environment variable name');
|
|
191
|
+
return { ref: `env:${name}`, source: 'env' };
|
|
192
|
+
}
|
|
193
|
+
if (choice === 'paste') {
|
|
194
|
+
const typed = await answer(ui, ui.password({ message: 'Jev key (input hidden)' }));
|
|
195
|
+
if (typeof typed !== 'string' || typed.length === 0) return { ref: null, source: 'none' };
|
|
196
|
+
const store = await getStore();
|
|
197
|
+
await store.put('jev', typed, { source: 'user', exp: null });
|
|
198
|
+
return { ref: 'user', source: 'keychain' };
|
|
199
|
+
}
|
|
200
|
+
return { ref: null, source: 'none' };
|
|
201
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants shared by the keychain backends. No imports on purpose: `security-cli.mjs` must stay
|
|
3
|
+
* loadable when `@napi-rs/keyring` (and anything that touches it) cannot load.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Keychain service name; the account is the key name. */
|
|
7
|
+
export const SERVICE = 'code-forge';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-resort backend: one file per key under `~/.code-forge/store/`, file mode 0600, directory
|
|
3
|
+
* mode 0700 (plan §1.2, §8.1). Selecting it makes `doctor` WARN (see `../store.mjs`).
|
|
4
|
+
*
|
|
5
|
+
* A secret is never written into an existing file: `writePrivateFile` creates a NEW temp file
|
|
6
|
+
* with `O_CREAT|O_EXCL` at mode 0600 (so a pre-planted file or symlink is never opened), writes,
|
|
7
|
+
* then renames it over the target. A key file with a loose mode, or a symlink planted at the
|
|
8
|
+
* key's path, is replaced, never written through. The rename is also what makes the write atomic.
|
|
9
|
+
*
|
|
10
|
+
* Values are stored verbatim; on read one trailing newline (a hand-edited file) is dropped and
|
|
11
|
+
* an empty file reads as absent.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {import('../store.mjs').Backend} Backend
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
export const FILE_MODE = 0o600;
|
|
21
|
+
export const DIR_MODE = 0o700;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write `content` to `file` so it only ever exists at mode 0600: temp file created exclusively
|
|
25
|
+
* at 0600 in the same directory, then renamed over the target.
|
|
26
|
+
* @param {string} file
|
|
27
|
+
* @param {string} content
|
|
28
|
+
*/
|
|
29
|
+
export async function writePrivateFile(file, content) {
|
|
30
|
+
const dir = path.dirname(file);
|
|
31
|
+
await mkdir(dir, { recursive: true, mode: DIR_MODE });
|
|
32
|
+
await chmod(dir, DIR_MODE);
|
|
33
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
34
|
+
const handle = await open(tmp, 'wx', FILE_MODE);
|
|
35
|
+
try {
|
|
36
|
+
// The umask can only remove bits from 0600, never add them; this makes the mode exact.
|
|
37
|
+
await handle.chmod(FILE_MODE);
|
|
38
|
+
await handle.writeFile(content, 'utf8');
|
|
39
|
+
await handle.sync();
|
|
40
|
+
} catch (err) {
|
|
41
|
+
await handle.close();
|
|
42
|
+
await rm(tmp, { force: true });
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
await handle.close();
|
|
46
|
+
await rename(tmp, file);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} opts
|
|
51
|
+
* @param {string} opts.dir - e.g. `~/.code-forge/store`.
|
|
52
|
+
* @returns {Backend}
|
|
53
|
+
*/
|
|
54
|
+
export function createFileBackend({ dir }) {
|
|
55
|
+
const root = path.resolve(dir);
|
|
56
|
+
/** @param {string} name */
|
|
57
|
+
const fileFor = (name) => {
|
|
58
|
+
const file = path.resolve(root, `${name}.key`);
|
|
59
|
+
if (typeof name !== 'string' || name.length === 0 || /[/\\]/.test(name) || name.startsWith('.') || path.dirname(file) !== root) {
|
|
60
|
+
throw new TypeError('file backend: key name must be a plain file name inside the store');
|
|
61
|
+
}
|
|
62
|
+
return file;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
name: 'file',
|
|
67
|
+
writable: true,
|
|
68
|
+
async available() {
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
async get(name) {
|
|
72
|
+
const file = fileFor(name);
|
|
73
|
+
try {
|
|
74
|
+
const raw = (await readFile(file, 'utf8')).replace(/\n$/, '');
|
|
75
|
+
return raw.length > 0 ? raw : null;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err?.code === 'ENOENT') {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async set(name, value) {
|
|
84
|
+
await writePrivateFile(fileFor(name), value);
|
|
85
|
+
},
|
|
86
|
+
async delete(name) {
|
|
87
|
+
const file = fileFor(name);
|
|
88
|
+
try {
|
|
89
|
+
await rm(file);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err?.code === 'ENOENT') {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS keychain backend over `@napi-rs/keyring` (an optionalDependency, plan §1.1 / §8.1).
|
|
3
|
+
*
|
|
4
|
+
* The module is loaded dynamically, and `available()` proves the keychain is USABLE, not only
|
|
5
|
+
* importable: it reads a probe entry that never exists. A binding that loads on a machine with
|
|
6
|
+
* no usable store (headless Linux without Secret Service, a denied keychain, a CI container)
|
|
7
|
+
* throws there, `available()` resolves `false`, and the store falls back to the macOS `security`
|
|
8
|
+
* CLI, then the 0600 file (see `../store.mjs`).
|
|
9
|
+
*
|
|
10
|
+
* Every entry lives under service `code-forge`, account = key name — the same pair the
|
|
11
|
+
* `security` CLI backend reads.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {import('../store.mjs').Backend} Backend
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { SERVICE } from './constants.mjs';
|
|
17
|
+
|
|
18
|
+
export { SERVICE };
|
|
19
|
+
|
|
20
|
+
/** An account name no key can have (key names start with a lowercase letter). */
|
|
21
|
+
export const PROBE_ACCOUNT = '__code-forge-probe__';
|
|
22
|
+
|
|
23
|
+
/** @returns {Promise<any>} the `@napi-rs/keyring` module namespace. */
|
|
24
|
+
async function importKeyring() {
|
|
25
|
+
return import('@napi-rs/keyring');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {() => Promise<any>} [opts.loadModule] - injected in tests; never the real keyring there.
|
|
31
|
+
* @param {string} [opts.service]
|
|
32
|
+
* @returns {Backend}
|
|
33
|
+
*/
|
|
34
|
+
export function createKeychainBackend({ loadModule = importKeyring, service = SERVICE } = {}) {
|
|
35
|
+
/** @type {Promise<any> | undefined} */
|
|
36
|
+
let loading;
|
|
37
|
+
const load = () => {
|
|
38
|
+
loading ??= loadModule().then(
|
|
39
|
+
(mod) => {
|
|
40
|
+
const Entry = mod?.Entry ?? mod?.default?.Entry ?? null;
|
|
41
|
+
if (!Entry) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
new Entry(service, PROBE_ACCOUNT).getPassword();
|
|
46
|
+
return Entry;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
() => null,
|
|
52
|
+
);
|
|
53
|
+
return loading;
|
|
54
|
+
};
|
|
55
|
+
/** @param {string} name */
|
|
56
|
+
const entry = async (name) => {
|
|
57
|
+
const Entry = await load();
|
|
58
|
+
if (!Entry) {
|
|
59
|
+
throw new Error('keychain: @napi-rs/keyring is not available');
|
|
60
|
+
}
|
|
61
|
+
return new Entry(service, name);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: 'keychain',
|
|
66
|
+
writable: true,
|
|
67
|
+
async available() {
|
|
68
|
+
return (await load()) !== null;
|
|
69
|
+
},
|
|
70
|
+
async get(name) {
|
|
71
|
+
const value = (await entry(name)).getPassword();
|
|
72
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
73
|
+
},
|
|
74
|
+
async set(name, value) {
|
|
75
|
+
(await entry(name)).setPassword(value);
|
|
76
|
+
},
|
|
77
|
+
async delete(name) {
|
|
78
|
+
return Boolean((await entry(name)).deletePassword());
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS `security` CLI backend — the fallback when `@napi-rs/keyring` cannot load (plan §8.1).
|
|
3
|
+
*
|
|
4
|
+
* Read, write and delete. A write never puts the secret in argv (where any process listing
|
|
5
|
+
* would show it): `add-generic-password … -w` with `-w` as the LAST option and no value makes
|
|
6
|
+
* `security` prompt for the password, and since `exec` spawns the child in a new session with
|
|
7
|
+
* no controlling terminal, the prompt reads from stdin. The value is piped there twice (password,
|
|
8
|
+
* then the "retype" confirmation), one per line, via B0.1 `exec`'s `input` (B2.1). A value that
|
|
9
|
+
* contains a line break cannot be sent that way and is refused before anything spawns. After the
|
|
10
|
+
* write the item is read back; a stored value that differs (truncated, empty) is deleted and
|
|
11
|
+
* the write fails.
|
|
12
|
+
*
|
|
13
|
+
* It imports nothing from `keychain.mjs`, so it loads even when the keyring binding cannot.
|
|
14
|
+
* The binary is called by absolute path so a `security` earlier on PATH cannot intercept it
|
|
15
|
+
* (`bin` exists only so tests can point it at a fake).
|
|
16
|
+
*
|
|
17
|
+
* Values: code-forge stores printable JSON text, which `-w` prints as-is. An item holding
|
|
18
|
+
* non-printable bytes is printed by `security` as hex; such items are not code-forge's and are
|
|
19
|
+
* returned as that text (documented limitation). An empty password reads as absent.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {import('../store.mjs').Backend} Backend
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { exec as realExec } from '../../util/exec.mjs';
|
|
25
|
+
import { SERVICE } from './constants.mjs';
|
|
26
|
+
|
|
27
|
+
export const SECURITY_BIN = '/usr/bin/security';
|
|
28
|
+
|
|
29
|
+
/** `security` exits 44 when no matching item exists. */
|
|
30
|
+
export const NOT_FOUND_EXIT = 44;
|
|
31
|
+
|
|
32
|
+
const TIMEOUT_MS = 10_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} [opts]
|
|
36
|
+
* @param {typeof realExec} [opts.exec]
|
|
37
|
+
* @param {string} [opts.platform]
|
|
38
|
+
* @param {string} [opts.service]
|
|
39
|
+
* @param {string} [opts.bin] - path of the `security` binary (default {@link SECURITY_BIN}).
|
|
40
|
+
* @returns {Backend}
|
|
41
|
+
*/
|
|
42
|
+
export function createSecurityCliBackend({
|
|
43
|
+
exec = realExec,
|
|
44
|
+
platform = process.platform,
|
|
45
|
+
service = SERVICE,
|
|
46
|
+
bin = SECURITY_BIN,
|
|
47
|
+
} = {}) {
|
|
48
|
+
/** @type {Backend} */
|
|
49
|
+
const backend = {
|
|
50
|
+
name: 'security-cli',
|
|
51
|
+
writable: true,
|
|
52
|
+
async available() {
|
|
53
|
+
if (platform !== 'darwin') {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
const res = await exec([bin, 'help'], { timeoutMs: TIMEOUT_MS, okExitCodes: [0, 1] });
|
|
57
|
+
return res.result === 'ok';
|
|
58
|
+
},
|
|
59
|
+
async get(name) {
|
|
60
|
+
const res = await exec([bin, 'find-generic-password', '-s', service, '-a', name, '-w'], {
|
|
61
|
+
timeoutMs: TIMEOUT_MS,
|
|
62
|
+
okExitCodes: [0, NOT_FOUND_EXIT],
|
|
63
|
+
});
|
|
64
|
+
if (res.result !== 'ok') {
|
|
65
|
+
throw new Error(`security-cli: find-generic-password failed (exit ${res.code})`);
|
|
66
|
+
}
|
|
67
|
+
if (res.code === NOT_FOUND_EXIT) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const value = res.stdout.replace(/\n$/, '');
|
|
71
|
+
return value.length > 0 ? value : null;
|
|
72
|
+
},
|
|
73
|
+
async set(name, value) {
|
|
74
|
+
if (/[\r\n]/.test(value)) {
|
|
75
|
+
throw new TypeError('security-cli: a value containing a line break cannot be written through stdin');
|
|
76
|
+
}
|
|
77
|
+
// `-U` updates an existing item; `-w` must stay last so `security` reads the value from stdin.
|
|
78
|
+
const res = await exec([bin, 'add-generic-password', '-U', '-s', service, '-a', name, '-w'], {
|
|
79
|
+
timeoutMs: TIMEOUT_MS,
|
|
80
|
+
input: `${value}\n${value}\n`,
|
|
81
|
+
});
|
|
82
|
+
if (res.result !== 'ok') {
|
|
83
|
+
throw new Error(`security-cli: add-generic-password failed (exit ${res.code})`);
|
|
84
|
+
}
|
|
85
|
+
// A clean exit is not proof: the prompt (getpass/readpassphrase) can truncate long input
|
|
86
|
+
// or store an empty value and still exit 0. Read the item back and compare.
|
|
87
|
+
if ((await backend.get(name)) !== value) {
|
|
88
|
+
// Best effort: drop the bad item so a truncated secret is never served later.
|
|
89
|
+
await backend.delete(name).catch(() => false);
|
|
90
|
+
throw new Error('security-cli: add-generic-password stored value mismatch');
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
async delete(name) {
|
|
94
|
+
const res = await exec([bin, 'delete-generic-password', '-s', service, '-a', name], {
|
|
95
|
+
timeoutMs: TIMEOUT_MS,
|
|
96
|
+
okExitCodes: [0, NOT_FOUND_EXIT],
|
|
97
|
+
});
|
|
98
|
+
if (res.result !== 'ok') {
|
|
99
|
+
throw new Error(`security-cli: delete-generic-password failed (exit ${res.code})`);
|
|
100
|
+
}
|
|
101
|
+
return res.code === 0;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
return backend;
|
|
105
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1Password source: `op read <op://ref>` (plan §8.1). Retried exactly once, and only when the
|
|
3
|
+
* FIRST call timed out (a cold `op` waiting on biometric unlock is the common case); any other
|
|
4
|
+
* failure is final. The value is read from stdout and never logged; a failure message carries
|
|
5
|
+
* the exit code only, never stdout/stderr.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { exec as realExec } from '../util/exec.mjs';
|
|
9
|
+
|
|
10
|
+
export const OP_TIMEOUT_MS = 20_000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} ref
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
export function isOpRef(ref) {
|
|
17
|
+
// Three non-empty segments; spaces are allowed (vault/item names have them, and argv needs no
|
|
18
|
+
// quoting). No line breaks.
|
|
19
|
+
return typeof ref === 'string' && /^op:\/\/[^/\r\n]+\/[^/\r\n]+\/[^\r\n]+$/.test(ref);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} ref - an `op://vault/item/field` reference.
|
|
24
|
+
* @param {object} [opts]
|
|
25
|
+
* @param {typeof realExec} [opts.exec]
|
|
26
|
+
* @param {number} [opts.timeoutMs]
|
|
27
|
+
* @returns {Promise<{value: string|null, attempts: number, error?: string}>}
|
|
28
|
+
*/
|
|
29
|
+
export async function opRead(ref, { exec = realExec, timeoutMs = OP_TIMEOUT_MS } = {}) {
|
|
30
|
+
if (!isOpRef(ref)) {
|
|
31
|
+
throw new TypeError('opRead: reference must look like op://vault/item/field');
|
|
32
|
+
}
|
|
33
|
+
let attempts = 0;
|
|
34
|
+
let res;
|
|
35
|
+
do {
|
|
36
|
+
attempts += 1;
|
|
37
|
+
res = await exec(['op', 'read', ref], { timeoutMs });
|
|
38
|
+
} while (res.result !== 'ok' && res.timedOut && attempts === 1);
|
|
39
|
+
|
|
40
|
+
if (res.result !== 'ok') {
|
|
41
|
+
const why = res.timedOut ? 'timed out' : res.code !== null ? `exit ${res.code}` : res.signal ? `signal ${res.signal}` : 'failed';
|
|
42
|
+
return { value: null, attempts, error: `op read failed (${why})` };
|
|
43
|
+
}
|
|
44
|
+
const value = res.stdout.replace(/\r?\n$/, '');
|
|
45
|
+
return value.length > 0 ? { value, attempts } : { value: null, attempts, error: 'op read returned nothing' };
|
|
46
|
+
}
|