@counterfact-labs/cli 0.1.2 → 0.2.3
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 +178 -38
- package/bundle/client-release.json +7 -0
- package/bundle/sealed.json +908 -0
- package/dist/adapters/anthropic.js +151 -0
- package/dist/adapters/completion.js +112 -0
- package/dist/adapters/durable-write.js +34 -0
- package/dist/adapters/files.js +140 -0
- package/dist/adapters/folder-path.js +6 -0
- package/dist/adapters/git-ignore.js +49 -0
- package/dist/adapters/git.js +44 -0
- package/dist/adapters/ignored-files.js +36 -0
- package/dist/adapters/instructions.js +35 -0
- package/dist/adapters/models.js +51 -0
- package/dist/adapters/output-paths.js +92 -0
- package/dist/adapters/prompt-cache.js +28 -0
- package/dist/adapters/public-instructions.js +36 -0
- package/dist/adapters/quality.js +6 -0
- package/dist/adapters/relay-plan.js +15 -0
- package/dist/adapters/release.js +57 -0
- package/dist/adapters/report.js +29 -0
- package/dist/adapters/repository-files.js +199 -0
- package/dist/adapters/repository-root.js +120 -0
- package/dist/adapters/results.js +9 -0
- package/dist/adapters/settings.js +25 -0
- package/dist/adapters/snapshot.js +68 -0
- package/dist/analysis-core/analysis/analysis-flags.js +1 -0
- package/dist/analysis-core/analysis/code-agents/skipped-files.js +1 -0
- package/dist/analysis-core/analysis/finding-quality/config.js +1 -0
- package/dist/analysis-core/analysis/finding-quality/policy.js +1 -0
- package/dist/analysis-core/analysis/headlines/attachment.js +1 -0
- package/dist/analysis-core/analysis/headlines/input.js +1 -0
- package/dist/analysis-core/analysis/headlines/types.js +1 -0
- package/dist/analysis-core/analysis/schemas.js +1 -0
- package/dist/analysis-core/analysis/source-notes/types.js +1 -0
- package/dist/analysis-core/chunks/2PSNOSJ2.js +1 -0
- package/dist/analysis-core/chunks/3227KXHF.js +1 -0
- package/dist/analysis-core/chunks/326F4XNG.js +1 -0
- package/dist/analysis-core/chunks/3MTXB4MK.js +6 -0
- package/dist/analysis-core/chunks/4F6ZIHXU.js +1 -0
- package/dist/analysis-core/chunks/57M4OQJM.js +1 -0
- package/dist/analysis-core/chunks/6YCABS5T.js +1 -0
- package/dist/analysis-core/chunks/724WZMYW.js +2 -0
- package/dist/analysis-core/chunks/74FPPPIG.js +1 -0
- package/dist/analysis-core/chunks/7QDOHWSY.js +27 -0
- package/dist/analysis-core/chunks/BZNHHLNP.js +1 -0
- package/dist/analysis-core/chunks/DTMHXKP6.js +7 -0
- package/dist/analysis-core/chunks/E7TFIHC5.js +1 -0
- package/dist/analysis-core/chunks/EH3QQ3T4.js +1 -0
- package/dist/analysis-core/chunks/EQTIRIMU.js +37 -0
- package/dist/analysis-core/chunks/FSLWD2PE.js +1 -0
- package/dist/analysis-core/chunks/G322LST6.js +1 -0
- package/dist/analysis-core/chunks/HTLIRVP7.js +1 -0
- package/dist/analysis-core/chunks/IBCFGUHI.js +1 -0
- package/dist/analysis-core/chunks/IL2KRP3T.js +1 -0
- package/dist/analysis-core/chunks/J4AXGE5W.js +1 -0
- package/dist/analysis-core/chunks/JF4GYMOZ.js +1 -0
- package/dist/analysis-core/chunks/KEKTZDIK.js +1 -0
- package/dist/analysis-core/chunks/KU5DINLW.js +1 -0
- package/dist/analysis-core/chunks/M7MMAUAK.js +1 -0
- package/dist/analysis-core/chunks/OFBJNHC6.js +1 -0
- package/dist/analysis-core/chunks/ORCYIIHB.js +60 -0
- package/dist/analysis-core/chunks/PVPEY7NF.js +1 -0
- package/dist/analysis-core/chunks/PYKAERZZ.js +1 -0
- package/dist/analysis-core/chunks/QDYMR6KS.js +1 -0
- package/dist/analysis-core/chunks/QPXNXMLC.js +1 -0
- package/dist/analysis-core/chunks/SL27GMEN.js +1 -0
- package/dist/analysis-core/chunks/STZOC7LN.js +1 -0
- package/dist/analysis-core/chunks/T5TREZCQ.js +1 -0
- package/dist/analysis-core/chunks/TDNTU4TF.js +1 -0
- package/dist/analysis-core/chunks/U5HOEVPE.js +1 -0
- package/dist/analysis-core/chunks/UEWL3GMQ.js +1 -0
- package/dist/analysis-core/chunks/VER355VN.js +1 -0
- package/dist/analysis-core/chunks/VFENG4MR.js +1 -0
- package/dist/analysis-core/chunks/VGYWI4X2.js +1 -0
- package/dist/analysis-core/chunks/Y5AUKKYG.js +1 -0
- package/dist/analysis-core/chunks/Y7JGSUOA.js +1 -0
- package/dist/analysis-core/chunks/YVDCJN7D.js +14 -0
- package/dist/analysis-core/chunks/ZAA44GPI.js +1 -0
- package/dist/analysis-core/chunks/ZMUBMYWN.js +1 -0
- package/dist/analysis-core/contracts/requests.js +1 -0
- package/dist/analysis-core/conventions/file-admission.js +1 -0
- package/dist/analysis-core/data/finding-types.js +1 -0
- package/dist/analysis-core/errors.js +1 -0
- package/dist/analysis-core/findings/presentation.js +1 -0
- package/dist/analysis-core/github/repo-key.js +1 -0
- package/dist/analysis-core/import/outside-loader-sources.js +1 -0
- package/dist/analysis-core/index.js +6 -0
- package/dist/analysis-core/llm/cache-policy.js +1 -0
- package/dist/analysis-core/llm/cost.js +1 -0
- package/dist/analysis-core/llm/execution.js +1 -0
- package/dist/analysis-core/llm/output-limit.js +1 -0
- package/dist/analysis-core/llm/slots.js +1 -0
- package/dist/analysis-core/llm/stable-json.js +1 -0
- package/dist/analysis-core/llm/structured-output-error.js +1 -0
- package/dist/analysis-core/map-limit.js +1 -0
- package/dist/analysis-core/requests/answer-formats.js +1 -0
- package/dist/analysis-core/requests/check-diagnostics.js +1 -0
- package/dist/analysis-core/requests/headline-request.js +1 -0
- package/dist/analysis-core/requests/instruction-text.js +1 -0
- package/dist/analysis-core/requests/local-plan.js +1 -0
- package/dist/analysis-core/requests/registry.js +1 -0
- package/dist/analysis-core/requests/schema-descriptions.js +1 -0
- package/dist/analysis-core/requests/shape-finding-input.js +1 -0
- package/dist/analysis-core/requests/template-version.js +1 -0
- package/dist/analysis-core/requests/tools.js +1 -0
- package/dist/analysis-core/settings.js +1 -0
- package/dist/bundle/seal/deserialise.js +75 -0
- package/dist/bundle/seal/encryption.js +25 -0
- package/dist/bundle/seal/metadata.js +69 -0
- package/dist/bundle/seal/open.js +38 -0
- package/dist/bundle/seal/planned-calls.js +20 -0
- package/dist/bundle/seal/signature.js +51 -0
- package/dist/cli/agent-folder.js +17 -0
- package/dist/cli/api-types.js +2 -0
- package/dist/cli/api.js +1 -0
- package/dist/cli/browser.js +23 -0
- package/dist/cli/claude-code-permission.js +20 -0
- package/dist/cli/commands/analyze.js +73 -0
- package/dist/cli/commands/login.js +51 -0
- package/dist/cli/completion-recovery.js +18 -0
- package/dist/cli/context.js +22 -0
- package/dist/cli/email-sign-in.js +56 -0
- package/dist/cli/git.js +39 -0
- package/dist/cli/intake-payload.js +18 -0
- package/dist/{main.js → cli/main.js} +23 -12
- package/dist/cli/mcp.js +297 -0
- package/dist/cli/output.js +10 -0
- package/dist/cli/relay-check.js +239 -0
- package/dist/cli/relay-preparation.js +31 -0
- package/dist/cli/sign-in-input.js +97 -0
- package/dist/cli/sign-in.js +37 -0
- package/dist/cli/suggested-email.js +10 -0
- package/dist/cli/summary.js +30 -0
- package/dist/contract/cli-api-messages.js +5 -0
- package/dist/contract/cli-refusals.js +22 -0
- package/dist/contract/cli-version.js +1 -0
- package/dist/contract/console-url.js +8 -0
- package/dist/contract/console.js +111 -0
- package/dist/contract/email-sign-in-messages.js +18 -0
- package/dist/contract/messages.js +252 -0
- package/dist/contract/release-key.js +2 -0
- package/dist/contract/runtime-environment.js +37 -0
- package/dist/core/analysis/finding-quality/config.js +4 -0
- package/dist/core/analysis/run-to-end.js +55 -0
- package/dist/core/analysis/structural-run.js +16 -0
- package/dist/gatekeeper/attestation.js +67 -0
- package/dist/gatekeeper/certificate-algorithms.js +18 -0
- package/dist/gatekeeper/certificate-path.js +99 -0
- package/dist/gatekeeper/cli-api.js +174 -0
- package/dist/gatekeeper/client.js +76 -0
- package/dist/gatekeeper/connection-exchange.js +60 -0
- package/dist/gatekeeper/connection.js +140 -0
- package/dist/gatekeeper/console.js +168 -0
- package/dist/gatekeeper/loopback.js +5 -0
- package/dist/gatekeeper/model.js +70 -0
- package/dist/gatekeeper/production-attestation.js +149 -0
- package/dist/gatekeeper/trust/hardware.js +21 -0
- package/dist/gatekeeper/trust/policy.js +42 -0
- package/dist/gatekeeper/trust/production.json +25 -0
- package/dist/setup-pings/client.js +31 -0
- package/dist/setup-pings/contract.js +2 -0
- package/dist/setup-pings/policy.js +3 -0
- package/dist/setup-pings/state.js +261 -0
- package/dist/setup-pings/worker.js +95 -0
- package/dist/transport/base64url.js +9 -0
- package/dist/transport/http-response.js +89 -0
- package/dist/transport/tls-exporter.js +5 -0
- package/package.json +57 -11
- package/LICENSE +0 -1
- package/dist/api-types.js +0 -2
- package/dist/api.js +0 -97
- package/dist/commands/check.js +0 -104
- package/dist/commands/login.js +0 -21
- package/dist/context.js +0 -27
- package/dist/files.js +0 -99
- package/dist/git.js +0 -21
- package/dist/gitignore.js +0 -70
- package/dist/mcp.js +0 -165
- package/dist/output.js +0 -23
- package/dist/secret-files.js +0 -5
- /package/dist/{commands → cli/commands}/balance.js +0 -0
- /package/dist/{commands → cli/commands}/finding.js +0 -0
- /package/dist/{commands → cli/commands}/logout.js +0 -0
- /package/dist/{credentials.js → cli/credentials.js} +0 -0
package/dist/cli/mcp.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { resolveFolderPath } from "../adapters/folder-path.js";
|
|
4
|
+
import { FINISH_FAILURE_MESSAGE, START_FAILURE_MESSAGE } from "../contract/cli-refusals.js";
|
|
5
|
+
import { errorCode, errorText } from "./api.js";
|
|
6
|
+
import { commandContext } from "./context.js";
|
|
7
|
+
import { analyze } from "./commands/analyze.js";
|
|
8
|
+
import { CLI_VERSION } from "../contract/cli-version.js";
|
|
9
|
+
import { finding } from "./commands/finding.js";
|
|
10
|
+
import { balance } from "./commands/balance.js";
|
|
11
|
+
import { ANALYZE_TOOL_NAME, analyzeToolAllowed } from "./claude-code-permission.js";
|
|
12
|
+
import { signInStatus, startSignIn } from "./sign-in.js";
|
|
13
|
+
import { readEmailSignIn } from "../adapters/settings.js";
|
|
14
|
+
import { emailSignInMessages } from "../contract/email-sign-in-messages.js";
|
|
15
|
+
import { EMAIL_SIGN_IN_TERMS, normalizeSignInEmail, validSignInCode, startEmailSignIn, signInWithCode } from "./email-sign-in.js";
|
|
16
|
+
import { resolveAgentFolder, resolveMcpAgentFolder } from "./agent-folder.js";
|
|
17
|
+
import { suggestedEmail } from "./suggested-email.js";
|
|
18
|
+
const legacyTools = [
|
|
19
|
+
{
|
|
20
|
+
name: ANALYZE_TOOL_NAME,
|
|
21
|
+
description: "Analyze the agent in a folder on this computer. Each call runs one analysis and charges the user's balance, so call it only when the user asks for an analysis. After edits, suggest an analysis and wait for the user to ask. Prices are listed on the top-up page and website. If the balance doesn't cover the analysis, show the returned message and top-up link.",
|
|
22
|
+
inputSchema: { type: "object", properties: { path: { type: "string", description: "The agent folder on this computer." } }, required: ["path"], additionalProperties: false },
|
|
23
|
+
},
|
|
24
|
+
{ name: "get_finding", description: "Get a finding's explanation, quoted lines, and suggested fixes.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false } },
|
|
25
|
+
{ name: "get_balance", description: "Get the balance and top-up link.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
26
|
+
{ name: "sign_in_status", description: "Check whether this computer is signed in to Counterfact, and whether the user has allowed the analysis tool in Claude Code. With wait: true, first wait for a sign-in started by sign_in to finish.", inputSchema: { type: "object", properties: { wait: { type: "boolean" } }, additionalProperties: false } },
|
|
27
|
+
{ name: "sign_in", description: "Open the Counterfact sign-in page in the user's browser. Then call sign_in_status with wait: true.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
28
|
+
];
|
|
29
|
+
class ProtocolError extends Error {
|
|
30
|
+
code;
|
|
31
|
+
constructor(code, message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function object(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
37
|
+
export async function serveMcp(input, output, options = {}) {
|
|
38
|
+
const cwd = options.cwd ?? process.cwd();
|
|
39
|
+
const guarded = (options.env ?? process.env).CF_MCP_PATH_GUARD !== "0";
|
|
40
|
+
const launch = guarded ? resolveFolderPath(cwd).catch(() => null) : Promise.resolve(cwd);
|
|
41
|
+
const lastAnalysis = new Map();
|
|
42
|
+
const emailSignIn = options.emailSignIn ?? readEmailSignIn(options.env);
|
|
43
|
+
const tools = emailSignIn ? [...legacyTools.slice(0, 3),
|
|
44
|
+
{
|
|
45
|
+
name: "sign_in_status",
|
|
46
|
+
description: "Check whether this computer is signed in to Counterfact and whether the user has allowed the analysis tool in Claude Code. Use path only to choose the agent folder for the local git email suggestion; otherwise use the launch folder. Analysis permission is always checked in the launch folder. A valid local git email is returned as suggestedEmail. Prefer the signed-in user's email already in your context; otherwise offer suggestedEmail. Ask the user to confirm an address before sending a code. With wait: true, wait only for browser sign-in to finish.",
|
|
47
|
+
inputSchema: { type: "object", properties: {
|
|
48
|
+
wait: { type: "boolean" },
|
|
49
|
+
path: { type: "string", description: "The chosen agent folder for the local git email suggestion. Defaults to the launch folder; does not change the analysis permission check." },
|
|
50
|
+
}, additionalProperties: false },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "sign_in",
|
|
54
|
+
description: `With email, send a Counterfact sign-in code only after the user confirms that address. Before calling, show: ${EMAIL_SIGN_IN_TERMS} Then ask the user for the code from their email and call sign_in_with_code. Without email, open the Counterfact sign-in page in the user's browser, show the returned link and code, then call sign_in_status with wait: true.`,
|
|
55
|
+
inputSchema: { type: "object", properties: {
|
|
56
|
+
email: { type: "string", description: "The email address confirmed by the user." },
|
|
57
|
+
}, additionalProperties: false },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "sign_in_with_code",
|
|
61
|
+
description: "Complete Counterfact email sign-in with the confirmed email address and the code supplied by the user. Save the sign-in on this computer. Never repeat the code in messages, logs, or reports. Do not request another code automatically.",
|
|
62
|
+
inputSchema: { type: "object", properties: {
|
|
63
|
+
email: { type: "string", description: "The email address confirmed by the user." },
|
|
64
|
+
code: { type: "string", description: "The sign-in code supplied by the user from their email." },
|
|
65
|
+
}, required: ["email", "code"], additionalProperties: false },
|
|
66
|
+
},
|
|
67
|
+
] : legacyTools;
|
|
68
|
+
let pendingSignIn;
|
|
69
|
+
const requests = new Map();
|
|
70
|
+
let queue = Promise.resolve();
|
|
71
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
72
|
+
async function toolCall(params, signal) {
|
|
73
|
+
if (!object(params) || typeof params.name !== "string")
|
|
74
|
+
throw new ProtocolError(-32602, "Choose a tool and its arguments.");
|
|
75
|
+
const args = params.arguments ?? {};
|
|
76
|
+
if (!object(args))
|
|
77
|
+
throw new ProtocolError(-32602, "Tool arguments must be an object.");
|
|
78
|
+
const name = params.name;
|
|
79
|
+
const allowed = name === ANALYZE_TOOL_NAME ? ["path"] : name === "get_finding" ? ["id"]
|
|
80
|
+
: name === "sign_in_status" ? (emailSignIn ? ["wait", "path"] : ["wait"])
|
|
81
|
+
: emailSignIn && name === "sign_in" ? ["email"] : emailSignIn && name === "sign_in_with_code" ? ["email", "code"] : [];
|
|
82
|
+
if (!tools.some(tool => tool.name === name) || Object.keys(args).some(key => !allowed.includes(key)))
|
|
83
|
+
throw new ProtocolError(-32602, "Unknown tool or argument.");
|
|
84
|
+
if (name === ANALYZE_TOOL_NAME && (typeof args.path !== "string" || !args.path.trim()))
|
|
85
|
+
throw new ProtocolError(-32602, "Provide a path.");
|
|
86
|
+
if (name === "get_finding" && (typeof args.id !== "string" || !args.id.trim()))
|
|
87
|
+
throw new ProtocolError(-32602, "Provide a finding id.");
|
|
88
|
+
if (name === "sign_in_status" && args.wait !== undefined && typeof args.wait !== "boolean")
|
|
89
|
+
throw new ProtocolError(-32602, "Provide an optional boolean wait.");
|
|
90
|
+
if (name === "sign_in_status" && "path" in args && (typeof args.path !== "string" || !args.path.trim()))
|
|
91
|
+
throw new ProtocolError(-32602, "Provide a path.");
|
|
92
|
+
const emailFlow = name === "sign_in_with_code" || (name === "sign_in" && "email" in args);
|
|
93
|
+
const email = emailFlow ? normalizeSignInEmail(args.email) : undefined;
|
|
94
|
+
if (emailFlow && !email)
|
|
95
|
+
throw new ProtocolError(-32602, emailSignInMessages.invalid_email);
|
|
96
|
+
if (name === "sign_in_with_code" && !validSignInCode(args.code))
|
|
97
|
+
throw new ProtocolError(-32602, emailSignInMessages.invalid_code);
|
|
98
|
+
let text = "", exitCode = 0;
|
|
99
|
+
let findingIds;
|
|
100
|
+
let url;
|
|
101
|
+
let signIn;
|
|
102
|
+
try {
|
|
103
|
+
const ctx = commandContext({ ...options, emailSignIn, signal, onCheckComplete: result => {
|
|
104
|
+
findingIds = result.findingIds ?? [];
|
|
105
|
+
url = result.url;
|
|
106
|
+
options.onCheckComplete?.(result);
|
|
107
|
+
}, write: chunk => {
|
|
108
|
+
// Progress is transient terminal text; only the final lines belong in a tool response.
|
|
109
|
+
if (!chunk.startsWith("\r"))
|
|
110
|
+
text += chunk;
|
|
111
|
+
} });
|
|
112
|
+
if (name === "get_finding")
|
|
113
|
+
await finding(args.id, ctx);
|
|
114
|
+
else if (name === "get_balance")
|
|
115
|
+
await balance(ctx);
|
|
116
|
+
else if (name === "sign_in_status") {
|
|
117
|
+
const pending = pendingSignIn;
|
|
118
|
+
const allowed = await analyzeToolAllowed(ctx.homeDir, cwd);
|
|
119
|
+
if (args.wait === true && pending) {
|
|
120
|
+
const cancel = () => pending.controller.abort();
|
|
121
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
122
|
+
try {
|
|
123
|
+
if (signal.aborted)
|
|
124
|
+
cancel();
|
|
125
|
+
await pending.flow.done;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
signal.removeEventListener("abort", cancel);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
signIn = { ...await signInStatus(ctx), analyzeToolAllowed: allowed };
|
|
132
|
+
text = signIn.signedIn ? "Signed in." : "Not signed in.";
|
|
133
|
+
if (emailSignIn) {
|
|
134
|
+
const suggestion = await suggestedEmail(resolveAgentFolder(args.path ?? ".", cwd), ctx.readEmail);
|
|
135
|
+
if (suggestion)
|
|
136
|
+
signIn.suggestedEmail = suggestion;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (emailFlow) {
|
|
140
|
+
if (pendingSignIn)
|
|
141
|
+
throw new Error(emailSignInMessages.browser_sign_in_pending);
|
|
142
|
+
if (name === "sign_in") {
|
|
143
|
+
const start = await startEmailSignIn(ctx, email);
|
|
144
|
+
signIn = { email: email, expiresIn: start.expiresIn };
|
|
145
|
+
text = `We sent a sign-in code to ${email}. Ask the user for the code from their email, then call sign_in_with_code with the same email and their code. Do not repeat the code.`;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
await signInWithCode(ctx, email, args.code);
|
|
149
|
+
signIn = { signedIn: true };
|
|
150
|
+
text = "Signed in.";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else if (name === "sign_in") {
|
|
154
|
+
if ((await signInStatus(ctx)).signedIn)
|
|
155
|
+
text = "Already signed in.";
|
|
156
|
+
else {
|
|
157
|
+
let pending = pendingSignIn;
|
|
158
|
+
if (!pending) {
|
|
159
|
+
// The flow outlives this call; a later wait call can cancel its polling.
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
const cancel = () => controller.abort();
|
|
162
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
163
|
+
try {
|
|
164
|
+
signal.throwIfAborted();
|
|
165
|
+
const flow = await startSignIn(commandContext({ ...options, emailSignIn, homeDir: ctx.homeDir, baseUrl: ctx.api.baseUrl, signal: controller.signal }));
|
|
166
|
+
pending = { flow, controller, opened: false };
|
|
167
|
+
pendingSignIn = pending;
|
|
168
|
+
const current = pending;
|
|
169
|
+
const clear = () => { if (pendingSignIn === current)
|
|
170
|
+
pendingSignIn = undefined; };
|
|
171
|
+
// Handle background failures even when the client never calls wait.
|
|
172
|
+
void flow.done.then(clear, clear);
|
|
173
|
+
pending.opened = await ctx.openBrowser(flow.pageUrl);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
signal.removeEventListener("abort", cancel);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
signIn = { pageUrl: pending.flow.pageUrl, userCode: pending.flow.userCode };
|
|
180
|
+
text = pending.opened ? `Opening your browser… If nothing opens, go to ${signIn.pageUrl} on any device and enter ${signIn.userCode}.`
|
|
181
|
+
: `Go to ${signIn.pageUrl} on any device and enter ${signIn.userCode}.`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const launchFolder = await launch;
|
|
186
|
+
if (guarded && !launchFolder)
|
|
187
|
+
throw new Error(START_FAILURE_MESSAGE);
|
|
188
|
+
const folder = guarded ? await resolveMcpAgentFolder(args.path, launchFolder, ctx.homeDir) : resolveAgentFolder(args.path, cwd);
|
|
189
|
+
const last = lastAnalysis.get(folder);
|
|
190
|
+
if (guarded && last !== undefined && ctx.now() - last < 10 * 60 * 1000) {
|
|
191
|
+
throw new Error(FINISH_FAILURE_MESSAGE);
|
|
192
|
+
}
|
|
193
|
+
let started = false;
|
|
194
|
+
try {
|
|
195
|
+
exitCode = await analyze(folder, {}, { ...ctx, onCheckStart: () => {
|
|
196
|
+
started = true;
|
|
197
|
+
if (guarded)
|
|
198
|
+
lastAnalysis.set(folder, ctx.now());
|
|
199
|
+
ctx.onCheckStart?.();
|
|
200
|
+
} });
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
if (guarded && started)
|
|
204
|
+
lastAnalysis.set(folder, ctx.now());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
text += errorText(error) + "\n";
|
|
210
|
+
exitCode = errorCode(error);
|
|
211
|
+
}
|
|
212
|
+
// Some clients (Claude Code among them) give the model only the structured result when one is present, so it
|
|
213
|
+
// repeats the text: without it the model cannot read the result.
|
|
214
|
+
return { content: [{ type: "text", text }], isError: exitCode !== 0,
|
|
215
|
+
structuredContent: { text, exitCode, ...(findingIds ? { findingIds } : {}), ...(url ? { url } : {}), ...signIn } };
|
|
216
|
+
}
|
|
217
|
+
async function respond(message, id, signal) {
|
|
218
|
+
if (signal.aborted)
|
|
219
|
+
return;
|
|
220
|
+
let reply;
|
|
221
|
+
try {
|
|
222
|
+
let result;
|
|
223
|
+
if (message.method === "initialize") {
|
|
224
|
+
const requested = object(message.params) ? message.params.protocolVersion : undefined;
|
|
225
|
+
result = { protocolVersion: ["2024-11-05", "2025-03-26", "2025-06-18"].includes(requested) ? requested : "2025-06-18",
|
|
226
|
+
capabilities: { tools: {} }, serverInfo: { name: "counterfact-labs", version: CLI_VERSION } };
|
|
227
|
+
}
|
|
228
|
+
else if (message.method === "ping")
|
|
229
|
+
result = {};
|
|
230
|
+
else if (message.method === "tools/list")
|
|
231
|
+
result = { tools };
|
|
232
|
+
else if (message.method === "tools/call")
|
|
233
|
+
result = await toolCall(message.params, signal);
|
|
234
|
+
else
|
|
235
|
+
throw new ProtocolError(-32601, "Method not found.");
|
|
236
|
+
reply = { jsonrpc: "2.0", id, result };
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
reply = errorReply(id, error);
|
|
240
|
+
}
|
|
241
|
+
// The caller stopped waiting; a late result must not look like a completed call.
|
|
242
|
+
// The output is an MCP stdio Writable carrying JSON-RPC, not an HTML HTTP response.
|
|
243
|
+
// nosemgrep: javascript.express.security.audit.xss.direct-response-write.direct-response-write
|
|
244
|
+
if (!signal.aborted && !output.write(JSON.stringify(reply) + "\n"))
|
|
245
|
+
await once(output, "drain");
|
|
246
|
+
}
|
|
247
|
+
for await (const line of lines) {
|
|
248
|
+
if (!line.trim())
|
|
249
|
+
continue;
|
|
250
|
+
let id = null;
|
|
251
|
+
let reply;
|
|
252
|
+
try {
|
|
253
|
+
let message;
|
|
254
|
+
try {
|
|
255
|
+
message = JSON.parse(line);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
throw new ProtocolError(-32700, "Invalid JSON.");
|
|
259
|
+
}
|
|
260
|
+
if (!object(message) || message.jsonrpc !== "2.0" || typeof message.method !== "string"
|
|
261
|
+
|| (message.id !== undefined && typeof message.id !== "string" && typeof message.id !== "number" && message.id !== null))
|
|
262
|
+
throw new ProtocolError(-32600, "Invalid request.");
|
|
263
|
+
if (message.id === undefined) {
|
|
264
|
+
if (message.method === "notifications/cancelled" && object(message.params)) {
|
|
265
|
+
const requestId = message.params.requestId;
|
|
266
|
+
if (typeof requestId === "string" || typeof requestId === "number")
|
|
267
|
+
requests.get(requestId)?.abort();
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
id = message.id;
|
|
272
|
+
const controller = new AbortController();
|
|
273
|
+
requests.set(id, controller);
|
|
274
|
+
// Keep tool requests in order while still reading cancellation notifications.
|
|
275
|
+
const request = message;
|
|
276
|
+
queue = queue.then(() => respond(request, id, controller.signal)).finally(() => { requests.delete(id); });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
reply = errorReply(id, error);
|
|
281
|
+
}
|
|
282
|
+
if (!output.write(JSON.stringify(reply) + "\n"))
|
|
283
|
+
await once(output, "drain");
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await queue;
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
pendingSignIn?.controller.abort();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function errorReply(id, error) {
|
|
293
|
+
return { jsonrpc: "2.0", id, error: {
|
|
294
|
+
code: error instanceof ProtocolError ? error.code : -32603,
|
|
295
|
+
message: error instanceof ProtocolError ? error.message : "The request could not be completed.",
|
|
296
|
+
} };
|
|
297
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function money(micro) {
|
|
2
|
+
const cents = Math.round(micro / 10_000);
|
|
3
|
+
const whole = Math.trunc(cents / 100);
|
|
4
|
+
const fraction = Math.abs(cents % 100);
|
|
5
|
+
return `${cents < 0 ? "-" : ""}$${Math.abs(whole)}${fraction ? `.${String(fraction).padStart(2, "0")}` : ""}`;
|
|
6
|
+
}
|
|
7
|
+
export function tasteText(taste, findingIds) {
|
|
8
|
+
return [taste.headline, ...taste.lines.map(line => ` ${line}`),
|
|
9
|
+
...(findingIds.length ? [`Finding ids: ${findingIds.join(", ")}`] : []), `Full results: ${taste.url}`, ""].join("\n");
|
|
10
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { resolveFolderPath } from "../adapters/folder-path.js";
|
|
2
|
+
import { savedAnalysisUrl } from "../contract/console-url.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { disabledSetupPings } from "../setup-pings/client.js";
|
|
5
|
+
import { saveCompletion, loadCompletion } from "../adapters/completion.js";
|
|
6
|
+
import { saveLocalResults } from "../adapters/results.js";
|
|
7
|
+
import { OutputPathError, outputPaths } from "../adapters/output-paths.js";
|
|
8
|
+
import { validateClientRelease } from "../adapters/release.js";
|
|
9
|
+
import { selectAnalysisExecution } from "../adapters/models.js";
|
|
10
|
+
import { prepareRelayCheck } from "./relay-preparation.js";
|
|
11
|
+
import { cliRefusals, COMMITTED_FILES_MESSAGE, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, refusalExitCode } from "../contract/cli-refusals.js";
|
|
12
|
+
import { ConsoleClient, ConsoleError } from "../gatekeeper/console.js";
|
|
13
|
+
import { GatekeeperClient } from "../gatekeeper/client.js";
|
|
14
|
+
import { connectRelay, relayOrigin } from "../gatekeeper/connection.js";
|
|
15
|
+
import productionTrust from "../gatekeeper/trust/production.json" with { type: "json" };
|
|
16
|
+
import { runAnalysisToEnd } from "../core/analysis/run-to-end.js";
|
|
17
|
+
import { formatSummary } from "./summary.js";
|
|
18
|
+
import { buildIntakePayload } from "./intake-payload.js";
|
|
19
|
+
import { completionRecoveryDecision, COMPLETION_SETTINGS_MISMATCH_MESSAGE, matchesCheck } from "./completion-recovery.js";
|
|
20
|
+
const stateLine = (status) => `Reservation ${status.state}; settlement ${status.settlement}; lease ${new Date(status.expiresAt).toLocaleString("en-US", { timeZone: "America/Los_Angeles", timeZoneName: "short" })}.\n`;
|
|
21
|
+
function requireIntakeReservation(status) {
|
|
22
|
+
if (status.state === "reserved")
|
|
23
|
+
return;
|
|
24
|
+
if (status.state === "closing")
|
|
25
|
+
throw new ConsoleError("reservation_closing");
|
|
26
|
+
if (status.state === "closed")
|
|
27
|
+
throw new ConsoleError("reservation_closed");
|
|
28
|
+
throw new ConsoleError("reservation_expired", "intake");
|
|
29
|
+
}
|
|
30
|
+
/** Retry an exact completed payload; a lost intake response must never purchase another analysis. */
|
|
31
|
+
async function submit(client, payload, signal) {
|
|
32
|
+
signal.throwIfAborted();
|
|
33
|
+
const status = await client.status(payload.reservationId);
|
|
34
|
+
const committed = (value) => ({ checkId: value.checkId, projectId: value.projectId, analysisId: value.analysisId, findingIds: value.findingIds, settlement: value.settlement });
|
|
35
|
+
if (!matchesCheck(status, payload))
|
|
36
|
+
throw new ConsoleError("check_conflict");
|
|
37
|
+
if (status.uploadState === "saved")
|
|
38
|
+
return committed(status);
|
|
39
|
+
requireIntakeReservation(status);
|
|
40
|
+
signal.throwIfAborted();
|
|
41
|
+
try {
|
|
42
|
+
return await client.intake(payload);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// Keep the refusal for display; giving up the saved payload requires confirmed recovery status.
|
|
46
|
+
const after = await client.status(payload.reservationId).catch(() => { throw error; });
|
|
47
|
+
if (!matchesCheck(after, payload))
|
|
48
|
+
throw new ConsoleError("check_conflict");
|
|
49
|
+
if (after.uploadState === "saved")
|
|
50
|
+
return committed(after);
|
|
51
|
+
// Exact idempotent retry only; a second uncertainty leaves the completion record for restart.
|
|
52
|
+
if (error instanceof ConsoleError && error.code === "unavailable") {
|
|
53
|
+
requireIntakeReservation(after);
|
|
54
|
+
signal.throwIfAborted();
|
|
55
|
+
return client.intake(payload);
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function runRelayCheck(folder, options) {
|
|
61
|
+
return runRelayLifecycle(folder, options);
|
|
62
|
+
}
|
|
63
|
+
/** The caller supplies the paid reservation; this runner owns its connection through cleanup. */
|
|
64
|
+
export function runReservedRelayCheck(folder, options) {
|
|
65
|
+
return runRelayLifecycle(folder, options, options);
|
|
66
|
+
}
|
|
67
|
+
async function runRelayLifecycle(folder, options, existing) {
|
|
68
|
+
const { config, settings, stdout, stderr } = options;
|
|
69
|
+
let client, reservationId, projectId;
|
|
70
|
+
let connection = existing?.connection;
|
|
71
|
+
let identity;
|
|
72
|
+
let completedPayload;
|
|
73
|
+
let completionOrigin;
|
|
74
|
+
let completed = false, uploaded = false, heartbeat;
|
|
75
|
+
let renewal;
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
|
|
78
|
+
const showSaved = (result) => {
|
|
79
|
+
const url = savedAnalysisUrl(result.projectId, config.CF_CONSOLE_URL);
|
|
80
|
+
options.onSaved?.({ ...result, url });
|
|
81
|
+
stdout(`Saved analysis ${result.analysisId}: ${url}\n`);
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
if (!config.CF_CONSOLE_URL || !existing && (!config.CF_RELAY_URL || !config.CF_CONSOLE_TOKEN)) {
|
|
85
|
+
throw new Error(cliRefusals.release_unavailable);
|
|
86
|
+
}
|
|
87
|
+
if (existing) {
|
|
88
|
+
requireIntakeReservation(existing.reservation);
|
|
89
|
+
if (existing.reservation.projectId !== existing.expectedProjectId)
|
|
90
|
+
throw new ConsoleError("check_conflict");
|
|
91
|
+
reservationId = existing.reservation.reservationId;
|
|
92
|
+
projectId = existing.reservation.projectId;
|
|
93
|
+
identity = existing.reservation;
|
|
94
|
+
client = existing.client;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
relayOrigin(config.CF_RELAY_URL);
|
|
98
|
+
client = new ConsoleClient({ url: config.CF_CONSOLE_URL, token: config.CF_CONSOLE_TOKEN });
|
|
99
|
+
}
|
|
100
|
+
// Signature verification precedes all folder reads, status calls and spending.
|
|
101
|
+
const validated = validateClientRelease(options.release, settings);
|
|
102
|
+
const { bundle } = validated;
|
|
103
|
+
folder = await resolveFolderPath(folder);
|
|
104
|
+
signal.throwIfAborted();
|
|
105
|
+
const prior = await loadCompletion(folder);
|
|
106
|
+
if (prior && (!prior.uploaded && !prior.finished || options.retryUpload)) {
|
|
107
|
+
// Recovering another check would leave the caller's paid reservation unexecuted.
|
|
108
|
+
if (existing && (prior.payload.reservationId !== reservationId || !matchesCheck(prior.payload, existing.reservation))) {
|
|
109
|
+
throw new ConsoleError("check_conflict");
|
|
110
|
+
}
|
|
111
|
+
completedPayload = prior.payload;
|
|
112
|
+
completionOrigin = prior.consoleOrigin;
|
|
113
|
+
uploaded = prior.uploaded ?? false;
|
|
114
|
+
const recovery = completionRecoveryDecision({ statusSucceeded: false,
|
|
115
|
+
localMismatch: prior.consoleOrigin !== new URL(config.CF_CONSOLE_URL).origin });
|
|
116
|
+
if (recovery.message)
|
|
117
|
+
throw new Error(recovery.message);
|
|
118
|
+
reservationId = prior.payload.reservationId;
|
|
119
|
+
identity = prior.payload;
|
|
120
|
+
completed = true;
|
|
121
|
+
const saved = await submit(client, prior.payload, signal);
|
|
122
|
+
uploaded = true;
|
|
123
|
+
await saveCompletion(folder, prior.payload, prior.consoleOrigin, true);
|
|
124
|
+
showSaved(saved);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
if (options.retryUpload)
|
|
129
|
+
throw new Error("The saved completion record is missing. Keep the local report and check the output directory.");
|
|
130
|
+
try {
|
|
131
|
+
await outputPaths(folder, ["findings.json", "summary.md"], options.outputDir);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code !== "ENOENT")
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
const { source, provenance, plan, start } = await prepareRelayCheck(folder, { ...options, signal, expectedCommit: existing?.expectedCommit }, validated);
|
|
138
|
+
let reservation = existing?.reservation;
|
|
139
|
+
if (!reservation) {
|
|
140
|
+
const started = await client.start(start);
|
|
141
|
+
projectId = started.projectId;
|
|
142
|
+
signal.throwIfAborted();
|
|
143
|
+
const created = await client.create({ ...started, checkId: randomUUID(), plan });
|
|
144
|
+
reservationId = created.reservationId;
|
|
145
|
+
identity = created;
|
|
146
|
+
signal.throwIfAborted();
|
|
147
|
+
// Attestation contains no source and precedes reserve, so a bad relay never starts paid work.
|
|
148
|
+
connection = await (options.connect ?? (url => connectRelay(url, { trust: productionTrust })))(config.CF_RELAY_URL);
|
|
149
|
+
signal.throwIfAborted();
|
|
150
|
+
reservation = await client.reserve({ reservationId });
|
|
151
|
+
}
|
|
152
|
+
const activeClient = client, activeId = reservationId;
|
|
153
|
+
let lease = reservation;
|
|
154
|
+
heartbeat = setInterval(() => {
|
|
155
|
+
if (renewal)
|
|
156
|
+
return;
|
|
157
|
+
renewal = activeClient.heartbeat(activeId).then(status => {
|
|
158
|
+
if (status.state !== "reserved")
|
|
159
|
+
throw new Error();
|
|
160
|
+
lease = status;
|
|
161
|
+
}).catch(() => { controller.abort(); }).finally(() => { renewal = undefined; });
|
|
162
|
+
}, 60000);
|
|
163
|
+
heartbeat.unref();
|
|
164
|
+
const execution = await selectAnalysisExecution(config, () => { throw new Error(); }, {
|
|
165
|
+
client: new GatekeeperClient({ mode: "production", connection: connection,
|
|
166
|
+
deadlineMs: () => Math.min(Date.parse(lease.expiresAt), lease.absoluteExpiresAt ? Date.parse(lease.absoluteExpiresAt) : Infinity), }), bundleVersion: bundle.version,
|
|
167
|
+
reservationId: activeId, reservationToken: reservation.reservationToken, instructions: { bundle, verificationKey: options.release.verificationKey }, signal,
|
|
168
|
+
});
|
|
169
|
+
options.onStart?.();
|
|
170
|
+
stderr("Reading the folder and running analysis.\n");
|
|
171
|
+
(options.setupPings ?? disabledSetupPings).ping("first_analysis_started");
|
|
172
|
+
const report = await runAnalysisToEnd(source, execution.model, settings, { execution });
|
|
173
|
+
signal.throwIfAborted();
|
|
174
|
+
const paths = await saveLocalResults(folder, report, source.skippedFiles, options.outputDir);
|
|
175
|
+
const payload = buildIntakePayload({ checkId: reservation.checkId, projectId: projectId, reservationId,
|
|
176
|
+
bundleVersion: bundle.version, relayVersion: options.release.relayVersion, counterfactLocalVersion: options.release.localVersion }, provenance, report);
|
|
177
|
+
completionOrigin = new URL(config.CF_CONSOLE_URL).origin;
|
|
178
|
+
await saveCompletion(folder, payload, completionOrigin);
|
|
179
|
+
completedPayload = payload;
|
|
180
|
+
completed = true;
|
|
181
|
+
const saved = await submit(client, payload, signal);
|
|
182
|
+
uploaded = true;
|
|
183
|
+
await saveCompletion(folder, payload, completionOrigin, true);
|
|
184
|
+
stdout(formatSummary(report));
|
|
185
|
+
stdout(`\nSaved ${paths.findingsPath}\nSaved ${paths.summaryPath}\n`);
|
|
186
|
+
showSaved(saved);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
let status;
|
|
191
|
+
if (client && reservationId) {
|
|
192
|
+
try {
|
|
193
|
+
status = await (completed || uploaded ? client.status(reservationId) : client.fail(reservationId));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// An uncertain server outcome cannot prove a refund.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (status?.uploadState === "saved" && identity && matchesCheck(status, identity)) {
|
|
200
|
+
if (completedPayload) {
|
|
201
|
+
try {
|
|
202
|
+
await saveCompletion(folder, completedPayload, completionOrigin, true);
|
|
203
|
+
}
|
|
204
|
+
catch { /* Committed server state still survives a local write failure. */ }
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
showSaved({ projectId: status.projectId, analysisId: status.analysisId, findingIds: status.findingIds });
|
|
208
|
+
}
|
|
209
|
+
catch { /* Output failure must not change a server outcome. */ }
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
const recovery = completionRecoveryDecision({ statusSucceeded: status !== undefined, status, completedPayload });
|
|
213
|
+
if (completedPayload && recovery.finished) {
|
|
214
|
+
try {
|
|
215
|
+
await saveCompletion(folder, completedPayload, completionOrigin, false, true);
|
|
216
|
+
}
|
|
217
|
+
catch { /* Keep the pending record if the durable write cannot be confirmed. */ }
|
|
218
|
+
}
|
|
219
|
+
const known = error instanceof ConsoleError || error instanceof OutputPathError || error instanceof Error && ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE].includes(error.message)
|
|
220
|
+
|| error.message.startsWith("The saved completion") || error.message === COMPLETION_SETTINGS_MISMATCH_MESSAGE);
|
|
221
|
+
const message = known ? error.message : FINISH_FAILURE_MESSAGE;
|
|
222
|
+
try {
|
|
223
|
+
options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, message });
|
|
224
|
+
stderr(`${message}\n`);
|
|
225
|
+
if (status && !(error instanceof ConsoleError && error.code === "low_balance"))
|
|
226
|
+
stderr(stateLine(status));
|
|
227
|
+
}
|
|
228
|
+
catch { /* Output failure must not change a server outcome. */ }
|
|
229
|
+
return refusalExitCode(error instanceof ConsoleError ? error.code : undefined);
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
clearInterval(heartbeat);
|
|
233
|
+
await renewal;
|
|
234
|
+
try {
|
|
235
|
+
connection?.close();
|
|
236
|
+
}
|
|
237
|
+
catch { /* A committed intake is already authoritative. */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { outsideLoadersEnabled } from "../analysis-core/settings.js";
|
|
2
|
+
import { readLocalFolder } from "../adapters/files.js";
|
|
3
|
+
import { snapshotProvenance } from "../adapters/snapshot.js";
|
|
4
|
+
import { validateClientRelease } from "../adapters/release.js";
|
|
5
|
+
import { prepareRelayPlan } from "../adapters/relay-plan.js";
|
|
6
|
+
import { COMMITTED_FILES_MESSAGE } from "../contract/cli-refusals.js";
|
|
7
|
+
import { repositoryFacts } from "./git.js";
|
|
8
|
+
/** Absolute checkout paths stay local; repository facts own the start's commit. */
|
|
9
|
+
export function buildRelayStartPayload(facts, files) {
|
|
10
|
+
const { remoteUrl, relPath, folderName, branch, commit, dirty } = facts;
|
|
11
|
+
return { execution: "local", remoteUrl, relPath, folderName, branch, commit, dirty, files };
|
|
12
|
+
}
|
|
13
|
+
/** Recovery can reuse signature validation without reading the folder again. */
|
|
14
|
+
export async function prepareRelayCheck(folder, options, validated = validateClientRelease(options.release, options.settings)) {
|
|
15
|
+
const { bundle, tools } = validated;
|
|
16
|
+
options.signal?.throwIfAborted();
|
|
17
|
+
// Discovery must agree with Git before publishing repository-relative captures.
|
|
18
|
+
const earlyFacts = outsideLoadersEnabled(options.settings) ? await (options.facts ?? repositoryFacts)(folder) : undefined;
|
|
19
|
+
options.signal?.throwIfAborted();
|
|
20
|
+
const read = await readLocalFolder(options.settings, folder, options.trialAdmission, { captureSnapshot: true,
|
|
21
|
+
...(earlyFacts ? { expectedRepositoryRoot: earlyFacts.repoRoot ?? null } : {}) });
|
|
22
|
+
const source = { ...read, files: Object.freeze({ ...read.files }) };
|
|
23
|
+
const provenance = await snapshotProvenance(source.sourceBase ?? folder, source.snapshot, source.sourceBase ? { selectionFolder: folder } : undefined);
|
|
24
|
+
if (options.expectedCommit !== undefined && provenance.commit !== options.expectedCommit)
|
|
25
|
+
throw new Error(COMMITTED_FILES_MESSAGE);
|
|
26
|
+
const { plan, files } = prepareRelayPlan({ bundle, tools, settings: options.settings, relayVersion: options.release.relayVersion, source });
|
|
27
|
+
options.signal?.throwIfAborted();
|
|
28
|
+
const facts = earlyFacts ?? await (options.facts ?? repositoryFacts)(folder);
|
|
29
|
+
options.signal?.throwIfAborted();
|
|
30
|
+
return { source, provenance, plan, start: buildRelayStartPayload(source.sourceBase ? { ...facts, relPath: "" } : facts, files) };
|
|
31
|
+
}
|