dorfl 0.7.0 → 0.8.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/dist/advance-drivers.d.ts.map +1 -1
- package/dist/advance-drivers.js +11 -2
- package/dist/advance-drivers.js.map +1 -1
- package/dist/bootstrap-forward.d.ts +224 -0
- package/dist/bootstrap-forward.d.ts.map +1 -0
- package/dist/bootstrap-forward.js +247 -0
- package/dist/bootstrap-forward.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +50 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +42 -0
- package/dist/config.js.map +1 -1
- package/dist/cwd-section.d.ts.map +1 -1
- package/dist/cwd-section.js +11 -2
- package/dist/cwd-section.js.map +1 -1
- package/dist/env-config.d.ts.map +1 -1
- package/dist/env-config.js +8 -0
- package/dist/env-config.js.map +1 -1
- package/dist/github.d.ts +32 -1
- package/dist/github.d.ts.map +1 -1
- package/dist/github.js +115 -0
- package/dist/github.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/install-ci-core.d.ts.map +1 -1
- package/dist/install-ci-core.js +20 -48
- package/dist/install-ci-core.js.map +1 -1
- package/dist/integration-core.d.ts.map +1 -1
- package/dist/integration-core.js +9 -0
- package/dist/integration-core.js.map +1 -1
- package/dist/integrator.d.ts +48 -0
- package/dist/integrator.d.ts.map +1 -1
- package/dist/integrator.js +12 -0
- package/dist/integrator.js.map +1 -1
- package/dist/item-lock.d.ts +26 -0
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +39 -0
- package/dist/item-lock.js.map +1 -1
- package/dist/repo-config.d.ts +1 -1
- package/dist/repo-config.d.ts.map +1 -1
- package/dist/repo-config.js +20 -1
- package/dist/repo-config.js.map +1 -1
- package/dist/scan.d.ts +24 -2
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +28 -4
- package/dist/scan.js.map +1 -1
- package/dist/skills/setup/SKILL.md +3 -0
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +124 -65
- package/dist/tasking.js.map +1 -1
- package/package.json +1 -1
- package/src/advance-drivers.ts +17 -8
- package/src/bootstrap-forward.ts +359 -0
- package/src/cli.ts +21 -1
- package/src/config.ts +75 -0
- package/src/cwd-section.ts +21 -2
- package/src/env-config.ts +8 -0
- package/src/github.ts +149 -0
- package/src/index.ts +24 -0
- package/src/install-ci-core.ts +20 -48
- package/src/integration-core.ts +10 -0
- package/src/integrator.ts +62 -0
- package/src/item-lock.ts +50 -0
- package/src/repo-config.ts +20 -0
- package/src/scan.ts +32 -8
- package/src/tasking.ts +154 -68
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import {spawnSync} from 'node:child_process';
|
|
2
|
+
import {brand} from './brand.js';
|
|
3
|
+
import {
|
|
4
|
+
resolveRepoConfigPath,
|
|
5
|
+
loadRepoConfig,
|
|
6
|
+
REPO_CONFIG_FILENAME,
|
|
7
|
+
} from './repo-config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bootstrap self-forward (spec `dorfl-self-version-pinning-and-bootstrap-forward`
|
|
11
|
+
* §2/§4; task `dorfl-bootstrap-self-forward`, stories 1/4/5).
|
|
12
|
+
*
|
|
13
|
+
* The globally-installed `dorfl` is a thin BOOTSTRAP. On startup, BEFORE any
|
|
14
|
+
* command dispatch, it reads the nearest repo `dorfl.json`; if that repo declares
|
|
15
|
+
* a `dorflCmd` (and we are not already the forwarded process), it `exec`s that
|
|
16
|
+
* command with the ORIGINAL argv + env inherited, after a one-line STDERR notice,
|
|
17
|
+
* and returns the child's exit code (transparent passthrough).
|
|
18
|
+
*
|
|
19
|
+
* This module is the INJECTABLE seam: {@link decideForward} makes the pure
|
|
20
|
+
* decision (run-self / forward / error / opt-out) from argv + env + a config
|
|
21
|
+
* reader, and {@link performForward} does the exec through an injected spawn
|
|
22
|
+
* function — so the whole path is unit-testable WITHOUT re-execing a real second
|
|
23
|
+
* dorfl or hitting the network (mirroring how git/agent seams are injected in
|
|
24
|
+
* tests). {@link maybeForward} wires the two for the CLI entry point.
|
|
25
|
+
*
|
|
26
|
+
* Load-bearing safety fact (spec §2, task): the forward decision fires ONCE, at
|
|
27
|
+
* bare-`dorfl` startup, in the CHECKOUT ROOT — it is NOT recursive. The gate
|
|
28
|
+
* worktree that runs `prepare`/`verify` is created + prepared by the
|
|
29
|
+
* ALREADY-RUNNING dorfl, which runs the repo's commands via
|
|
30
|
+
* `spawn('bash', ['-c', cmd], {cwd: worktreeDir})` (`prepare.ts`/`verify.ts`) —
|
|
31
|
+
* it never launches a new `dorfl`, so a fresh worktree's empty `node_modules`
|
|
32
|
+
* never re-triggers the forward. Therefore a declared-but-ABSENT `dorflCmd` at
|
|
33
|
+
* the top level is almost always a MISCONFIGURATION (the repo's install did not
|
|
34
|
+
* run), and we FAIL LOUD (decision 2026-07-21, option B) rather than silently
|
|
35
|
+
* degrade to the global and run the WRONG version.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The env marker set on the FORWARDED child so it does not forward AGAIN forever
|
|
40
|
+
* (loop-safe). The forwarded dorfl reads the SAME `dorfl.json` (same `dorflCmd`);
|
|
41
|
+
* seeing this marker it runs in-process instead of re-execing. `DORFL_` prefixed
|
|
42
|
+
* from the single brand identity so a rename flips it in lockstep.
|
|
43
|
+
*/
|
|
44
|
+
export const FORWARDED_ENV_MARKER = `${brand.envPrefix}FORWARDED`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The opt-out env var (spec §4). `DORFL_NO_FORWARD=1` (or any truthy value)
|
|
48
|
+
* DISABLES forwarding AND suppresses the notice — the bootstrap/global runs
|
|
49
|
+
* as-is, so a user can always reach the bootstrap dorfl directly.
|
|
50
|
+
*/
|
|
51
|
+
export const NO_FORWARD_ENV = `${brand.envPrefix}NO_FORWARD`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The opt-out CLI flag (spec §4). `--no-forward` DISABLES forwarding AND
|
|
55
|
+
* suppresses the notice, exactly like {@link NO_FORWARD_ENV}. It is honoured
|
|
56
|
+
* BEFORE command dispatch and STRIPPED from the argv commander parses (commander
|
|
57
|
+
* never sees it — it is a bootstrap-level flag, not a per-command option).
|
|
58
|
+
*/
|
|
59
|
+
export const NO_FORWARD_FLAG = '--no-forward';
|
|
60
|
+
|
|
61
|
+
/** The pure forward DECISION — what the bootstrap should do at startup. */
|
|
62
|
+
export type ForwardDecision =
|
|
63
|
+
| {kind: 'run-self'; reason: 'no-cmd' | 'already-forwarded' | 'opted-out'}
|
|
64
|
+
| {kind: 'forward'; cmd: string; configPath: string}
|
|
65
|
+
| {kind: 'error'; message: string};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the nearest repo's declared `dorflCmd` for `cwd`. Returns the resolved
|
|
69
|
+
* command (trimmed / empty⇒undefined via the config layer) and the `dorfl.json`
|
|
70
|
+
* path it was read from. A missing/config-less repo ⇒ `{cmd: undefined}`. The
|
|
71
|
+
* default reader; tests inject a stub instead of touching the filesystem.
|
|
72
|
+
*/
|
|
73
|
+
export interface RepoCmdReader {
|
|
74
|
+
(cwd: string): {cmd: string | undefined; configPath: string};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The default {@link RepoCmdReader}: read the cwd's `dorfl.json` `dorflCmd`. */
|
|
78
|
+
export function defaultRepoCmdReader(cwd: string): {
|
|
79
|
+
cmd: string | undefined;
|
|
80
|
+
configPath: string;
|
|
81
|
+
} {
|
|
82
|
+
const configPath = resolveRepoConfigPath(cwd);
|
|
83
|
+
const loaded = loadRepoConfig(cwd);
|
|
84
|
+
// `loadRepoConfig` carries `dorflCmd` verbatim from the file; the config-layer
|
|
85
|
+
// trim/empty⇒unset normalisation runs at full resolution, but for the bootstrap
|
|
86
|
+
// decision we only need the raw presence + trimmed value, so normalise here too.
|
|
87
|
+
const raw = loaded.config.dorflCmd;
|
|
88
|
+
const cmd =
|
|
89
|
+
typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : undefined;
|
|
90
|
+
return {cmd, configPath: loaded.path ?? configPath};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* True iff the ORIGINAL argv carries the {@link NO_FORWARD_FLAG} opt-out. Read
|
|
95
|
+
* from raw argv BEFORE commander parses (commander never sees this bootstrap
|
|
96
|
+
* flag). Only the exact `--no-forward` token counts.
|
|
97
|
+
*/
|
|
98
|
+
export function argvHasNoForward(argv: readonly string[]): boolean {
|
|
99
|
+
return argv.includes(NO_FORWARD_FLAG);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True iff the env carries a truthy {@link NO_FORWARD_ENV} opt-out. */
|
|
103
|
+
export function envHasNoForward(env: NodeJS.ProcessEnv): boolean {
|
|
104
|
+
const value = env[NO_FORWARD_ENV];
|
|
105
|
+
return value !== undefined && value !== '' && value !== '0';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True iff the env carries the {@link FORWARDED_ENV_MARKER} (we ARE the child). */
|
|
109
|
+
export function envIsForwarded(env: NodeJS.ProcessEnv): boolean {
|
|
110
|
+
const value = env[FORWARDED_ENV_MARKER];
|
|
111
|
+
return value !== undefined && value !== '' && value !== '0';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Return `argv` with EVERY {@link NO_FORWARD_FLAG} token removed, so commander
|
|
116
|
+
* (which does not declare `--no-forward` as an option) never sees it. Preserves
|
|
117
|
+
* order + all other tokens. Used to hand a clean argv to the in-process run when
|
|
118
|
+
* the opt-out is set.
|
|
119
|
+
*/
|
|
120
|
+
export function stripNoForwardFlag(argv: readonly string[]): string[] {
|
|
121
|
+
return argv.filter((token) => token !== NO_FORWARD_FLAG);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The PURE forward decision (no I/O beyond the injected `readRepoCmd`). Given the
|
|
126
|
+
* original argv + env + cwd, decide whether the bootstrap should run itself,
|
|
127
|
+
* forward to a `dorflCmd`, or (a declared-but-unusable command) surface an error.
|
|
128
|
+
* The forward is fired ONCE here at startup; the returned decision is acted on by
|
|
129
|
+
* {@link performForward}.
|
|
130
|
+
*
|
|
131
|
+
* Order of precedence:
|
|
132
|
+
* 1. OPT-OUT (`--no-forward` OR `DORFL_NO_FORWARD`) ⇒ run-self, silent.
|
|
133
|
+
* 2. ALREADY-FORWARDED (the child's env marker) ⇒ run-self, silent (loop-safe).
|
|
134
|
+
* 3. NO `dorflCmd` declared ⇒ run-self (onboarding-safe; never chicken-and-egg).
|
|
135
|
+
* 4. A `dorflCmd` is declared ⇒ forward to it.
|
|
136
|
+
*
|
|
137
|
+
* NOTE: this decision never itself resolves whether the target BINARY exists —
|
|
138
|
+
* that is the exec's job ({@link performForward}), which FAILS LOUD on a spawn
|
|
139
|
+
* error (the absent / present-but-broken pin). Keeping the decision pure lets a
|
|
140
|
+
* test drive every branch without a filesystem probe.
|
|
141
|
+
*/
|
|
142
|
+
export function decideForward(options: {
|
|
143
|
+
argv: readonly string[];
|
|
144
|
+
env: NodeJS.ProcessEnv;
|
|
145
|
+
cwd: string;
|
|
146
|
+
readRepoCmd: RepoCmdReader;
|
|
147
|
+
}): ForwardDecision {
|
|
148
|
+
const {argv, env, cwd, readRepoCmd} = options;
|
|
149
|
+
|
|
150
|
+
if (argvHasNoForward(argv) || envHasNoForward(env)) {
|
|
151
|
+
return {kind: 'run-self', reason: 'opted-out'};
|
|
152
|
+
}
|
|
153
|
+
if (envIsForwarded(env)) {
|
|
154
|
+
return {kind: 'run-self', reason: 'already-forwarded'};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const {cmd, configPath} = readRepoCmd(cwd);
|
|
158
|
+
if (cmd === undefined) {
|
|
159
|
+
return {kind: 'run-self', reason: 'no-cmd'};
|
|
160
|
+
}
|
|
161
|
+
return {kind: 'forward', cmd, configPath};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The injected exec seam. Runs `cmd` (through the shell so `npx dorfl@x` /
|
|
166
|
+
* `mise exec dorfl@x --` / `node_modules/.bin/dorfl` all behave as written) with
|
|
167
|
+
* the given argv appended, the given env (which carries the
|
|
168
|
+
* {@link FORWARDED_ENV_MARKER} so the child does not forward again), inheriting
|
|
169
|
+
* stdio. Returns the child's exit code, or a spawn ERROR (target absent / not
|
|
170
|
+
* executable) which {@link performForward} turns into the loud, actionable error.
|
|
171
|
+
*
|
|
172
|
+
* Tests inject a stub that never touches a real process; the default
|
|
173
|
+
* ({@link defaultForwardSpawn}) uses `spawnSync('bash', ['-c', ...])`.
|
|
174
|
+
*/
|
|
175
|
+
export interface ForwardSpawn {
|
|
176
|
+
(options: {
|
|
177
|
+
cmd: string;
|
|
178
|
+
forwardedArgs: readonly string[];
|
|
179
|
+
env: NodeJS.ProcessEnv;
|
|
180
|
+
}): ForwardSpawnResult;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The result of the injected {@link ForwardSpawn}. */
|
|
184
|
+
export type ForwardSpawnResult =
|
|
185
|
+
| {kind: 'exited'; code: number}
|
|
186
|
+
| {kind: 'spawn-error'; message: string};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The default {@link ForwardSpawn}: run `<cmd> <forwardedArgs...>` via
|
|
190
|
+
* `bash -c "<cmd> \"$@\"" bash <args...>` with inherited stdio, returning the
|
|
191
|
+
* child's exit code. A spawn error (`error` set — e.g. bash missing) or a shell
|
|
192
|
+
* "command not found" (exit 127) is reported as `spawn-error` so an absent /
|
|
193
|
+
* broken `dorflCmd` fails loud rather than silently degrading.
|
|
194
|
+
*/
|
|
195
|
+
export function defaultForwardSpawn(options: {
|
|
196
|
+
cmd: string;
|
|
197
|
+
forwardedArgs: readonly string[];
|
|
198
|
+
env: NodeJS.ProcessEnv;
|
|
199
|
+
}): ForwardSpawnResult {
|
|
200
|
+
// `bash -c '<cmd> "$@"' bash <forwardedArgs...>` passes the ORIGINAL argv
|
|
201
|
+
// through as positional params (quoting-safe) AFTER the (possibly multi-token)
|
|
202
|
+
// dorflCmd — so `npx dorfl@x <args>` and `./bin/dorfl <args>` both work.
|
|
203
|
+
const result = spawnSync(
|
|
204
|
+
'bash',
|
|
205
|
+
['-c', `${options.cmd} "$@"`, 'bash', ...options.forwardedArgs],
|
|
206
|
+
{env: options.env, stdio: 'inherit'},
|
|
207
|
+
);
|
|
208
|
+
if (result.error) {
|
|
209
|
+
return {kind: 'spawn-error', message: result.error.message};
|
|
210
|
+
}
|
|
211
|
+
// 127 is the shell's "command not found" — the ABSENT-target case (e.g.
|
|
212
|
+
// `node_modules/.bin/dorfl` before install). Surface it as a spawn error so it
|
|
213
|
+
// fails loud, NOT a silent passthrough of a misleading exit code.
|
|
214
|
+
if (result.status === 127) {
|
|
215
|
+
return {kind: 'spawn-error', message: `command not found: ${options.cmd}`};
|
|
216
|
+
}
|
|
217
|
+
if (result.signal) {
|
|
218
|
+
return {
|
|
219
|
+
kind: 'spawn-error',
|
|
220
|
+
message: `terminated by signal ${result.signal}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {kind: 'exited', code: result.status ?? 1};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The one-line STDERR notice announcing a forward (never stdout — must not
|
|
227
|
+
* corrupt `--json`). Shape per spec §4. */
|
|
228
|
+
export function forwardNotice(cmd: string, configPath: string): string {
|
|
229
|
+
return `${brand.bin}: forwarding to \`${cmd}\` (from ${configPath})`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The loud, actionable error for a declared-but-unusable `dorflCmd` (absent
|
|
234
|
+
* target, or a present binary that spawn-errors). Names the `dorflCmd` value +
|
|
235
|
+
* the `dorfl.json` path + the FIX (run the dependency install first) + the
|
|
236
|
+
* `--no-forward` / `DORFL_NO_FORWARD` bypass — so it is never a silent degrade to
|
|
237
|
+
* the global (which would run the WRONG version and defeat the pin).
|
|
238
|
+
*/
|
|
239
|
+
export function forwardFailureMessage(options: {
|
|
240
|
+
cmd: string;
|
|
241
|
+
configPath: string;
|
|
242
|
+
detail: string;
|
|
243
|
+
}): string {
|
|
244
|
+
const {cmd, configPath, detail} = options;
|
|
245
|
+
return (
|
|
246
|
+
`${brand.bin}: could NOT run the repo-declared dorflCmd \`${cmd}\` ` +
|
|
247
|
+
`(from ${configPath}): ${detail}.\n` +
|
|
248
|
+
`This usually means the repo's dependencies are not installed yet ` +
|
|
249
|
+
`(e.g. \`${cmd}\` points at node_modules/.bin/dorfl before install). ` +
|
|
250
|
+
`Run the repo's dependency install first (e.g. \`pnpm install\`, or the ` +
|
|
251
|
+
`CI project-setup hook), then re-run.\n` +
|
|
252
|
+
`To bypass the forward and run the bootstrap ${brand.bin} directly, pass ` +
|
|
253
|
+
`${NO_FORWARD_FLAG} or set ${NO_FORWARD_ENV}=1 ` +
|
|
254
|
+
`(this runs whatever ${brand.bin} is on PATH — NOT the pinned version).`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** What {@link performForward} tells the caller to do next. */
|
|
259
|
+
export type ForwardOutcome =
|
|
260
|
+
/** A forward happened; exit the process with this code (transparent passthrough). */
|
|
261
|
+
| {kind: 'forwarded'; exitCode: number}
|
|
262
|
+
/** The forward could not run; print `message` to stderr and exit non-zero. */
|
|
263
|
+
| {kind: 'error'; message: string; exitCode: number}
|
|
264
|
+
/** No forward — the caller runs the bootstrap itself with `argv`. */
|
|
265
|
+
| {kind: 'run-self'; argv: string[]};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Act on a {@link ForwardDecision}: for a `forward`, print the notice to STDERR,
|
|
269
|
+
* spawn the child (via the injected `spawn`) with the {@link FORWARDED_ENV_MARKER}
|
|
270
|
+
* set + the ORIGINAL argv appended, and return the child's exit code — or, on a
|
|
271
|
+
* spawn error, the loud actionable failure. For `run-self`, return the (opt-out-
|
|
272
|
+
* stripped) argv for the caller to parse in-process.
|
|
273
|
+
*
|
|
274
|
+
* `argv` here is the FULL process argv (`[node, script, ...userArgs]`); only the
|
|
275
|
+
* USER args (index 2+) are forwarded to the child, since the child provides its
|
|
276
|
+
* own node + script.
|
|
277
|
+
*/
|
|
278
|
+
export function performForward(options: {
|
|
279
|
+
decision: ForwardDecision;
|
|
280
|
+
argv: readonly string[];
|
|
281
|
+
env: NodeJS.ProcessEnv;
|
|
282
|
+
spawn: ForwardSpawn;
|
|
283
|
+
writeNotice: (line: string) => void;
|
|
284
|
+
}): ForwardOutcome {
|
|
285
|
+
const {decision, argv, env, spawn, writeNotice} = options;
|
|
286
|
+
|
|
287
|
+
if (decision.kind === 'run-self') {
|
|
288
|
+
return {kind: 'run-self', argv: stripNoForwardFlag(argv)};
|
|
289
|
+
}
|
|
290
|
+
if (decision.kind === 'error') {
|
|
291
|
+
return {kind: 'error', message: decision.message, exitCode: 1};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// A forward. The user args are everything after `[node, script]`.
|
|
295
|
+
const forwardedArgs = argv.slice(2);
|
|
296
|
+
writeNotice(forwardNotice(decision.cmd, decision.configPath));
|
|
297
|
+
|
|
298
|
+
const childEnv: NodeJS.ProcessEnv = {...env, [FORWARDED_ENV_MARKER]: '1'};
|
|
299
|
+
const result = spawn({cmd: decision.cmd, forwardedArgs, env: childEnv});
|
|
300
|
+
|
|
301
|
+
if (result.kind === 'spawn-error') {
|
|
302
|
+
return {
|
|
303
|
+
kind: 'error',
|
|
304
|
+
message: forwardFailureMessage({
|
|
305
|
+
cmd: decision.cmd,
|
|
306
|
+
configPath: decision.configPath,
|
|
307
|
+
detail: result.message,
|
|
308
|
+
}),
|
|
309
|
+
exitCode: 1,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {kind: 'forwarded', exitCode: result.code};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The CLI-entry wiring: decide + (if forwarding) exec, all through the default
|
|
317
|
+
* seams. Returns the {@link ForwardOutcome} the entry point acts on — a
|
|
318
|
+
* `forwarded`/`error` outcome means "exit with this code now"; a `run-self`
|
|
319
|
+
* outcome means "continue into commander with this argv". Kept thin so the entry
|
|
320
|
+
* point stays a two-line hook; the testable logic lives in {@link decideForward}
|
|
321
|
+
* + {@link performForward}.
|
|
322
|
+
*/
|
|
323
|
+
export function maybeForward(options: {
|
|
324
|
+
argv?: readonly string[];
|
|
325
|
+
env?: NodeJS.ProcessEnv;
|
|
326
|
+
cwd?: string;
|
|
327
|
+
readRepoCmd?: RepoCmdReader;
|
|
328
|
+
spawn?: ForwardSpawn;
|
|
329
|
+
writeNotice?: (line: string) => void;
|
|
330
|
+
}): ForwardOutcome {
|
|
331
|
+
const argv = options.argv ?? process.argv;
|
|
332
|
+
const env = options.env ?? process.env;
|
|
333
|
+
const cwd = options.cwd ?? process.cwd();
|
|
334
|
+
const readRepoCmd = options.readRepoCmd ?? defaultRepoCmdReader;
|
|
335
|
+
const spawn = options.spawn ?? defaultForwardSpawn;
|
|
336
|
+
const writeNotice =
|
|
337
|
+
options.writeNotice ??
|
|
338
|
+
((line: string) => process.stderr.write(line + '\n'));
|
|
339
|
+
|
|
340
|
+
let decision: ForwardDecision;
|
|
341
|
+
try {
|
|
342
|
+
decision = decideForward({argv, env, cwd, readRepoCmd});
|
|
343
|
+
} catch (err) {
|
|
344
|
+
// A malformed `dorfl.json` (invalid JSON / non-string dorflCmd) surfaces as
|
|
345
|
+
// a loud error rather than a silent run-self, so a broken pin config is never
|
|
346
|
+
// masked. `REPO_CONFIG_FILENAME` names the file in the message.
|
|
347
|
+
return {
|
|
348
|
+
kind: 'error',
|
|
349
|
+
message:
|
|
350
|
+
`${brand.bin}: could not read ${REPO_CONFIG_FILENAME} to decide the ` +
|
|
351
|
+
`bootstrap forward: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
352
|
+
`Fix ${REPO_CONFIG_FILENAME}, or pass ${NO_FORWARD_FLAG} / set ` +
|
|
353
|
+
`${NO_FORWARD_ENV}=1 to run the bootstrap ${brand.bin} directly.`,
|
|
354
|
+
exitCode: 1,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return performForward({decision, argv, env, spawn, writeNotice});
|
|
359
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -149,6 +149,7 @@ import {
|
|
|
149
149
|
SlugResolutionError,
|
|
150
150
|
} from './slug-namespace.js';
|
|
151
151
|
import {brand} from './brand.js';
|
|
152
|
+
import {maybeForward} from './bootstrap-forward.js';
|
|
152
153
|
import {installCI, type WizardPrompts} from './install-ci.js';
|
|
153
154
|
import {GitHubCIContext} from './install-ci-github.js';
|
|
154
155
|
import {loadCapabilityRegistry} from './install-ci-core.js';
|
|
@@ -4894,11 +4895,30 @@ function promptMultiSelect(repos: string[]): Promise<string[]> {
|
|
|
4894
4895
|
* so tests can build + introspect/parse the program WITHOUT triggering a real
|
|
4895
4896
|
* argv parse + `process.exit` on import (the module-level bootstrap below only
|
|
4896
4897
|
* fires when this file is the process entry point).
|
|
4898
|
+
*
|
|
4899
|
+
* BEFORE any command dispatch it runs the bootstrap SELF-FORWARD hook
|
|
4900
|
+
* ({@link maybeForward}): if the nearest repo `dorfl.json` declares a `dorflCmd`
|
|
4901
|
+
* (and we are not already the forwarded child / opted out), it execs that command
|
|
4902
|
+
* with the ORIGINAL argv + env inherited and exits with the child's code
|
|
4903
|
+
* (transparent passthrough). A declared-but-unusable `dorflCmd` FAILS LOUD. On
|
|
4904
|
+
* the run-self branch (no `dorflCmd`, opted out, or the forwarded child) it parses
|
|
4905
|
+
* the (opt-out-stripped) argv in-process, exactly as before. See
|
|
4906
|
+
* `bootstrap-forward.ts` for the full model + the injectable seam.
|
|
4897
4907
|
*/
|
|
4898
4908
|
export async function runCli(argv: string[] = process.argv): Promise<void> {
|
|
4909
|
+
const outcome = maybeForward({argv});
|
|
4910
|
+
if (outcome.kind === 'forwarded') {
|
|
4911
|
+
process.exit(outcome.exitCode);
|
|
4912
|
+
}
|
|
4913
|
+
if (outcome.kind === 'error') {
|
|
4914
|
+
console.error(outcome.message);
|
|
4915
|
+
process.exit(outcome.exitCode);
|
|
4916
|
+
}
|
|
4917
|
+
// run-self: parse the argv commander sees (with any `--no-forward` stripped,
|
|
4918
|
+
// since commander does not declare that bootstrap-level flag).
|
|
4899
4919
|
const program = buildProgram();
|
|
4900
4920
|
try {
|
|
4901
|
-
await program.parseAsync(argv);
|
|
4921
|
+
await program.parseAsync(outcome.argv);
|
|
4902
4922
|
} catch (err: unknown) {
|
|
4903
4923
|
console.error(err instanceof Error ? err.message : String(err));
|
|
4904
4924
|
process.exit(1);
|
package/src/config.ts
CHANGED
|
@@ -406,6 +406,36 @@ export interface Config {
|
|
|
406
406
|
* (`--no-pr`) > env > per-repo > global > default `false`.
|
|
407
407
|
*/
|
|
408
408
|
noPR: boolean;
|
|
409
|
+
/**
|
|
410
|
+
* **The repo-declared dorfl COMMAND** (spec
|
|
411
|
+
* `dorfl-self-version-pinning-and-bootstrap-forward` Solution §1/§3): the single
|
|
412
|
+
* command string a repo's committed `dorfl.json` declares so that bare
|
|
413
|
+
* `dorfl` (a thin bootstrap) can self-forward to the EXACT dorfl that repo runs
|
|
414
|
+
* with — e.g. `"node_modules/.bin/dorfl"`, `"npx dorfl@0.7.0"`, `"./bin/dorfl"`,
|
|
415
|
+
* `"mise exec dorfl@0.7.0 --"`. It is honoured VERBATIM: dorfl does NOT parse a
|
|
416
|
+
* version, resolve/download/cache one, or shell-split the command here (a version
|
|
417
|
+
* is expressed by the user writing `npx dorfl@<version>` themselves; the FORWARD
|
|
418
|
+
* task, `dorfl-bootstrap-self-forward`, owns exec semantics). Optional with NO
|
|
419
|
+
* default so "unset" is meaningful: unset/empty/whitespace-only ⇒ absent (the
|
|
420
|
+
* bootstrap runs itself — never an error), so onboarding (`setup`/`install-ci` in
|
|
421
|
+
* a repo with no pin yet) is never chicken-and-egg. Leading/trailing whitespace is
|
|
422
|
+
* trimmed; a non-string value FAILS LOUD at config load
|
|
423
|
+
* ({@link validateDorflCmdConfig}).
|
|
424
|
+
*
|
|
425
|
+
* **Deliberate host-only EXCEPTION (ADR
|
|
426
|
+
* `dorfl-cmd-repo-settable-exception-to-host-only`).** `dorflCmd` names which
|
|
427
|
+
* executable runs — definitionally the same class as the machine-command keys
|
|
428
|
+
* `agentCmd`/`piBin`/`sessionsDir` that ADR §13 (see
|
|
429
|
+
* `execution-substrate-decisions.md`) keeps HOST-ONLY (`REPO_REJECTED_KEYS`: a
|
|
430
|
+
* committed repo file must not redirect where the host runs). `dorflCmd` REVERSES
|
|
431
|
+
* that rule for this ONE key — it is in `REPO_ALLOWED_KEYS`, repo-settable — because
|
|
432
|
+
* its purpose is repo-declared REPRODUCIBILITY, it carries no more trust than the
|
|
433
|
+
* committed `verify` command the repo already runs, and the forward is ANNOUNCED on
|
|
434
|
+
* stderr (unlike a silent `piBin`). There is NO trust gate. See the ADR for the full
|
|
435
|
+
* why. Resolved per-repo like `integration`/`verify`: flag > env (`DORFL_DORFL_CMD`)
|
|
436
|
+
* > per-repo > global > default (unset).
|
|
437
|
+
*/
|
|
438
|
+
dorflCmd?: string;
|
|
409
439
|
/**
|
|
410
440
|
* The command the runner shells out to for one task. The runner appends the
|
|
411
441
|
* built prompt on stdin; the command does NO git ops on the repo (the runner
|
|
@@ -900,6 +930,47 @@ export function validateDeadlineConfig(config: Config): void {
|
|
|
900
930
|
}
|
|
901
931
|
}
|
|
902
932
|
|
|
933
|
+
/**
|
|
934
|
+
* Validate + NORMALISE the repo-declared `dorflCmd` (spec
|
|
935
|
+
* `dorfl-self-version-pinning-and-bootstrap-forward` §1/§3; ADR
|
|
936
|
+
* `dorfl-cmd-repo-settable-exception-to-host-only`). Called at the SAME two
|
|
937
|
+
* resolution FINAL points as {@link validateDeadlineConfig} (`loadConfig` for the
|
|
938
|
+
* global chain; `resolveRepoConfigFromLoaded` for the per-repo chain), so a
|
|
939
|
+
* malformed value from ANY layer (flag / env / per-repo / global) surfaces the
|
|
940
|
+
* same way. Mutates `config` in place:
|
|
941
|
+
*
|
|
942
|
+
* - a NON-STRING value (number/array/object/boolean/null) FAILS LOUD with a
|
|
943
|
+
* clear message naming the field — the config layer's existing fail-loud
|
|
944
|
+
* error path (mirrors {@link validateDeadlineConfig} / the identity check),
|
|
945
|
+
* never a crash;
|
|
946
|
+
* - a string is TRIMMED (leading/trailing whitespace removed) and carried
|
|
947
|
+
* VERBATIM otherwise (no shell-splitting — the forward task owns exec);
|
|
948
|
+
* - an empty / whitespace-only string resolves to UNSET (the field is deleted),
|
|
949
|
+
* so "absent" and "the bootstrap runs itself" are the same state — never an
|
|
950
|
+
* error.
|
|
951
|
+
*/
|
|
952
|
+
export function validateDorflCmdConfig(config: Config): void {
|
|
953
|
+
const value = config.dorflCmd;
|
|
954
|
+
if (value === undefined) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (typeof value !== 'string') {
|
|
958
|
+
throw new Error(
|
|
959
|
+
`dorflCmd must be a string (got ${typeof value}). It is the exact dorfl ` +
|
|
960
|
+
`COMMAND this repo runs with, forwarded verbatim by the bootstrap ` +
|
|
961
|
+
`(e.g. "node_modules/.bin/dorfl", "npx dorfl@0.7.0", "./bin/dorfl"). ` +
|
|
962
|
+
`Leave it unset to run the bootstrap dorfl itself.`,
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
const trimmed = value.trim();
|
|
966
|
+
if (trimmed === '') {
|
|
967
|
+
// Empty / whitespace-only ⇒ UNSET (never an error): the bootstrap runs itself.
|
|
968
|
+
delete config.dorflCmd;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
config.dorflCmd = trimmed;
|
|
972
|
+
}
|
|
973
|
+
|
|
903
974
|
/** The conventional config location (`~/.config/dorfl/config.json`). */
|
|
904
975
|
export function defaultConfigPath(): string {
|
|
905
976
|
return join(homedir(), '.config', brand.configDirName, 'config.json');
|
|
@@ -973,5 +1044,9 @@ export function loadConfig(path: string = defaultConfigPath()): Config {
|
|
|
973
1044
|
}
|
|
974
1045
|
const merged = mergeConfig(parsed);
|
|
975
1046
|
validateDeadlineConfig(merged);
|
|
1047
|
+
// Validate + normalise (trim; empty ⇒ unset; non-string ⇒ fail-loud) the
|
|
1048
|
+
// repo-declared dorfl command from the global chain (ADR
|
|
1049
|
+
// `dorfl-cmd-repo-settable-exception-to-host-only`).
|
|
1050
|
+
validateDorflCmdConfig(merged);
|
|
976
1051
|
return merged;
|
|
977
1052
|
}
|
package/src/cwd-section.ts
CHANGED
|
@@ -4,7 +4,11 @@ import {scanRepoPaths, type RepoReport} from './scan.js';
|
|
|
4
4
|
import {arbiterStatus} from './arbiter.js';
|
|
5
5
|
import {listMirrors} from './registry.js';
|
|
6
6
|
import {encodeRepoKey, mirrorPath} from './repo-mirror.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
heldTaskSlugsStrict,
|
|
9
|
+
heldSpecSlugsStrict,
|
|
10
|
+
listItemLockEntries,
|
|
11
|
+
} from './item-lock.js';
|
|
8
12
|
import type {Config} from './config.js';
|
|
9
13
|
import type {ConfigOverrideMap} from './config-override.js';
|
|
10
14
|
|
|
@@ -260,12 +264,27 @@ export async function resolveCwdSection(
|
|
|
260
264
|
const heldSlugs = hasLockRemote
|
|
261
265
|
? await heldTaskSlugsStrict(cwd, lockRemote, env)
|
|
262
266
|
: new Set<string>();
|
|
267
|
+
// The held-SPEC set (fix
|
|
268
|
+
// `propose-tasking-releases-lock-so-spec-is-retasked-and-pr-force-pushed-every-tick`):
|
|
269
|
+
// a spec whose `spec-<slug>` lock is held has an in-flight tasking PR (propose keeps
|
|
270
|
+
// the lock across the open PR). SUBTRACT it from the taskable pool so CI does not
|
|
271
|
+
// re-task it every tick. Read fail-CLOSED from the SAME coordination remote as the
|
|
272
|
+
// task held set (SELECTION must not enumerate an untrusted pool).
|
|
273
|
+
const heldSpecSlugs = hasLockRemote
|
|
274
|
+
? await heldSpecSlugsStrict(cwd, lockRemote, env)
|
|
275
|
+
: new Set<string>();
|
|
263
276
|
|
|
264
277
|
// 3. The cwd's `work/` lifecycle from the LOCAL WORKING TREE (not a mirror ref),
|
|
265
278
|
// with the arbiter-read held set SUBTRACTED so in-flight (lock-held) items are
|
|
266
279
|
// not reported eligible. Thread the per-machine override so the cwd section's
|
|
267
280
|
// eligibility matches what `do`/`advance` autopick will actually select.
|
|
268
|
-
const localReport = scanRepoPaths(
|
|
281
|
+
const localReport = scanRepoPaths(
|
|
282
|
+
[cwd],
|
|
283
|
+
config,
|
|
284
|
+
heldSlugs,
|
|
285
|
+
options.override,
|
|
286
|
+
heldSpecSlugs,
|
|
287
|
+
);
|
|
269
288
|
const repo = localReport.repos[0];
|
|
270
289
|
|
|
271
290
|
// 3b. The PER-ITEM LOCK in-flight SURFACE for the cwd (spec
|
package/src/env-config.ts
CHANGED
|
@@ -121,6 +121,14 @@ const KEY_COERCIONS: {[K in keyof Config]?: Coercion} = {
|
|
|
121
121
|
// with a deprecation warning — see `envOverrides`).
|
|
122
122
|
noPR: 'boolean',
|
|
123
123
|
agentCmd: 'string',
|
|
124
|
+
// `dorflCmd` (the repo-declared dorfl COMMAND bare `dorfl` self-forwards to —
|
|
125
|
+
// spec `dorfl-self-version-pinning-and-bootstrap-forward`) coerces as a STRING
|
|
126
|
+
// (carried verbatim, no shell-splitting), so `DORFL_DORFL_CMD="npx dorfl@0.7.0"`
|
|
127
|
+
// works. Unlike `piBin`/`agentCmd` (host-only) it is ALSO repo-settable (ADR
|
|
128
|
+
// `dorfl-cmd-repo-settable-exception-to-host-only`); env is simply the
|
|
129
|
+
// per-machine layer above the committed file in the shared precedence chain.
|
|
130
|
+
// A non-string / empty value is normalised at load (`validateDorflCmdConfig`).
|
|
131
|
+
dorflCmd: 'string',
|
|
124
132
|
model: 'string',
|
|
125
133
|
harness: {enum: ['null', 'pi']},
|
|
126
134
|
piBin: 'string',
|