@intentius/chant 0.90.0 → 0.91.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/cli/handlers/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +14 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +6 -4
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/index.d.ts +9 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +19 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +10 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts +29 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +9 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward.d.ts +140 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +51 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +71 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +10 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +184 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +51 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +172 -0
- package/dist/workspace/declared-kinds.d.ts +12 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -2
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +121 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +52 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +78 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/operator.ts +107 -2
- package/src/cli/handlers/run.test.ts +19 -0
- package/src/cli/handlers/run.ts +53 -1
- package/src/cli/main.test.ts +40 -0
- package/src/cli/main.ts +78 -2
- package/src/cli/mcp/workspace-tools.test.ts +14 -1
- package/src/cli/mcp/workspace-tools.ts +49 -5
- package/src/cli/registry.ts +31 -0
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/op/builders.ts +5 -0
- package/src/op/discover.ts +71 -0
- package/src/op/index.ts +16 -3
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +216 -18
- package/src/op/local-output.ts +13 -0
- package/src/op/op-ir.ts +14 -0
- package/src/op/operator.ts +75 -4
- package/src/op/runtime.ts +6 -0
- package/src/op/runtimes/local.ts +3 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward.test.ts +212 -0
- package/src/op/steward.ts +253 -0
- package/src/op/types.ts +53 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +326 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/check-contract.test.ts +3 -1
- package/src/workspace/check.schema.json +15 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +197 -0
- package/src/workspace/checks/boxes.ts +307 -0
- package/src/workspace/checks/records.ts +25 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +19 -2
- package/src/workspace/decide.test.ts +224 -0
- package/src/workspace/decide.ts +576 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +172 -0
- package/src/workspace/declaration.ts +137 -0
- package/src/workspace/declared-kinds.ts +25 -2
- package/src/workspace/intent.schema.json +4 -1
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +273 -0
- package/src/workspace/points-write.schema.json +489 -0
- package/src/workspace/points.schema.json +710 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +14 -1
- package/src/workspace/reason-codes.ts +33 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-cli.ts +15 -2
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records.schema.json +17 -0
- package/src/workspace/records.ts +33 -3
- package/src/workspace/status-contract.test.ts +178 -0
- package/src/workspace/status-stewards.ts +225 -0
- package/src/workspace/status.schema.json +230 -4
- package/src/workspace/status.ts +106 -5
- package/src/workspace/work-cli.test.ts +180 -0
- package/src/workspace/work-cli.ts +246 -0
- package/src/workspace/work-lease.schema.json +233 -0
- package/src/workspace/work-readiness-chud.test.ts +145 -0
|
@@ -85,7 +85,7 @@ describe("which servers have the workspace tools", () => {
|
|
|
85
85
|
const { tools } = (await rpc(server(cwd), "tools/list")) as { tools: { name: string }[] };
|
|
86
86
|
expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(names));
|
|
87
87
|
}
|
|
88
|
-
expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-records", "records-new", "records-amend", "records-review", "records-close"]);
|
|
88
|
+
expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-records", "workspace-points", "records-new", "records-amend", "records-review", "records-close", "points-answer"]);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
test("a server outside any workspace, or given none, does not", async () => {
|
|
@@ -115,6 +115,19 @@ describe("reads", () => {
|
|
|
115
115
|
expect(recordsOf(only.structuredContent).map((r) => r.id)).toEqual(["fix-001"]);
|
|
116
116
|
}, 120_000);
|
|
117
117
|
|
|
118
|
+
test("workspace-points returns the document chant workspace points --json prints, and points-answer the write's (ws-058)", async () => {
|
|
119
|
+
const s = server();
|
|
120
|
+
const res = await call(s, "workspace-points", { open: true });
|
|
121
|
+
expect(res.isError).toBeUndefined();
|
|
122
|
+
expect(res.structuredContent).toEqual(cli(["workspace", "points", "--open", "--json"]));
|
|
123
|
+
expect(res.structuredContent).toMatchObject({ open: true, sources: [], points: [], questions: [] });
|
|
124
|
+
// The generated workspace declares no answer kind, so there is nothing to answer.
|
|
125
|
+
const answered = await call(s, "points-answer", { id: "slice-tier-000000000000", answer: "small", by: ["alice"] });
|
|
126
|
+
expect(answered.structuredContent).toMatchObject({ verb: "answer", error: { code: "points-undeclared" } });
|
|
127
|
+
const bad = await call(s, "points-answer", { id: "slice-tier-000000000000", answer: "small", by: [] });
|
|
128
|
+
expect(bad.isError).toBe(true);
|
|
129
|
+
}, 120_000);
|
|
130
|
+
|
|
118
131
|
test("an error document is returned as a document, and a flag-shaped value is refused before chant runs", async () => {
|
|
119
132
|
const s = server();
|
|
120
133
|
const missing = await call(s, "workspace-records", { kind: "nowhere/none.kind.mjs" });
|
|
@@ -6,15 +6,17 @@
|
|
|
6
6
|
* is a thin call into the code the CLI runs:
|
|
7
7
|
*
|
|
8
8
|
* - The reads (`workspace-ls`, `workspace-status`, `workspace-graph`,
|
|
9
|
-
* `workspace-records`) run `chant workspace <command>
|
|
10
|
-
* chant this server runs as, in the server's
|
|
9
|
+
* `workspace-records`, `workspace-points`) run `chant workspace <command>
|
|
10
|
+
* ... --json` with the chant this server runs as, in the server's
|
|
11
|
+
* directory, and return the
|
|
11
12
|
* document it printed, unchanged, with its reason codes (#2536). Running the
|
|
12
13
|
* command, rather than calling into it, keeps every rule it has, handing the
|
|
13
14
|
* read to the workspace root's pinned chant included (ws-021), and keeps
|
|
14
15
|
* anything a command prints away from the protocol on stdout.
|
|
15
16
|
* - The writes (`records-new`, `records-amend`, `records-review`,
|
|
16
|
-
* `records-close`) call the functions `chant workspace
|
|
17
|
-
* review|close` call, and return the
|
|
17
|
+
* `records-close`, `points-answer`) call the functions `chant workspace
|
|
18
|
+
* records new|amend|review|close` and `points answer` call, and return the
|
|
19
|
+
* same JSON result. They keep every rule
|
|
18
20
|
* the CLI keeps: the kind's schema, a closed record never changing, an
|
|
19
21
|
* approved one changing only as its approval rule allows, a dissent needing
|
|
20
22
|
* a note, and `sign` using this host's configured key or refusing with the
|
|
@@ -75,7 +77,7 @@ export const workspaceReadTools: ToolDefinition[] = [
|
|
|
75
77
|
{
|
|
76
78
|
name: "workspace-status",
|
|
77
79
|
description:
|
|
78
|
-
"What each member has released to an environment, from the lifecycle ledgers: chant workspace status <env> --json. Returns that document unchanged (status.schema.json).",
|
|
80
|
+
"What each member has released to an environment, its gates, and the steward that runs it with each Op's last run, from the declaration and the lifecycle ledgers: chant workspace status <env> --json. Returns that document unchanged (status.schema.json).",
|
|
79
81
|
inputSchema: {
|
|
80
82
|
type: "object",
|
|
81
83
|
properties: {
|
|
@@ -119,6 +121,19 @@ export const workspaceReadTools: ToolDefinition[] = [
|
|
|
119
121
|
},
|
|
120
122
|
},
|
|
121
123
|
},
|
|
124
|
+
{
|
|
125
|
+
name: "workspace-points",
|
|
126
|
+
description:
|
|
127
|
+
"The workspace's decision points and the questions asked of them: chant workspace points --json (points.schema.json, ws-058). A question is open while it is escalated to people, or proposed by a model and not yet confirmed; each lists any model's answer with its confidence and threshold. Returns the document unchanged. chant calls no model to answer this.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: "object",
|
|
130
|
+
properties: {
|
|
131
|
+
open: { type: "boolean", description: "Only the open questions (--open)." },
|
|
132
|
+
kind: { type: "string", description: "One answer kind file, in place of the declared ones (--kind)." },
|
|
133
|
+
at: atProp,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
122
137
|
];
|
|
123
138
|
|
|
124
139
|
export const workspaceWriteTools: ToolDefinition[] = [
|
|
@@ -191,6 +206,23 @@ export const workspaceWriteTools: ToolDefinition[] = [
|
|
|
191
206
|
required: ["id"],
|
|
192
207
|
},
|
|
193
208
|
},
|
|
209
|
+
{
|
|
210
|
+
name: "points-answer",
|
|
211
|
+
description:
|
|
212
|
+
"Record people's answer to an open decision point question, or confirm a model's proposal: chant workspace points answer. Refused unless the point's quorum is met: distinct people, none holding the agent role, each holding one of the quorum's roles when it names any. An answered question never changes. " +
|
|
213
|
+
PROTOCOL,
|
|
214
|
+
inputSchema: {
|
|
215
|
+
type: "object",
|
|
216
|
+
properties: {
|
|
217
|
+
id: { type: "string", description: "The question's id, as workspace-points lists it." },
|
|
218
|
+
answer: { type: ["string", "boolean"], description: "One of the question's candidates; for a noul, true or false." },
|
|
219
|
+
by: { type: "array", items: { type: "string" }, description: "Each person who answered (--by)." },
|
|
220
|
+
kind: { type: "string", description: "The answer kind file. Without it, the declared answer kinds." },
|
|
221
|
+
dryRun: dryRunProp,
|
|
222
|
+
},
|
|
223
|
+
required: ["id", "answer", "by"],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
194
226
|
];
|
|
195
227
|
|
|
196
228
|
/** A tool call that can't be made as given: the client gets it as an error result, and nothing runs. */
|
|
@@ -249,6 +281,10 @@ export function readArgv(tool: string, params: Record<string, unknown>): string[
|
|
|
249
281
|
if (intent !== undefined) return ["workspace", "graph", "--intent", intent, ...kindArgs, ...atArgs, "--json"];
|
|
250
282
|
return ["workspace", "graph", ...kindArgs, ...atArgs, "--json"];
|
|
251
283
|
}
|
|
284
|
+
case "workspace-points": {
|
|
285
|
+
const kind = str(params, "kind");
|
|
286
|
+
return ["workspace", "points", ...(bool(params, "open") ? ["--open"] : []), ...(kind !== undefined ? ["--kind", kind] : []), ...atArgs, "--json"];
|
|
287
|
+
}
|
|
252
288
|
case "workspace-records": {
|
|
253
289
|
const kind = str(params, "kind");
|
|
254
290
|
const since = str(params, "since");
|
|
@@ -389,6 +425,14 @@ export function createWorkspaceTools(options: WorkspaceToolsOptions): WorkspaceT
|
|
|
389
425
|
cwd,
|
|
390
426
|
});
|
|
391
427
|
},
|
|
428
|
+
"points-answer": async (params) => {
|
|
429
|
+
const { answerPoint } = await import("../../workspace/decide");
|
|
430
|
+
const answer = params.answer;
|
|
431
|
+
if (typeof answer !== "string" && typeof answer !== "boolean") throw new ToolInputError("answer must be a string, or true or false");
|
|
432
|
+
const by = params.by;
|
|
433
|
+
if (!Array.isArray(by) || by.length === 0 || !by.every((b) => typeof b === "string" && b.trim() !== "")) throw new ToolInputError("by must list each person who answered");
|
|
434
|
+
return answerPoint({ cwd, id: str(params, "id", true)!, answer, by: by as string[], kind: str(params, "kind"), dryRun: bool(params, "dryRun") });
|
|
435
|
+
},
|
|
392
436
|
"records-close": async (params) => {
|
|
393
437
|
const w = await write();
|
|
394
438
|
const { closeRecord, RECORDS_CLOSE_SCHEMA_ID } = await import("../../workspace/records-close");
|
package/src/cli/registry.ts
CHANGED
|
@@ -287,6 +287,18 @@ export interface ParsedArgs {
|
|
|
287
287
|
kind?: string;
|
|
288
288
|
/** Every `--kind` given, in order: `chant workspace graph --intent` reads each (#2651). */
|
|
289
289
|
kinds?: string[];
|
|
290
|
+
/** Every `--by` given, in order: `chant workspace points answer` counts each person toward the quorum (#2739). */
|
|
291
|
+
bys?: string[];
|
|
292
|
+
/** `chant workspace points --open` (#2739): only the questions still open. */
|
|
293
|
+
open?: boolean;
|
|
294
|
+
/** `chant workspace points ask <point> --inputs <file|->` (#2739): the inputs, as a JSON object. */
|
|
295
|
+
inputs?: string;
|
|
296
|
+
/** `chant workspace points ask <point> --response <file>` (#2739): a POST /v1/systemone response the caller got from a backend. */
|
|
297
|
+
response?: string;
|
|
298
|
+
/** `chant workspace points ask <point> --subject <id>` (#2739): what the question is about. */
|
|
299
|
+
subject?: string;
|
|
300
|
+
/** `chant workspace points answer <id> --answer <value>` (#2739): the people's answer. */
|
|
301
|
+
answer?: string;
|
|
290
302
|
/** `chant workspace graph --composites` (#2662): print each composite instance with the components that can deploy it. */
|
|
291
303
|
composites?: boolean;
|
|
292
304
|
/** `chant workspace graph --intent <path[:start-end]>` (#2651): the region the intent graph is over. */
|
|
@@ -369,6 +381,8 @@ export interface ParsedArgs {
|
|
|
369
381
|
component?: string;
|
|
370
382
|
/** `chant components release record --digest <sha256:...>` (#568) — artifact digest to record, joining this release to the build archive/ledger. Also `chant components export --digest <manifestDigest>` (#929) — a build archive manifest digest to export directly, bypassing env/component resolution. */
|
|
371
383
|
digest?: string;
|
|
384
|
+
/** `chant components release record --release-plan <file>` (ws-055, #2733) — path to a release plan JSON file, persisted content-addressed to `_plans/<digest>.json` on chant/lifecycle and read back through the read contract (`chant workspace status --json`). The plan's own `digest` field supplies `--digest` when it is omitted, and must match it when both are given. Distinct from `--plan` (#2300, below), the Op gate-approval plan digest. */
|
|
385
|
+
releasePlanFile?: string;
|
|
372
386
|
/** Every `--digest` value, in order (#2602). `chant components promote --digest <component>=<sha256:...>` is repeatable, one per component; the other commands read the single {@link digest}. */
|
|
373
387
|
digests?: string[];
|
|
374
388
|
/** `chant components release record --git-sha <sha>` (#568) — git commit the deploy was built from. */
|
|
@@ -413,6 +427,23 @@ export interface ParsedArgs {
|
|
|
413
427
|
interval?: string;
|
|
414
428
|
/** `chant operator --lease-ttl <duration>` (#1485) — how long an acquired lease is valid before it's reclaimable by another operator. Default: 5m. */
|
|
415
429
|
leaseTtl?: string;
|
|
430
|
+
/**
|
|
431
|
+
* `chant operator --steward [<name>]` (#2731) — run a declared steward's
|
|
432
|
+
* local form: its scheduled Ops on their crons, under the steward's own
|
|
433
|
+
* lease. `""` when the flag is given without a name, which picks the
|
|
434
|
+
* project's only steward.
|
|
435
|
+
*/
|
|
436
|
+
steward?: string;
|
|
437
|
+
/** `chant workspace work claim|renew|release <id> --holder <name>` (#2732): who holds, or releases, the work lease. */
|
|
438
|
+
holder?: string;
|
|
439
|
+
/** `chant run <op> --work <id>` (#2748): the work item an Op with a work lease runs under. */
|
|
440
|
+
work?: string;
|
|
441
|
+
/** `chant workspace work claim|renew <id> --ttl <seconds|duration>` (#2732): how long the lease lasts unless renewed. */
|
|
442
|
+
ttl?: string;
|
|
443
|
+
/** `chant workspace work renew|release <id> --token <token>` (#2732): the fencing token the caller holds. */
|
|
444
|
+
token?: string;
|
|
445
|
+
/** `chant workspace work release <id> --outcome <text>` (#2732): how the work ended, such as done or not_done. */
|
|
446
|
+
outcome?: string;
|
|
416
447
|
/** `chant operator --once` (#1485) — run a single round and exit, instead of looping until Ctrl-C. Also the offline test/cron-invoker story. */
|
|
417
448
|
once?: boolean;
|
|
418
449
|
/** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* `scan-vulnerabilities` capability over an injected scanner.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync } from "node:fs";
|
|
8
|
+
import { readFileSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
9
10
|
import { join } from "node:path";
|
|
10
11
|
import { describe, test, expect } from "vitest";
|
|
11
12
|
import {
|
|
@@ -15,8 +16,13 @@ import {
|
|
|
15
16
|
createToolVulnScanner,
|
|
16
17
|
createScanVulnerabilitiesCapability,
|
|
17
18
|
autoDetectVulnScanner,
|
|
19
|
+
sbomPackages,
|
|
20
|
+
compareVersions,
|
|
21
|
+
scanWithDatabase,
|
|
22
|
+
createDatabaseVulnScanner,
|
|
18
23
|
type VulnFinding,
|
|
19
24
|
type VulnScanner,
|
|
25
|
+
type AdvisoryDatabase,
|
|
20
26
|
} from "./vuln-scan";
|
|
21
27
|
import { ToolNotAvailableError } from "./process-runner";
|
|
22
28
|
import type { SbomDocument } from "./sbom-generator";
|
|
@@ -178,6 +184,123 @@ describe("exploitability parsing (#1463)", () => {
|
|
|
178
184
|
});
|
|
179
185
|
});
|
|
180
186
|
|
|
187
|
+
// ── offline advisory database (ws-056, INTENTIUS/chant#2735) ─────────────────
|
|
188
|
+
// Ported from chud's supply-chain.mjs tests: an SBOM's purl-derived packages
|
|
189
|
+
// matched against a local, offline advisory database — no scanner binary, no
|
|
190
|
+
// network.
|
|
191
|
+
|
|
192
|
+
const SPDX_SBOM: SbomDocument = {
|
|
193
|
+
format: "spdx",
|
|
194
|
+
mediaType: "application/spdx+json",
|
|
195
|
+
bytes: JSON.stringify({
|
|
196
|
+
packages: [
|
|
197
|
+
{ name: "lodash", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: "pkg:npm/lodash@4.17.20" }] },
|
|
198
|
+
{ name: "left-pad", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: "pkg:npm/left-pad@1.3.0" }] },
|
|
199
|
+
{ name: "no-purl" },
|
|
200
|
+
],
|
|
201
|
+
}),
|
|
202
|
+
generator: "lockfile",
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const CYCLONEDX_SBOM: SbomDocument = {
|
|
206
|
+
format: "cyclonedx",
|
|
207
|
+
mediaType: "application/vnd.cyclonedx+json",
|
|
208
|
+
bytes: JSON.stringify({
|
|
209
|
+
components: [{ name: "lodash", purl: "pkg:npm/lodash@4.17.20" }, { name: "left-pad", purl: "pkg:npm/left-pad@1.3.0" }],
|
|
210
|
+
}),
|
|
211
|
+
generator: "lockfile",
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const DB: AdvisoryDatabase = {
|
|
215
|
+
advisories: [
|
|
216
|
+
{ id: "CVE-2024-0001", package: "lodash", severity: "critical", introduced: "4.0.0", fixed: "4.17.21" },
|
|
217
|
+
{ id: "CVE-2024-0002", package: "left-pad", severity: "low" },
|
|
218
|
+
{ id: "CVE-2024-0003", package: "lodash", ecosystem: "pypi", severity: "high" },
|
|
219
|
+
],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
describe("sbomPackages", () => {
|
|
223
|
+
test("reads purls from an SPDX document's externalRefs, skipping entries with none", () => {
|
|
224
|
+
const packages = sbomPackages(SPDX_SBOM);
|
|
225
|
+
expect(packages).toEqual([
|
|
226
|
+
{ ecosystem: "npm", name: "lodash", version: "4.17.20" },
|
|
227
|
+
{ ecosystem: "npm", name: "left-pad", version: "1.3.0" },
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("reads purls from a CycloneDX document's components", () => {
|
|
232
|
+
expect(sbomPackages(CYCLONEDX_SBOM)).toEqual([
|
|
233
|
+
{ ecosystem: "npm", name: "lodash", version: "4.17.20" },
|
|
234
|
+
{ ecosystem: "npm", name: "left-pad", version: "1.3.0" },
|
|
235
|
+
]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("deduplicates identical ecosystem/name/version", () => {
|
|
239
|
+
const doc = { bytes: JSON.stringify({ components: [{ purl: "pkg:npm/x@1.0.0" }, { purl: "pkg:npm/x@1.0.0" }] }) };
|
|
240
|
+
expect(sbomPackages(doc)).toHaveLength(1);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("compareVersions", () => {
|
|
245
|
+
test("compares numerically, not lexically (1.9.0 < 1.10.0)", () => {
|
|
246
|
+
expect(compareVersions("1.9.0", "1.10.0")).toBe(-1);
|
|
247
|
+
expect(compareVersions("1.10.0", "1.9.0")).toBe(1);
|
|
248
|
+
expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("a pre-release sorts before its release", () => {
|
|
252
|
+
expect(compareVersions("1.2.0-rc.1", "1.2.0")).toBe(-1);
|
|
253
|
+
expect(compareVersions("1.2.0", "1.2.0-rc.1")).toBe(1);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("scanWithDatabase", () => {
|
|
258
|
+
test("matches an SBOM's packages against the database by ecosystem + name, introduced <= version < fixed", () => {
|
|
259
|
+
const findings = scanWithDatabase(SPDX_SBOM, DB);
|
|
260
|
+
expect(findings).toHaveLength(2);
|
|
261
|
+
const lodash = findings.find((f) => f.cveId === "CVE-2024-0001");
|
|
262
|
+
expect(lodash).toEqual({ cveId: "CVE-2024-0001", severity: "critical", package: "lodash", installedVersion: "4.17.20", fixedVersion: "4.17.21", fixable: true });
|
|
263
|
+
const leftPad = findings.find((f) => f.cveId === "CVE-2024-0002");
|
|
264
|
+
expect(leftPad).toMatchObject({ package: "left-pad", fixable: false });
|
|
265
|
+
expect(leftPad!.fixedVersion).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("a version at or after `fixed` is not affected", () => {
|
|
269
|
+
const doc = { bytes: JSON.stringify({ components: [{ purl: "pkg:npm/lodash@4.17.21" }] }) };
|
|
270
|
+
expect(scanWithDatabase(doc, DB)).toHaveLength(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("an ecosystem mismatch does not match (pypi advisory, npm package)", () => {
|
|
274
|
+
const findings = scanWithDatabase(SPDX_SBOM, DB);
|
|
275
|
+
expect(findings.some((f) => f.cveId === "CVE-2024-0003")).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("accepts a bare advisories array as well as { advisories: [...] }", () => {
|
|
279
|
+
expect(scanWithDatabase(SPDX_SBOM, DB.advisories)).toHaveLength(2);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("createDatabaseVulnScanner", () => {
|
|
284
|
+
test("scans by reading the database file at scan time", async () => {
|
|
285
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-vulndb-"));
|
|
286
|
+
const dbFile = join(dir, "advisories.json");
|
|
287
|
+
writeFileSync(dbFile, JSON.stringify(DB));
|
|
288
|
+
const scanner = createDatabaseVulnScanner(dbFile);
|
|
289
|
+
const findings = await scanner.scan({ sbom: SPDX_SBOM });
|
|
290
|
+
expect(findings).toHaveLength(2);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("composes with scan-vulnerabilities like any other VulnScanner", async () => {
|
|
294
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-vulndb-"));
|
|
295
|
+
const dbFile = join(dir, "advisories.json");
|
|
296
|
+
writeFileSync(dbFile, JSON.stringify(DB));
|
|
297
|
+
const cap = createScanVulnerabilitiesCapability(createDatabaseVulnScanner(dbFile));
|
|
298
|
+
const out = await cap.run(ctx, { sbom: SPDX_SBOM, digest: "sha256:abc" });
|
|
299
|
+
expect(out.findings).toHaveLength(2);
|
|
300
|
+
expect(out.digest).toBe("sha256:abc");
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
181
304
|
describe("createToolVulnScanner (grype, via MockProcessRunner)", () => {
|
|
182
305
|
test("scans the SBOM with `grype sbom:<file>` and parses the result", async () => {
|
|
183
306
|
const mock = createMockProcessRunner({ tools: { grype: true }, responses: { "grype sbom:": GRYPE_JSON } });
|
|
@@ -13,9 +13,17 @@
|
|
|
13
13
|
* inline fake `VulnScanner` (an object literal with a canned `scan()`, see
|
|
14
14
|
* ./vuln-gate.test.ts) and never invoke a real scanner, network, or vuln DB.
|
|
15
15
|
* The scanner's vuln DB currency is the tool's job, not chant's.
|
|
16
|
+
*
|
|
17
|
+
* `createDatabaseVulnScanner` (ws-056, INTENTIUS/chant#2735) is the one
|
|
18
|
+
* hermetic exception: a `VulnScanner` matched against a local, offline
|
|
19
|
+
* advisory-database file instead of a real scanner binary — no `grype`/
|
|
20
|
+
* `trivy` required, deterministic, at the cost of covering only the
|
|
21
|
+
* advisories the database names. Ported from chud's `supply-chain.mjs`
|
|
22
|
+
* (`scanWithDatabase`/`sbomPackages`/`compareVersions`), which built its own
|
|
23
|
+
* copy of this because `scan-vulnerabilities` had no offline backend.
|
|
16
24
|
*/
|
|
17
25
|
|
|
18
|
-
import { writeFileSync } from "node:fs";
|
|
26
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
19
27
|
import { tmpdir } from "node:os";
|
|
20
28
|
import { join } from "node:path";
|
|
21
29
|
import { createHash } from "node:crypto";
|
|
@@ -174,6 +182,139 @@ export function parseTrivyOutput(stdout: string): VulnFinding[] {
|
|
|
174
182
|
return out;
|
|
175
183
|
}
|
|
176
184
|
|
|
185
|
+
// ── offline advisory database ────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
/** One package an SBOM names, by purl: the ecosystem, its name and its installed version. */
|
|
188
|
+
export interface SbomPackage {
|
|
189
|
+
ecosystem: string;
|
|
190
|
+
name: string;
|
|
191
|
+
version: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The packages an SPDX or CycloneDX document lists, by purl (SPDX's
|
|
196
|
+
* `externalRefs` with `referenceType: "purl"`, CycloneDX's `components[].purl`),
|
|
197
|
+
* deduplicated by ecosystem/name/version. Parses `doc.bytes` (a string, as
|
|
198
|
+
* every `SbomDocument` carries it) rather than requiring a pre-parsed object,
|
|
199
|
+
* so a caller can pass a `scan-vulnerabilities` input's `sbom` straight
|
|
200
|
+
* through.
|
|
201
|
+
*/
|
|
202
|
+
export function sbomPackages(doc: Pick<SbomDocument, "bytes">): SbomPackage[] {
|
|
203
|
+
const parsed = JSON.parse(doc.bytes) as {
|
|
204
|
+
packages?: Array<{ externalRefs?: Array<{ referenceType?: string; referenceLocator?: string }> }>;
|
|
205
|
+
components?: Array<{ purl?: string }>;
|
|
206
|
+
};
|
|
207
|
+
const purls = [
|
|
208
|
+
...(parsed.packages ?? []).flatMap((p) =>
|
|
209
|
+
(p.externalRefs ?? []).filter((r) => r.referenceType === "purl").map((r) => r.referenceLocator ?? ""),
|
|
210
|
+
),
|
|
211
|
+
...(parsed.components ?? []).map((c) => c.purl).filter((p): p is string => Boolean(p)),
|
|
212
|
+
];
|
|
213
|
+
const seen = new Map<string, SbomPackage>();
|
|
214
|
+
for (const purl of purls) {
|
|
215
|
+
const m = /^pkg:([^/]+)\/(.+)@([^?#]+)/.exec(purl);
|
|
216
|
+
if (!m) continue;
|
|
217
|
+
const pkg: SbomPackage = { ecosystem: m[1], name: decodeURIComponent(m[2]), version: decodeURIComponent(m[3]) };
|
|
218
|
+
seen.set(`${pkg.ecosystem}:${pkg.name}@${pkg.version}`, pkg);
|
|
219
|
+
}
|
|
220
|
+
return [...seen.values()];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compare two dotted version strings numerically, a pre-release sorting
|
|
225
|
+
* before its release (`1.2.0-rc.1` < `1.2.0`). Not full semver (no build
|
|
226
|
+
* metadata, no ordering between differing pre-release labels beyond "a
|
|
227
|
+
* pre-release exists") — just enough to place an installed version between
|
|
228
|
+
* an advisory's `introduced` and `fixed`.
|
|
229
|
+
*/
|
|
230
|
+
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
|
231
|
+
const parse = (v: string) => {
|
|
232
|
+
const [core, pre] = String(v).replace(/^v/, "").split("-", 2);
|
|
233
|
+
return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
|
|
234
|
+
};
|
|
235
|
+
const x = parse(a);
|
|
236
|
+
const y = parse(b);
|
|
237
|
+
for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
|
|
238
|
+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
|
|
239
|
+
if (d) return Math.sign(d) as -1 | 1;
|
|
240
|
+
}
|
|
241
|
+
if (x.pre && !y.pre) return -1;
|
|
242
|
+
if (!x.pre && y.pre) return 1;
|
|
243
|
+
if (x.pre === y.pre) return 0;
|
|
244
|
+
return x.pre! < y.pre! ? -1 : 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** One advisory in a local, offline advisory database (`AdvisoryDatabase.advisories`). */
|
|
248
|
+
export interface Advisory {
|
|
249
|
+
/** Advisory id, e.g. `"CVE-2024-12345"` or `"GHSA-…"`. */
|
|
250
|
+
id: string;
|
|
251
|
+
/** Ecosystem the advisory applies to (npm, pypi, …). Default: `"npm"`. */
|
|
252
|
+
ecosystem?: string;
|
|
253
|
+
/** Affected package name, matched against the SBOM's purl-derived package name. */
|
|
254
|
+
package: string;
|
|
255
|
+
severity?: string;
|
|
256
|
+
/** First affected version. Default: `"0"` (affects every version up to `fixed`). */
|
|
257
|
+
introduced?: string;
|
|
258
|
+
/** First version that fixes it. Omitted for a vulnerability with no fix yet. */
|
|
259
|
+
fixed?: string;
|
|
260
|
+
summary?: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A local, offline advisory database: `{ "advisories": [...] }` (or a bare array, for convenience). */
|
|
264
|
+
export interface AdvisoryDatabase {
|
|
265
|
+
advisories: Advisory[];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Match an SBOM's packages against a local, offline advisory database —
|
|
270
|
+
* no scanner binary, no network, deterministic. A package is affected when
|
|
271
|
+
* `introduced <= installedVersion < fixed` (or unconditionally affected when
|
|
272
|
+
* the advisory names no `fixed`). Findings are `VulnFinding`-shaped, so they
|
|
273
|
+
* feed `vuln-gate` exactly like `grype`/`trivy` output does; every
|
|
274
|
+
* exploitability field (`epss`, `inKev`, …) an offline database can't supply
|
|
275
|
+
* is left `undefined`, never defaulted to a false negative.
|
|
276
|
+
*/
|
|
277
|
+
export function scanWithDatabase(doc: Pick<SbomDocument, "bytes">, db: AdvisoryDatabase | Advisory[]): VulnFinding[] {
|
|
278
|
+
const advisories = Array.isArray(db) ? db : (db.advisories ?? []);
|
|
279
|
+
const findings: VulnFinding[] = [];
|
|
280
|
+
for (const pkg of sbomPackages(doc)) {
|
|
281
|
+
for (const a of advisories) {
|
|
282
|
+
if ((a.ecosystem ?? "npm") !== pkg.ecosystem || a.package !== pkg.name) continue;
|
|
283
|
+
if (compareVersions(pkg.version, a.introduced ?? "0") < 0) continue;
|
|
284
|
+
if (a.fixed && compareVersions(pkg.version, a.fixed) >= 0) continue;
|
|
285
|
+
findings.push({
|
|
286
|
+
cveId: a.id,
|
|
287
|
+
severity: normalizeSeverity(a.severity),
|
|
288
|
+
package: pkg.name,
|
|
289
|
+
installedVersion: pkg.version,
|
|
290
|
+
...(a.fixed ? { fixedVersion: a.fixed } : {}),
|
|
291
|
+
fixable: Boolean(a.fixed),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return findings;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* A `VulnScanner` backed by a local, offline advisory database file (JSON:
|
|
300
|
+
* `{ "advisories": [...] }`, see `AdvisoryDatabase`) instead of a real
|
|
301
|
+
* scanner binary — the hermetic fallback `autoDetectVulnScanner`'s doc
|
|
302
|
+
* comment says a scan doesn't have ("there is no hermetic fallback, a scan
|
|
303
|
+
* needs a real vuln DB"): deterministic, offline, no `grype`/`trivy`
|
|
304
|
+
* install required, at the cost of covering only the advisories the
|
|
305
|
+
* database names. Reads the file once per call (a database pinned in the
|
|
306
|
+
* repo changes rarely; callers that scan repeatedly should cache the parsed
|
|
307
|
+
* `AdvisoryDatabase` and call `scanWithDatabase` directly instead).
|
|
308
|
+
*/
|
|
309
|
+
export function createDatabaseVulnScanner(dbFile: string): VulnScanner {
|
|
310
|
+
return {
|
|
311
|
+
async scan(input) {
|
|
312
|
+
const db = JSON.parse(readFileSync(dbFile, "utf8")) as AdvisoryDatabase | Advisory[];
|
|
313
|
+
return scanWithDatabase(input.sbom, db);
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
177
318
|
/**
|
|
178
319
|
* A real `VulnScanner` that writes the SBOM to a temp file and scans it with
|
|
179
320
|
* `grype` (default) or `trivy`, through the injectable `ProcessRunner`.
|
package/src/lifecycle/git.ts
CHANGED
|
@@ -1100,34 +1100,70 @@ export async function readBlobBySha(sha: string, opts?: { cwd?: string }): Promi
|
|
|
1100
1100
|
* Push one arbitrary ref (e.g. a lease ref) to the remote, guarded the same
|
|
1101
1101
|
* way {@link pushLifecycle} guards the ledger branch: `--force-with-lease`
|
|
1102
1102
|
* keyed to the remote SHA last observed locally, so a concurrent push from a
|
|
1103
|
-
* second machine is rejected rather than silently clobbered.
|
|
1104
|
-
*
|
|
1105
|
-
* commit descending from the previous one
|
|
1106
|
-
*
|
|
1103
|
+
* second machine is rejected rather than silently clobbered. The lease alone
|
|
1104
|
+
* lets a push replace a value that is not an ancestor — a lease ref's value is
|
|
1105
|
+
* a bare blob SHA, not a commit descending from the previous one — as long as
|
|
1106
|
+
* the remote still holds the expected value. No `--force` beside it: git lets
|
|
1107
|
+
* `--force` override `--force-with-lease`, which made every lease push
|
|
1108
|
+
* unconditional before #2732.
|
|
1107
1109
|
*
|
|
1108
1110
|
* Returns `false` (never throws) when no remote is configured — a
|
|
1109
1111
|
* remote-less project's lease is local-only by construction (see
|
|
1110
1112
|
* `./lease.ts`'s module doc), or when the push itself is rejected (the
|
|
1111
|
-
* caller re-reads and retries; see `acquireLease`).
|
|
1113
|
+
* caller re-reads and retries; see `acquireLease`). {@link pushRefStatus}
|
|
1114
|
+
* tells those two apart.
|
|
1112
1115
|
*/
|
|
1113
|
-
export async function pushRef(ref: string, opts?: { cwd?: string }): Promise<boolean> {
|
|
1116
|
+
export async function pushRef(ref: string, opts?: { cwd?: string; expect?: string | null }): Promise<boolean> {
|
|
1117
|
+
return (await pushRefStatus(ref, opts)) === "pushed";
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** What {@link pushRefStatus} did: the remote took the ref, there is no remote, or the remote refused it. */
|
|
1121
|
+
export type PushRefStatus = "pushed" | "no-remote" | "rejected";
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* {@link pushRef}, saying why a push did not land (#2732).
|
|
1125
|
+
*
|
|
1126
|
+
* `opts.expect` is the value the caller last saw the ref at on the remote
|
|
1127
|
+
* (`null`: absent there), and becomes the `--force-with-lease` expectation.
|
|
1128
|
+
* Without it the expectation is `refs/remotes/<remote>/<ref>`, which nothing
|
|
1129
|
+
* fetches a lease ref into, so it reads as "absent" and only a first push
|
|
1130
|
+
* lands. A lease passes the value of its own remote-tracking ref instead
|
|
1131
|
+
* (`./lease.ts`'s `readLease`).
|
|
1132
|
+
*
|
|
1133
|
+
* A ref that no longer exists locally is pushed as a deletion, under the same
|
|
1134
|
+
* expectation, so a released lease is released on the remote too. Deleting a
|
|
1135
|
+
* ref the caller expects to be absent already is a no-op that reports
|
|
1136
|
+
* `pushed`.
|
|
1137
|
+
*/
|
|
1138
|
+
export async function pushRefStatus(ref: string, opts?: { cwd?: string; expect?: string | null }): Promise<PushRefStatus> {
|
|
1114
1139
|
const rt = getRuntime();
|
|
1115
1140
|
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
1116
|
-
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return
|
|
1141
|
+
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return "no-remote";
|
|
1117
1142
|
const remote = remoteResult.stdout.trim().split("\n")[0];
|
|
1118
1143
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1144
|
+
let expected: string | null;
|
|
1145
|
+
if (opts?.expect !== undefined) {
|
|
1146
|
+
expected = opts.expect;
|
|
1147
|
+
} else {
|
|
1148
|
+
const remoteRef = `refs/remotes/${remote}/${ref.replace(/^refs\//, "")}`;
|
|
1149
|
+
const expectedResult = await rt.spawn(["git", "rev-parse", "--verify", remoteRef], { cwd: opts?.cwd });
|
|
1150
|
+
expected = expectedResult.exitCode === 0 ? expectedResult.stdout.trim() : null;
|
|
1151
|
+
}
|
|
1122
1152
|
const lease = `${ref}:${expected ?? ""}`;
|
|
1123
1153
|
|
|
1154
|
+
const local = await readRefSha(ref, opts);
|
|
1155
|
+
if (local === null && expected === null) return "pushed";
|
|
1156
|
+
const refspec = local === null ? `:${ref}` : `${ref}:${ref}`;
|
|
1124
1157
|
const pushResult = await rt.spawn(
|
|
1125
|
-
["git", "push",
|
|
1158
|
+
["git", "push", `--force-with-lease=${lease}`, remote, refspec],
|
|
1126
1159
|
{ cwd: opts?.cwd },
|
|
1127
1160
|
);
|
|
1128
|
-
return pushResult.exitCode === 0;
|
|
1161
|
+
return pushResult.exitCode === 0 ? "pushed" : "rejected";
|
|
1129
1162
|
}
|
|
1130
1163
|
|
|
1164
|
+
/** What {@link fetchRefIntoStatus} found: the ref fetched, no remote, the remote answered without the ref, or the fetch failed. */
|
|
1165
|
+
export type FetchRefStatus = "fetched" | "no-remote" | "missing" | "failed";
|
|
1166
|
+
|
|
1131
1167
|
/**
|
|
1132
1168
|
* Fetch one arbitrary remote ref into a local ref of a possibly *different*
|
|
1133
1169
|
* name (#1959 finding 3). `+` forces the update even when it isn't a
|
|
@@ -1145,12 +1181,26 @@ export async function fetchRefInto(
|
|
|
1145
1181
|
localRef: string,
|
|
1146
1182
|
opts?: { cwd?: string },
|
|
1147
1183
|
): Promise<boolean> {
|
|
1184
|
+
return (await fetchRefIntoStatus(remoteRef, localRef, opts)) === "fetched";
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* {@link fetchRefInto}, saying why nothing was fetched (#2732). `missing`
|
|
1189
|
+
* means the remote answered and does not carry the ref, which for a lease is
|
|
1190
|
+
* the news that it was released there.
|
|
1191
|
+
*/
|
|
1192
|
+
export async function fetchRefIntoStatus(
|
|
1193
|
+
remoteRef: string,
|
|
1194
|
+
localRef: string,
|
|
1195
|
+
opts?: { cwd?: string },
|
|
1196
|
+
): Promise<FetchRefStatus> {
|
|
1148
1197
|
const rt = getRuntime();
|
|
1149
1198
|
const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
|
|
1150
|
-
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return
|
|
1199
|
+
if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return "no-remote";
|
|
1151
1200
|
const remote = remoteResult.stdout.trim().split("\n")[0];
|
|
1152
|
-
const fetchResult = await rt.spawn(["git", "fetch", remote, `+${remoteRef}:${localRef}`], { cwd: opts?.cwd });
|
|
1153
|
-
|
|
1201
|
+
const fetchResult = await rt.spawn(["git", "fetch", remote, `+${remoteRef}:${localRef}`], { cwd: opts?.cwd, env: C_LOCALE_ENV });
|
|
1202
|
+
if (fetchResult.exitCode === 0) return "fetched";
|
|
1203
|
+
return NO_SUCH_REMOTE_REF_RE.test(fetchResult.stderr ?? "") ? "missing" : "failed";
|
|
1154
1204
|
}
|
|
1155
1205
|
|
|
1156
1206
|
/**
|
|
@@ -285,6 +285,28 @@ describe("lifecycle/lease", () => {
|
|
|
285
285
|
}
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
+
// #2732: pushRef passed `--force` beside `--force-with-lease`, and git
|
|
289
|
+
// lets `--force` win, so every lease push was unconditional. It now pushes
|
|
290
|
+
// under the lease alone, expecting the value last fetched, so a renewal
|
|
291
|
+
// lands and a release deletes the ref on the remote.
|
|
292
|
+
test("a renewal and a release reach the remote, under the lease the last fetch saw (#2732)", async () => {
|
|
293
|
+
const { clonePath, remotePath, cleanup } = await setupClonePair();
|
|
294
|
+
try {
|
|
295
|
+
const ref = leaseRef("fountain-converge");
|
|
296
|
+
const first = await acquireLease("fountain-converge", "operator-a", { cwd: clonePath, ttlMs: 60_000 });
|
|
297
|
+
expect(first.acquired).toBe(true);
|
|
298
|
+
const renewed = await acquireLease("fountain-converge", "operator-a", { cwd: clonePath, ttlMs: 120_000 });
|
|
299
|
+
expect(renewed.lease?.token).toBe(first.lease?.token);
|
|
300
|
+
const local = git(["rev-parse", ref], clonePath).stdout.trim();
|
|
301
|
+
expect(git(["rev-parse", ref], remotePath).stdout.trim()).toBe(local);
|
|
302
|
+
|
|
303
|
+
expect(await releaseLease("fountain-converge", "operator-a", renewed.lease!.token, { cwd: clonePath })).toBe(true);
|
|
304
|
+
expect(git(["rev-parse", "--verify", ref], remotePath).exitCode).not.toBe(0);
|
|
305
|
+
} finally {
|
|
306
|
+
await cleanup();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
288
310
|
// ── #1959 finding 3 ──────────────────────────────────────────────────
|
|
289
311
|
//
|
|
290
312
|
// `readLease` used to fetch the remote lease ref directly into the same
|