catherd-cli 1.3.0 → 1.5.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/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { existsSync, renameSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { ROLE_ENV } from "../domain/role-scope.ts";
|
|
6
|
+
import { isCatherdSecret } from "../infra/env.ts";
|
|
7
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
8
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
9
|
+
import { DISPATCH_ID_ENV } from "../infra/lock-activity.ts";
|
|
10
|
+
import { repoDir } from "../infra/paths.ts";
|
|
11
|
+
import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
12
|
+
import { knowledgeFor } from "./run-store.ts";
|
|
13
|
+
|
|
14
|
+
// Plan 23: the gate environment a repo's verifier and preflight need (DOCKER_HOST, HTTPS_PROXY, a
|
|
15
|
+
// TESTCONTAINERS_* setting), kept beside the repo's knowledge.md, so no verifier sets it up by hand. A value is
|
|
16
|
+
// stored as is; a secret is stored by reference only: the name of the env var that holds it, read when a
|
|
17
|
+
// role or a check starts, never written to disk.
|
|
18
|
+
|
|
19
|
+
const GATE_ENV_SCHEMA = 1;
|
|
20
|
+
|
|
21
|
+
const EntrySchema = z.union([z.strictObject({ value: z.string() }), z.strictObject({ from: z.string() })]);
|
|
22
|
+
export type GateEnvEntry = z.infer<typeof EntrySchema>;
|
|
23
|
+
|
|
24
|
+
const GateEnvSchema = z.looseObject({
|
|
25
|
+
schema: z.literal(GATE_ENV_SCHEMA),
|
|
26
|
+
vars: z.record(z.string(), EntrySchema),
|
|
27
|
+
});
|
|
28
|
+
type GateEnvFile = z.infer<typeof GateEnvSchema>;
|
|
29
|
+
|
|
30
|
+
/** `<data>/repos/<repo key>/gate-env.json`: where a toplevel keyed it (before 1.5, and with no origin). */
|
|
31
|
+
export const gateEnvFile = (toplevel: string): string => join(repoDir(toplevel), "gate-env.json");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Spec 1.5 "Knowledge keyed by git origin": the gate-env.json beside the repo's knowledge.md, which every
|
|
35
|
+
* worktree and clone of one repository shares. A toplevel-keyed file this worktree left before is migrated on
|
|
36
|
+
* read: its variables join the shared file (the shared file's own win), and it is renamed `.migrated`.
|
|
37
|
+
*/
|
|
38
|
+
export async function gateEnvFor(toplevel: string): Promise<string> {
|
|
39
|
+
const file = join(dirname(await knowledgeFor(toplevel)), "gate-env.json");
|
|
40
|
+
const legacy = gateEnvFile(toplevel);
|
|
41
|
+
if (file === legacy || !existsSync(legacy)) return file;
|
|
42
|
+
ensurePrivateDir(dirname(file));
|
|
43
|
+
await withFileLock(file, () => {
|
|
44
|
+
if (!existsSync(legacy)) return;
|
|
45
|
+
const vars = { ...readVarsAt(legacy), ...readVarsAt(file) };
|
|
46
|
+
writeJsonAtomic(file, { schema: GATE_ENV_SCHEMA, vars } satisfies GateEnvFile);
|
|
47
|
+
renameSync(legacy, `${legacy}.migrated`);
|
|
48
|
+
});
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readVarsAt(file: string): Record<string, GateEnvEntry> {
|
|
53
|
+
if (!existsSync(file)) return {};
|
|
54
|
+
return readVersioned<GateEnvFile>(file, GateEnvSchema, GATE_ENV_SCHEMA).vars;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
58
|
+
/** A name a secret goes by: such a variable is set by reference (--from), never by value. */
|
|
59
|
+
const SECRET_NAME = /KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH/i;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The names catherd sets in a dispatch itself: the role's identity, dispatch id, testcontainers session, scratch
|
|
63
|
+
* and working dir, the process basics, catherd's own dirs, and the homes an isolated CLI is moved to (codex,
|
|
64
|
+
* grok, cursor, agy, opencode, claude). A gate env entry under one would take the role's identity or isolation.
|
|
65
|
+
*/
|
|
66
|
+
const RESERVED = new Set([
|
|
67
|
+
ROLE_ENV,
|
|
68
|
+
DISPATCH_ID_ENV,
|
|
69
|
+
"TESTCONTAINERS_SESSION_ID",
|
|
70
|
+
"TMPDIR",
|
|
71
|
+
"PWD",
|
|
72
|
+
"PATH",
|
|
73
|
+
"HOME",
|
|
74
|
+
"CATHERD_HOME",
|
|
75
|
+
"CATHERD_CONFIG_DIR",
|
|
76
|
+
"CATHERD_DATA_DIR",
|
|
77
|
+
"CODEX_HOME",
|
|
78
|
+
"GROK_HOME",
|
|
79
|
+
"CLAUDE_CONFIG_DIR",
|
|
80
|
+
"CURSOR_CONFIG_DIR",
|
|
81
|
+
"CURSOR_DATA_DIR",
|
|
82
|
+
"XDG_CONFIG_HOME",
|
|
83
|
+
"XDG_DATA_HOME",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/** The repo's gate environment, by name; empty when none is set. */
|
|
87
|
+
export async function readGateEnv(toplevel: string): Promise<Record<string, GateEnvEntry>> {
|
|
88
|
+
return readVarsAt(await gateEnvFor(toplevel));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function top(repo: string): Promise<string> {
|
|
92
|
+
const t = await gitToplevel(repo);
|
|
93
|
+
if (!t)
|
|
94
|
+
throw new CatherdError("E_IO_PATH", `${repo} is not inside a git repository`, {
|
|
95
|
+
fix: "pass the path of the repository",
|
|
96
|
+
});
|
|
97
|
+
return t;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertName(name: string): void {
|
|
101
|
+
if (!NAME.test(name))
|
|
102
|
+
throw new CatherdError("E_INPUT_INVALID", `"${name}" is not an env var name`, {
|
|
103
|
+
fix: "use letters, digits and _, not starting with a digit, e.g. DOCKER_HOST",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function update(
|
|
108
|
+
repo: string,
|
|
109
|
+
change: (vars: Record<string, GateEnvEntry>) => void,
|
|
110
|
+
): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
|
|
111
|
+
const t = await top(repo);
|
|
112
|
+
const file = await gateEnvFor(t);
|
|
113
|
+
ensurePrivateDir(dirname(file));
|
|
114
|
+
return withFileLock(file, () => {
|
|
115
|
+
const vars = { ...readVarsAt(file) };
|
|
116
|
+
change(vars);
|
|
117
|
+
writeJsonAtomic(file, { schema: GATE_ENV_SCHEMA, vars } satisfies GateEnvFile);
|
|
118
|
+
return { repo: t, vars };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Sets one variable of the repo's gate environment: a value, or `from`, the name of the env var that holds a
|
|
124
|
+
* secret. A secret-looking name (…KEY, …TOKEN, …PASSWORD) takes `from` only, so no secret lands on disk.
|
|
125
|
+
*/
|
|
126
|
+
export async function setGateEnv(
|
|
127
|
+
repo: string,
|
|
128
|
+
name: string,
|
|
129
|
+
entry: GateEnvEntry,
|
|
130
|
+
): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
|
|
131
|
+
assertName(name);
|
|
132
|
+
if (RESERVED.has(name))
|
|
133
|
+
throw new CatherdError("E_INPUT_INVALID", `${name} is set by catherd for every role`, {
|
|
134
|
+
fix: "the gate environment holds what a gate needs (DOCKER_HOST, a proxy, a TESTCONTAINERS_* setting), not a role's identity, scratch or home",
|
|
135
|
+
});
|
|
136
|
+
if ("from" in entry) {
|
|
137
|
+
assertName(entry.from);
|
|
138
|
+
if (isCatherdSecret(entry.from))
|
|
139
|
+
throw new CatherdError("E_INPUT_INVALID", `${entry.from} is catherd's own secret: no role gets it`, {
|
|
140
|
+
fix: "reference the env var that holds the gate's own secret",
|
|
141
|
+
});
|
|
142
|
+
} else if (SECRET_NAME.test(name))
|
|
143
|
+
throw new CatherdError(
|
|
144
|
+
"E_INPUT_INVALID",
|
|
145
|
+
`${name} looks like a secret: catherd stores it by reference only`,
|
|
146
|
+
{
|
|
147
|
+
fix: `export it in your shell, then: catherd knowledge env set ${name} --from <the env var that holds it>`,
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
return update(repo, (vars) => {
|
|
151
|
+
vars[name] = entry;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Removes one variable; refused when it is not set. */
|
|
156
|
+
export function removeGateEnv(
|
|
157
|
+
repo: string,
|
|
158
|
+
name: string,
|
|
159
|
+
): Promise<{ repo: string; vars: Record<string, GateEnvEntry> }> {
|
|
160
|
+
return update(repo, (vars) => {
|
|
161
|
+
if (!(name in vars))
|
|
162
|
+
throw new CatherdError("E_INPUT_INVALID", `${name} is not in the gate environment`, {
|
|
163
|
+
fix: "catherd knowledge env list shows what is set",
|
|
164
|
+
});
|
|
165
|
+
delete vars[name];
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The variables as briefs show them: `NAME=value`, a reference as `NAME=$FROM`. */
|
|
170
|
+
export const gateEnvLines = (vars: Record<string, GateEnvEntry>): string[] =>
|
|
171
|
+
Object.entries(vars)
|
|
172
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
173
|
+
.map(([k, e]) => ("from" in e ? `${k}=$${e.from}` : `${k}=${e.value}`));
|
|
174
|
+
|
|
175
|
+
/** The literal values, for a spec written to disk; references stay names (`refs`), resolved at spawn. */
|
|
176
|
+
export function gateEnvParts(vars: Record<string, GateEnvEntry>): {
|
|
177
|
+
values: Record<string, string>;
|
|
178
|
+
refs: Record<string, string>;
|
|
179
|
+
} {
|
|
180
|
+
const values: Record<string, string> = {};
|
|
181
|
+
const refs: Record<string, string> = {};
|
|
182
|
+
for (const [k, e] of Object.entries(vars)) {
|
|
183
|
+
if ("from" in e) refs[k] = e.from;
|
|
184
|
+
else values[k] = e.value;
|
|
185
|
+
}
|
|
186
|
+
return { values, refs };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The env the gate environment adds to `base`: its values, and each reference read from `base`, else from
|
|
191
|
+
* `fallback` (the login env); a reference neither holds is left out, and named in `missing`.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveGateEnv(
|
|
194
|
+
vars: Record<string, GateEnvEntry>,
|
|
195
|
+
base: Record<string, string | undefined>,
|
|
196
|
+
fallback: () => Record<string, string | undefined> = () => ({}),
|
|
197
|
+
): { env: Record<string, string>; missing: string[] } {
|
|
198
|
+
const { values, refs } = gateEnvParts(vars);
|
|
199
|
+
return resolveRefs(values, refs, base, fallback);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `values` plus each of `refs` (name → the env var holding it) read from `base`, else `fallback`. */
|
|
203
|
+
export function resolveRefs(
|
|
204
|
+
values: Record<string, string>,
|
|
205
|
+
refs: Record<string, string>,
|
|
206
|
+
base: Record<string, string | undefined>,
|
|
207
|
+
fallback: () => Record<string, string | undefined> = () => ({}),
|
|
208
|
+
): { env: Record<string, string>; missing: string[] } {
|
|
209
|
+
const env = { ...values };
|
|
210
|
+
const missing: string[] = [];
|
|
211
|
+
let login: Record<string, string | undefined> | null = null;
|
|
212
|
+
for (const [k, from] of Object.entries(refs)) {
|
|
213
|
+
const v = base[from] ?? (login ??= fallback())[from];
|
|
214
|
+
if (v === undefined) missing.push(k);
|
|
215
|
+
else env[k] = v;
|
|
216
|
+
}
|
|
217
|
+
return { env, missing };
|
|
218
|
+
}
|
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
lstatSync,
|
|
4
|
-
readdirSync,
|
|
5
|
-
readlinkSync,
|
|
6
|
-
realpathSync,
|
|
7
|
-
type Stats,
|
|
8
|
-
statSync,
|
|
9
|
-
} from "node:fs";
|
|
10
|
-
import { join } from "node:path";
|
|
1
|
+
import { lstatSync, readdirSync, readlinkSync, realpathSync, type Stats, statSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
11
3
|
import { z } from "zod";
|
|
12
4
|
import { CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
13
5
|
import { normalizeOwned, overlaps } from "../domain/lane.ts";
|
|
14
6
|
import { git, gitHead, statusSnapshot } from "../infra/git.ts";
|
|
15
7
|
import { repoDir } from "../infra/paths.ts";
|
|
16
8
|
import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
|
|
9
|
+
import { gateEnvLines, readGateEnv } from "./gate-env.ts";
|
|
17
10
|
import type { Deps } from "./ports.ts";
|
|
18
11
|
import { findRun, type Run } from "./run-store.ts";
|
|
19
12
|
|
|
@@ -35,6 +28,10 @@ type GatePass = z.infer<typeof GatePassSchema>;
|
|
|
35
28
|
export interface VerifierStep {
|
|
36
29
|
at: string;
|
|
37
30
|
item: string;
|
|
31
|
+
/** the item's command; steps written before 1.5 lack it */
|
|
32
|
+
command?: string;
|
|
33
|
+
/** the item's paths, cleaned (plan 23); steps written before it lack them */
|
|
34
|
+
paths?: string[];
|
|
38
35
|
carried: boolean;
|
|
39
36
|
/** a carried item: the commit its pass was recorded on */
|
|
40
37
|
commit?: string;
|
|
@@ -46,10 +43,11 @@ export interface VerifierStep {
|
|
|
46
43
|
export const gatesFile = (toplevel: string): string => join(repoDir(toplevel), "gates.jsonl");
|
|
47
44
|
const stepsFile = (run: Run): string => join(run.dir, "verifier.jsonl");
|
|
48
45
|
|
|
49
|
-
/** Repo-relative paths, `.` for the whole repo; anything that leaves the repo is refused. */
|
|
46
|
+
/** Repo-relative paths, `.` (or `./`) for the whole repo; anything that leaves the repo is refused. */
|
|
50
47
|
function cleanPaths(paths: string[]): string[] {
|
|
48
|
+
const whole = (p: string) => p.trim() === "." || p.trim() === "./";
|
|
51
49
|
try {
|
|
52
|
-
return [...new Set(paths.map((p) => (p
|
|
50
|
+
return [...new Set(paths.map((p) => (whole(p) ? "." : normalizeOwned(p))))].sort();
|
|
53
51
|
} catch (e) {
|
|
54
52
|
if (isCatherdError(e))
|
|
55
53
|
throw new CatherdError("E_INPUT_INVALID", e.message.replace("owned path", "gate path"), {
|
|
@@ -148,7 +146,10 @@ const isLink = (file: string): boolean => {
|
|
|
148
146
|
|
|
149
147
|
/** Whether git ignores `p` (a tracked file never is). */
|
|
150
148
|
async function ignored(repo: string, p: string): Promise<boolean> {
|
|
151
|
-
|
|
149
|
+
const bare = p.replace(/\/$/, "");
|
|
150
|
+
const asked = async (q: string) => (await git(repo, ["check-ignore", "-q", "--", q])).kind === "ok";
|
|
151
|
+
// a directory pattern (`dist/`) matches a path that is not on disk yet only through a child of it
|
|
152
|
+
return (await asked(bare)) || (!onDisk(join(repo, bare)) && (await asked(`${bare}/.catherd`)));
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
/** The most files a gate path's on-disk walk hashes. */
|
|
@@ -179,47 +180,101 @@ function walk(repo: string, p: string): string[] {
|
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
/**
|
|
182
|
-
* The
|
|
183
|
-
*
|
|
184
|
-
* A path git ignores, or one not at HEAD, is also hashed file by file from disk; ignored files under `.` or
|
|
185
|
-
* under a tracked directory are not, so an ignored input is covered only when it is named.
|
|
183
|
+
* The lockfiles a gate item's dependencies come from, wherever the repo tracks them: they stand in for
|
|
184
|
+
* `node_modules` and the toolchain caches, which a gate path never needs to name (plan 23).
|
|
186
185
|
*/
|
|
187
|
-
|
|
186
|
+
export const LOCKFILES = [
|
|
187
|
+
"bun.lock",
|
|
188
|
+
"bun.lockb",
|
|
189
|
+
"package-lock.json",
|
|
190
|
+
"npm-shrinkwrap.json",
|
|
191
|
+
"pnpm-lock.yaml",
|
|
192
|
+
"yarn.lock",
|
|
193
|
+
"go.sum",
|
|
194
|
+
"Cargo.lock",
|
|
195
|
+
"poetry.lock",
|
|
196
|
+
"uv.lock",
|
|
197
|
+
"Gemfile.lock",
|
|
198
|
+
"composer.lock",
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
/** `git ls-files -s -z` for `pathspecs`: each tracked file's mode, object id, stage and path, as the index holds them. */
|
|
202
|
+
async function tracked(repo: string, pathspecs: string[]): Promise<string> {
|
|
203
|
+
const r = await git(repo, ["ls-files", "-s", "-z", "--", ...pathspecs]);
|
|
204
|
+
if (r.kind !== "ok")
|
|
205
|
+
throw new CatherdError(
|
|
206
|
+
"E_IO_UNEXPECTED",
|
|
207
|
+
`git ls-files ${r.kind === "timed-out" ? "timed out" : "failed"} in ${repo}`,
|
|
208
|
+
{ fix: `check that git works in ${repo}` },
|
|
209
|
+
);
|
|
210
|
+
return r.out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Whether anything is at `file` on disk, a dangling symlink included. */
|
|
214
|
+
const onDisk = (file: string): boolean => {
|
|
215
|
+
try {
|
|
216
|
+
lstatSync(file);
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The content hash of `paths` (plan 23, a monorepo gate): the tracked files under each one, from `git ls-files -s`
|
|
225
|
+
* (mode, object id and path, so a committed chmod -x is new content), the repo's tracked lockfiles, and the
|
|
226
|
+
* content of every uncommitted change under them, so a verifier checking a tree not yet committed gets a hash of
|
|
227
|
+
* what it ran. An ignored path named explicitly (a `.env`, a build output, a `node_modules` folder) is hashed
|
|
228
|
+
* from disk, file by file within GATE_WALK_MAX, and as "absent" while it is not there; ignored files under `.`
|
|
229
|
+
* or under a tracked directory are never walked. A named symlink is hashed by what it points at too.
|
|
230
|
+
*/
|
|
231
|
+
async function contentHash(repo: string, paths: string[]): Promise<{ hash: string; dirty: boolean }> {
|
|
188
232
|
const h = new Bun.CryptoHasher("sha256");
|
|
233
|
+
// a dependency bump reaches every gate item through its lockfile
|
|
234
|
+
h.update(
|
|
235
|
+
`locks=${sha(
|
|
236
|
+
await tracked(
|
|
237
|
+
repo,
|
|
238
|
+
LOCKFILES.map((l) => `:(glob)**/${l}`),
|
|
239
|
+
),
|
|
240
|
+
)}\n`,
|
|
241
|
+
);
|
|
189
242
|
for (const p of paths) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
198
|
-
const at = entry.kind === "ok" ? entry.out.trim() : "";
|
|
199
|
-
// a mistyped path (or a glob) would hash as a constant and carry a pass forever
|
|
200
|
-
if (!at && !existsSync(join(repo, p)))
|
|
243
|
+
const bare = p.replace(/\/$/, "");
|
|
244
|
+
const listed = await tracked(repo, [p === "." ? "." : bare]);
|
|
245
|
+
const there = p === "." || onDisk(join(repo, bare));
|
|
246
|
+
const isIgnored = p !== "." && listed === "" && (await ignored(repo, p));
|
|
247
|
+
// a mistyped path (or a glob) would hash as a constant and carry a pass forever; an ignored output that
|
|
248
|
+
// is not built yet is part of the content, as "absent"
|
|
249
|
+
if (listed === "" && !there && !isIgnored)
|
|
201
250
|
throw new CatherdError(
|
|
202
251
|
"E_INPUT_INVALID",
|
|
203
252
|
`gate path ${p} exists neither at HEAD nor in the working tree`,
|
|
204
253
|
{
|
|
205
|
-
fix: "check the spelling: pass repo-relative files or directories that exist, like src/ or package.json (no globs)",
|
|
254
|
+
fix: "check the spelling: pass repo-relative files or directories that exist, like src/ or package.json (no globs); a git-ignored output not built yet is fine",
|
|
206
255
|
},
|
|
207
256
|
);
|
|
208
|
-
h.update(`${p}=${
|
|
209
|
-
// git status leaves ignored files out, so an ignored path
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
if (p !== "." && (
|
|
257
|
+
h.update(`${p}=${listed ? sha(listed) : there ? "untracked" : "absent"}\n`);
|
|
258
|
+
// git status leaves ignored files out, so an ignored path named here is hashed by what is on disk; "."
|
|
259
|
+
// keeps to the index and the not-ignored status. A tracked symlink's index entry is only its link text,
|
|
260
|
+
// so a named symlink is hashed from disk too, by what it points at
|
|
261
|
+
if (p !== "." && there && (isIgnored || isLink(join(repo, bare)))) {
|
|
213
262
|
// one budget per gate path: its walk and every symlink target it follows count against GATE_WALK_MAX
|
|
214
263
|
const budget: Budget = { path: p, files: 0, seen: new Set() };
|
|
215
264
|
for (const f of walk(repo, p)) h.update(`disk ${f}=${await diskEntry(join(repo, f), budget)}\n`);
|
|
216
265
|
}
|
|
217
266
|
}
|
|
267
|
+
// an uncommitted lockfile change (a worker's `bun add`) reaches every item, as a committed one does
|
|
218
268
|
const dirty = Object.keys(await statusSnapshot(repo))
|
|
219
|
-
.filter(
|
|
269
|
+
.filter(
|
|
270
|
+
(f) =>
|
|
271
|
+
paths.includes(".") ||
|
|
272
|
+
LOCKFILES.includes(basename(f.replace(/\/$/, ""))) ||
|
|
273
|
+
overlaps([f], paths).length > 0,
|
|
274
|
+
)
|
|
220
275
|
.sort();
|
|
221
276
|
for (const f of dirty) h.update(`dirty ${f}=${await dirtyEntry(join(repo, f), f)}\n`);
|
|
222
|
-
return h.digest("hex");
|
|
277
|
+
return { hash: h.digest("hex"), dirty: dirty.length > 0 };
|
|
223
278
|
}
|
|
224
279
|
|
|
225
280
|
const readPasses = (repo: string): GatePass[] =>
|
|
@@ -241,37 +296,153 @@ export function latestVerifierStep(run: Run): VerifierStep | null {
|
|
|
241
296
|
.at(-1) ?? null) as VerifierStep | null;
|
|
242
297
|
}
|
|
243
298
|
|
|
299
|
+
/** One item the verifier checked for a milestone in this run: its name, its command, and whether it passed. */
|
|
300
|
+
export interface RecordedItem {
|
|
301
|
+
item: string;
|
|
302
|
+
command: string | null;
|
|
303
|
+
/** gate_check would carry it now: its last check's command has a pass on the current content of its paths */
|
|
304
|
+
passed: boolean;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const milestoneSteps = (run: Run, m: string): VerifierStep[] =>
|
|
308
|
+
readJsonl<VerifierStep>(stepsFile(run)).rows.filter(
|
|
309
|
+
(s) => typeof s?.item === "string" && s.milestone === m,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
/** Each item's last step, in the order the items were first checked. */
|
|
313
|
+
const lastSteps = (steps: VerifierStep[]): VerifierStep[] =>
|
|
314
|
+
[...new Set(steps.map((s) => s.item))].map(
|
|
315
|
+
(item) => steps.findLast((s) => s.item === item) as VerifierStep,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
/** The pass gate_check carries for `command` on `paths` as the tree is now, if any. */
|
|
319
|
+
async function carryingPass(repo: string, command: string, paths: string[]): Promise<GatePass | undefined> {
|
|
320
|
+
const { hash } = await contentHash(repo, paths);
|
|
321
|
+
return readPasses(repo).findLast((p) => p.command === command && p.hash === hash);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The items checked for milestone `m` in this run, in the order they were first checked (plan 23): a new
|
|
326
|
+
* verifier reuses their names, so the ledger carries what passed, and a re-check starts from the failed ones.
|
|
327
|
+
* An item counts as passed only when gate_check would carry it now, by the same rule: its last check's command
|
|
328
|
+
* has a pass on the current content of its paths, so a fix round that changed what it reads opens it again.
|
|
329
|
+
*/
|
|
330
|
+
export async function recordedItems(run: Run, m: string): Promise<RecordedItem[]> {
|
|
331
|
+
const repo = run.meta.repo;
|
|
332
|
+
const passes = readPasses(repo);
|
|
333
|
+
return Promise.all(
|
|
334
|
+
lastSteps(milestoneSteps(run, m)).map(async (last) => {
|
|
335
|
+
const command = last.command ?? null;
|
|
336
|
+
// a step written before its paths were recorded: those of this run's latest pass of the item stand in
|
|
337
|
+
const paths =
|
|
338
|
+
last.paths ??
|
|
339
|
+
passes.findLast((p) => p.run === run.id && p.item === last.item && p.command === command)?.paths;
|
|
340
|
+
let passed = false;
|
|
341
|
+
if (command !== null && paths !== undefined) {
|
|
342
|
+
try {
|
|
343
|
+
passed = (await carryingPass(repo, command, paths)) !== undefined;
|
|
344
|
+
} catch (e) {
|
|
345
|
+
// a path gone from the tree carries no pass
|
|
346
|
+
if (!isCatherdError(e)) throw e;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { item: last.item, command, passed };
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The milestone's checked items with no pass recorded since their last check, from the step history alone
|
|
356
|
+
* (sync, for the protocol's next step); the verifier's listing and brief use recordedItems, which also re-opens
|
|
357
|
+
* a pass whose content has changed since.
|
|
358
|
+
*/
|
|
359
|
+
export function failedItems(run: Run, m: string): string[] {
|
|
360
|
+
const passes = readPasses(run.meta.repo).filter((p) => p.run === run.id);
|
|
361
|
+
return lastSteps(milestoneSteps(run, m))
|
|
362
|
+
.filter(
|
|
363
|
+
(last) =>
|
|
364
|
+
!last.carried && !passes.some((p) => p.item === last.item && Date.parse(p.at) >= Date.parse(last.at)),
|
|
365
|
+
)
|
|
366
|
+
.map((last) => last.item);
|
|
367
|
+
}
|
|
368
|
+
|
|
244
369
|
/**
|
|
245
370
|
* `gate_check`: carried when this repo has a pass with the same command on the same content of `paths`;
|
|
246
|
-
* records "verifier step: <item>" either way.
|
|
371
|
+
* records "verifier step: <item>" either way. With `milestone` it also lists the milestone's recorded items
|
|
372
|
+
* (plan 23), so a verifier reuses the names an earlier one checked.
|
|
247
373
|
*/
|
|
248
374
|
export async function gateCheck(
|
|
249
375
|
deps: Deps,
|
|
250
376
|
i: { run: string; item: string; command: string; paths: string[]; milestone?: string },
|
|
251
|
-
): Promise<
|
|
377
|
+
): Promise<
|
|
378
|
+
({ carried: true; passedAt: string; commit: string } | { carried: false }) & { recorded?: RecordedItem[] }
|
|
379
|
+
> {
|
|
252
380
|
const run = findRun(i.run);
|
|
253
381
|
const paths = cleanPaths(i.paths);
|
|
254
|
-
const
|
|
255
|
-
const pass = readPasses(run.meta.repo).findLast((p) => p.command === i.command && p.hash === hash);
|
|
382
|
+
const pass = await carryingPass(run.meta.repo, i.command, paths);
|
|
256
383
|
recordStep(run, {
|
|
257
384
|
at: new Date(deps.now()).toISOString(),
|
|
258
385
|
item: i.item,
|
|
386
|
+
command: i.command,
|
|
387
|
+
paths,
|
|
259
388
|
carried: pass !== undefined,
|
|
260
389
|
...(pass ? { commit: pass.commit } : {}),
|
|
261
390
|
...(i.milestone ? { milestone: i.milestone } : {}),
|
|
262
391
|
});
|
|
263
|
-
|
|
392
|
+
const carried = pass
|
|
393
|
+
? { carried: true as const, passedAt: pass.at, commit: pass.commit }
|
|
394
|
+
: { carried: false as const };
|
|
395
|
+
return i.milestone ? { ...carried, recorded: await recordedItems(run, i.milestone) } : carried;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* `gate_check` with a milestone and no item: the milestone's recorded items, recording no step, and the
|
|
400
|
+
* repo's gate environment as `NAME=value` lines (a secret as `NAME=$FROM`), which a native verifier exports.
|
|
401
|
+
*/
|
|
402
|
+
export async function gateList(i: {
|
|
403
|
+
run: string;
|
|
404
|
+
milestone: string;
|
|
405
|
+
}): Promise<{ recorded: RecordedItem[]; env: string[] }> {
|
|
406
|
+
const run = findRun(i.run);
|
|
407
|
+
return {
|
|
408
|
+
recorded: await recordedItems(run, i.milestone),
|
|
409
|
+
env: gateEnvLines(await readGateEnv(run.meta.repo)),
|
|
410
|
+
};
|
|
264
411
|
}
|
|
265
412
|
|
|
266
|
-
/** `
|
|
413
|
+
/** `gate_check` as both MCP servers expose it: item, command and paths together check an item; none of them,
|
|
414
|
+
* with a milestone, lists the milestone's recorded items. */
|
|
415
|
+
export async function gateCheckOrList(
|
|
416
|
+
deps: Deps,
|
|
417
|
+
a: { run: string; item?: string; command?: string; paths?: string[]; milestone?: string },
|
|
418
|
+
): Promise<unknown> {
|
|
419
|
+
if (a.item === undefined && a.command === undefined && a.paths === undefined) {
|
|
420
|
+
if (!a.milestone)
|
|
421
|
+
throw new CatherdError("E_INPUT_INVALID", "gate_check needs an item, or a milestone to list", {
|
|
422
|
+
fix: "pass item, command and paths to check an item, or only run and milestone to list the recorded items",
|
|
423
|
+
});
|
|
424
|
+
return gateList({ run: a.run, milestone: a.milestone });
|
|
425
|
+
}
|
|
426
|
+
if (a.item === undefined || a.command === undefined || a.paths === undefined)
|
|
427
|
+
throw new CatherdError("E_INPUT_INVALID", "gate_check needs item, command and paths together", {
|
|
428
|
+
fix: "pass all three to check an item, or none of them (with milestone) to list the recorded items",
|
|
429
|
+
});
|
|
430
|
+
return gateCheck(deps, { ...a, item: a.item, command: a.command, paths: a.paths });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* `gate_pass`: records that `command` passed on the current content of `paths`, with its evidence. A pass on
|
|
435
|
+
* uncommitted changes under `paths` names its commit `<HEAD>+uncommitted`: HEAD alone is not what it ran on.
|
|
436
|
+
*/
|
|
267
437
|
export async function gatePass(
|
|
268
438
|
deps: Deps,
|
|
269
439
|
i: { run: string; item: string; command: string; paths: string[]; evidence: string },
|
|
270
440
|
): Promise<{ recorded: true; hash: string; commit: string }> {
|
|
271
441
|
const run = findRun(i.run);
|
|
272
442
|
const paths = cleanPaths(i.paths);
|
|
273
|
-
const hash = await contentHash(run.meta.repo, paths);
|
|
274
|
-
const
|
|
443
|
+
const { hash, dirty } = await contentHash(run.meta.repo, paths);
|
|
444
|
+
const head = (await gitHead(run.meta.repo)) ?? "none";
|
|
445
|
+
const commit = dirty ? `${head}+uncommitted` : head;
|
|
275
446
|
const file = gatesFile(run.meta.repo);
|
|
276
447
|
ensureJsonlHeader(file, "gates");
|
|
277
448
|
appendJsonl(file, {
|
|
@@ -23,25 +23,51 @@ export type GlobalInstall =
|
|
|
23
23
|
| { state: "shadowed"; onPath: string | null }
|
|
24
24
|
| { state: "failed"; reason: string };
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
/** How long `bun add -g` may take: a blackholed or proxied registry would otherwise hold `init` for as long as Bun retries. */
|
|
27
|
+
export const installLimits = { timeoutMs: 120_000 };
|
|
28
|
+
|
|
29
|
+
/** How long a `catherd --version` probe may take before its version counts as unknown. */
|
|
30
|
+
export const versionLimits = { timeoutMs: 10_000 };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `cmd` with its output. Past `timeoutMs` it is killed and reported as failed with the reason; its output is
|
|
34
|
+
* not awaited then, since a child it started may still hold the pipes open.
|
|
35
|
+
*/
|
|
36
|
+
export async function run(
|
|
37
|
+
cmd: string[],
|
|
38
|
+
timeoutMs?: number,
|
|
39
|
+
): Promise<{ ok: boolean; stdout: string; output: string }> {
|
|
27
40
|
const p = Bun.spawn(cmd, {
|
|
28
41
|
env: scrubSecrets(process.env),
|
|
29
42
|
stdin: "ignore",
|
|
30
43
|
stdout: "pipe",
|
|
31
44
|
stderr: "pipe",
|
|
32
45
|
});
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
const out = new Response(p.stdout).text().catch(() => "");
|
|
47
|
+
const err = new Response(p.stderr).text().catch(() => "");
|
|
48
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
const late = new Promise<"late">((resolve) => {
|
|
50
|
+
if (timeoutMs !== undefined) timer = setTimeout(() => resolve("late"), timeoutMs);
|
|
51
|
+
});
|
|
52
|
+
const code = await Promise.race([p.exited, late]).finally(() => clearTimeout(timer));
|
|
53
|
+
if (code === "late") {
|
|
54
|
+
p.kill("SIGKILL");
|
|
55
|
+
const what = cmd.slice(1).join(" ");
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
stdout: "",
|
|
59
|
+
output: `${what} timed out after ${Math.round((timeoutMs ?? 0) / 1000)} s`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const [stdout, stderr] = await Promise.all([out, err]);
|
|
63
|
+
return { ok: code === 0, stdout, output: `${stderr}${stdout}` };
|
|
39
64
|
}
|
|
40
65
|
|
|
41
66
|
/** what `bin --version` prints on stdout (a warning on stderr must not make it look like another version) */
|
|
42
67
|
async function versionOf(bin: string | null): Promise<string | null> {
|
|
43
68
|
if (!bin) return null;
|
|
44
|
-
|
|
69
|
+
// a wedged `catherd` on PATH must not hold `init`: past the limit its version is unknown
|
|
70
|
+
const r = await run([bin, "--version"], versionLimits.timeoutMs).catch(() => null);
|
|
45
71
|
return r?.ok ? r.stdout.trim() || null : null;
|
|
46
72
|
}
|
|
47
73
|
|
|
@@ -71,10 +97,12 @@ export const realGlobalInstall: GlobalInstallDeps = {
|
|
|
71
97
|
return versionOf(Bun.which("catherd", { PATH }));
|
|
72
98
|
},
|
|
73
99
|
install: (version) =>
|
|
74
|
-
run([process.execPath, "add", "-g", `catherd-cli@${version}`]).catch(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
100
|
+
run([process.execPath, "add", "-g", `catherd-cli@${version}`], installLimits.timeoutMs).catch(
|
|
101
|
+
(e: unknown) => ({
|
|
102
|
+
ok: false,
|
|
103
|
+
output: errorMessage(e),
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
78
106
|
};
|
|
79
107
|
|
|
80
108
|
/**
|
|
@@ -86,7 +114,12 @@ export async function ensureGlobal(
|
|
|
86
114
|
deps: GlobalInstallDeps,
|
|
87
115
|
installing: () => void,
|
|
88
116
|
): Promise<GlobalInstall> {
|
|
89
|
-
if ((await deps.globalVersion()) === version)
|
|
117
|
+
if ((await deps.globalVersion()) === version) {
|
|
118
|
+
// current, but the launcher runs the catherd PATH finds first: another version there (or none) still
|
|
119
|
+
// makes it fall back to bunx, so say so as after an install
|
|
120
|
+
const onPath = await deps.pathVersion().catch(() => null);
|
|
121
|
+
return onPath === version ? { state: "current" } : { state: "shadowed", onPath };
|
|
122
|
+
}
|
|
90
123
|
installing();
|
|
91
124
|
// never rejects: a failed install is reported, and init goes on
|
|
92
125
|
const r = await deps.install(version).catch((e: unknown) => ({ ok: false, output: errorMessage(e) }));
|