@open-cr-agent/cli 0.5.0 → 0.6.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/cloud/account-salt.d.ts +6 -0
- package/dist/cloud/account-salt.js +33 -0
- package/dist/{review → cloud}/account-settings.js +5 -4
- package/dist/cloud/browser.d.ts +17 -0
- package/dist/cloud/browser.js +37 -0
- package/dist/cloud/client.d.ts +93 -0
- package/dist/cloud/client.js +221 -0
- package/dist/cloud/credentials.d.ts +18 -0
- package/dist/cloud/credentials.js +52 -0
- package/dist/cloud/deps.d.ts +14 -0
- package/dist/cloud/deps.js +34 -0
- package/dist/cloud/file-lock.d.ts +14 -0
- package/dist/cloud/file-lock.js +77 -0
- package/dist/cloud/findings.d.ts +8 -0
- package/dist/{review/cloud-findings.js → cloud/findings.js} +10 -32
- package/dist/cloud/gateway-token.d.ts +18 -0
- package/dist/cloud/gateway-token.js +63 -0
- package/dist/cloud/memory.d.ts +8 -0
- package/dist/cloud/memory.js +68 -0
- package/dist/cloud/private-file.d.ts +8 -0
- package/dist/cloud/private-file.js +46 -0
- package/dist/{review/cloud-providers.d.ts → cloud/providers.d.ts} +12 -6
- package/dist/cloud/providers.js +132 -0
- package/dist/cloud/review.d.ts +28 -0
- package/dist/cloud/review.js +76 -0
- package/dist/cloud/settings.d.ts +22 -0
- package/dist/cloud/settings.js +64 -0
- package/dist/cloud/upload.d.ts +20 -0
- package/dist/{review/cloud-upload.js → cloud/upload.js} +74 -71
- package/dist/commands/login.d.ts +5 -0
- package/dist/commands/login.js +164 -0
- package/dist/commands/memory.d.ts +3 -0
- package/dist/{memory.js → commands/memory.js} +21 -20
- package/dist/commands/metrics.d.ts +45 -0
- package/dist/{metrics.js → commands/metrics.js} +32 -45
- package/dist/{plugins-command.d.ts → commands/plugins.d.ts} +3 -9
- package/dist/{plugins-command.js → commands/plugins.js} +29 -53
- package/dist/{review → commands/review}/args.d.ts +7 -10
- package/dist/{review → commands/review}/args.js +11 -16
- package/dist/commands/review/deliver.d.ts +7 -0
- package/dist/commands/review/deliver.js +56 -0
- package/dist/commands/review/deps.d.ts +18 -0
- package/dist/commands/review/deps.js +2 -0
- package/dist/commands/review/execute-run.d.ts +13 -0
- package/dist/commands/review/execute-run.js +97 -0
- package/dist/commands/review/exit-code.d.ts +4 -0
- package/dist/commands/review/exit-code.js +29 -0
- package/dist/commands/review/plan-prices.d.ts +3 -0
- package/dist/commands/review/plan-prices.js +18 -0
- package/dist/{review → commands/review}/plan-render.js +22 -2
- package/dist/commands/review/plan-run.d.ts +5 -0
- package/dist/commands/review/plan-run.js +38 -0
- package/dist/commands/review/plan-schema.d.ts +97 -0
- package/dist/commands/review/plan-schema.js +34 -0
- package/dist/{review → commands/review}/progress.d.ts +3 -5
- package/dist/{review → commands/review}/progress.js +3 -3
- package/dist/{review → commands/review}/provenance.d.ts +4 -2
- package/dist/{review → commands/review}/provenance.js +2 -3
- package/dist/{review → commands/review}/render.js +1 -1
- package/dist/commands/review/resolve-run.d.ts +29 -0
- package/dist/commands/review/resolve-run.js +125 -0
- package/dist/commands/review/review-options.d.ts +6 -0
- package/dist/commands/review/review-options.js +35 -0
- package/dist/{review → commands/review}/runtimes.js +1 -1
- package/dist/{review → commands/review}/sarif-input.d.ts +0 -1
- package/dist/{review → commands/review}/sarif-input.js +5 -5
- package/dist/commands/review/sarif.d.ts +96 -0
- package/dist/{review → commands/review}/sarif.js +29 -7
- package/dist/commands/review/settings-sources.d.ts +17 -0
- package/dist/commands/review/settings-sources.js +30 -0
- package/dist/commands/review/target.d.ts +38 -0
- package/dist/commands/review/target.js +126 -0
- package/dist/commands/review.d.ts +7 -0
- package/dist/commands/review.js +34 -0
- package/dist/config/cli-config.d.ts +41 -0
- package/dist/config/cli-config.js +199 -0
- package/dist/{review/remote-config.d.ts → config/remote.d.ts} +3 -3
- package/dist/{review/remote-config.js → config/remote.js} +5 -23
- package/dist/{review/config.d.ts → config/schema.d.ts} +8 -31
- package/dist/config/schema.js +138 -0
- package/dist/config/settings.d.ts +37 -0
- package/dist/config/settings.js +133 -0
- package/dist/config/user-dir.d.ts +3 -0
- package/dist/config/user-dir.js +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +10 -1
- package/dist/internal.d.ts +6 -0
- package/dist/internal.js +7 -0
- package/dist/io/exit.d.ts +9 -0
- package/dist/io/exit.js +12 -0
- package/dist/io/output.d.ts +4 -0
- package/dist/io/output.js +2 -0
- package/dist/io/usage-error.d.ts +5 -0
- package/dist/io/usage-error.js +8 -0
- package/dist/main.js +2 -2
- package/dist/{review/account-plugins.d.ts → plugins/account.d.ts} +2 -2
- package/dist/{review/account-plugins.js → plugins/account.js} +7 -7
- package/dist/{review/plugins.d.ts → plugins/load.d.ts} +2 -2
- package/dist/{review/plugins.js → plugins/load.js} +6 -5
- package/dist/plugins/npm.d.ts +8 -0
- package/dist/plugins/npm.js +24 -0
- package/dist/{plugin-store.d.ts → plugins/store.d.ts} +1 -1
- package/dist/{plugin-store.js → plugins/store.js} +5 -4
- package/dist/repository-id.d.ts +12 -0
- package/dist/repository-id.js +72 -0
- package/dist/run.d.ts +2 -2
- package/dist/run.js +15 -12
- package/dist/session/store.d.ts +9 -0
- package/dist/session/store.js +31 -0
- package/package.json +17 -7
- package/dist/account-salt.d.ts +0 -7
- package/dist/account-salt.js +0 -43
- package/dist/cloud.d.ts +0 -34
- package/dist/cloud.js +0 -272
- package/dist/memory.d.ts +0 -4
- package/dist/metrics.d.ts +0 -47
- package/dist/review/cloud-findings.d.ts +0 -31
- package/dist/review/cloud-memory.d.ts +0 -6
- package/dist/review/cloud-memory.js +0 -58
- package/dist/review/cloud-providers.js +0 -60
- package/dist/review/cloud-review.d.ts +0 -19
- package/dist/review/cloud-review.js +0 -51
- package/dist/review/cloud-settings.d.ts +0 -16
- package/dist/review/cloud-settings.js +0 -118
- package/dist/review/cloud-upload.d.ts +0 -60
- package/dist/review/command.d.ts +0 -33
- package/dist/review/command.js +0 -293
- package/dist/review/config.js +0 -304
- package/dist/review/sarif.d.ts +0 -4
- package/dist/review/settings-sources.d.ts +0 -11
- package/dist/review/settings-sources.js +0 -58
- package/dist/review/target.d.ts +0 -18
- package/dist/review/target.js +0 -210
- /package/dist/{review → cloud}/account-settings.d.ts +0 -0
- /package/dist/{review → commands/review}/plan-render.d.ts +0 -0
- /package/dist/{review → commands/review}/render.d.ts +0 -0
- /package/dist/{review → commands/review}/runtimes.d.ts +0 -0
- /package/dist/{review → io}/terminal.d.ts +0 -0
- /package/dist/{review → io}/terminal.js +0 -0
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
4
3
|
import { dirname, join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import { coverageGaps, } from "@open-cr-agent/core";
|
|
4
|
+
import { coverageGaps } from "@open-cr-agent/core";
|
|
7
5
|
import { verificationSchema } from "@open-cr-agent/core/internal";
|
|
8
|
-
import { cloudSession, readCredentials } from "../cloud.js";
|
|
9
6
|
import { VERSION } from "../version.js";
|
|
7
|
+
import { CloudClient, CloudError, sessionLostReason } from "./client.js";
|
|
8
|
+
import { readCredentials } from "./credentials.js";
|
|
9
|
+
import { createPrivateFile, writePrivateFile } from "./private-file.js";
|
|
10
10
|
// After a review, a signed-in CLI sends ocra Cloud its counts (ADR-0024):
|
|
11
11
|
// the verdict, how many findings of each severity, files and tasks, tokens
|
|
12
12
|
// and time. A path, a title, a finding's text or code go only when the
|
|
13
|
-
// account shares findings (ADR-0028,
|
|
14
|
-
// a salted hash
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
13
|
+
// account shares findings (ADR-0028, findings.ts). The repository is
|
|
14
|
+
// a salted hash of its `https://host/owner/repo` (repository-id.ts), so
|
|
15
|
+
// the server can group reviews of one repository without learning which
|
|
16
|
+
// it is: the salt stays on this machine, or is the account's while it
|
|
17
|
+
// shares findings, so the hash matches on all its machines.
|
|
18
18
|
export function uploadOf(report, source, repoHash, durationMs) {
|
|
19
19
|
const severity = (s) => report.findings.filter((f) => f.severity === s).length;
|
|
20
20
|
const gaps = coverageGaps(report);
|
|
@@ -24,7 +24,9 @@ export function uploadOf(report, source, repoHash, durationMs) {
|
|
|
24
24
|
source,
|
|
25
25
|
tier: report.tier,
|
|
26
26
|
verdict: report.verdict,
|
|
27
|
-
|
|
27
|
+
// As the exit code reads it: a review that missed files or could not
|
|
28
|
+
// verify a critical finding is incomplete.
|
|
29
|
+
complete: gaps.notReviewed === 0 && report.unverifiedCriticals === 0,
|
|
28
30
|
findings: {
|
|
29
31
|
critical: severity("critical"),
|
|
30
32
|
warning: severity("warning"),
|
|
@@ -36,7 +38,7 @@ export function uploadOf(report, source, repoHash, durationMs) {
|
|
|
36
38
|
},
|
|
37
39
|
tasks: {
|
|
38
40
|
completed: report.tasks.filter((t) => t.status === "completed").length,
|
|
39
|
-
failed: report.tasks.filter((t) => t.status === "failed").length,
|
|
41
|
+
failed: report.tasks.filter((t) => t.status === "failed" || t.status === "timed_out").length,
|
|
40
42
|
},
|
|
41
43
|
usage: {
|
|
42
44
|
inputTokens: report.usage.inputTokens,
|
|
@@ -50,8 +52,9 @@ export function uploadOf(report, source, repoHash, durationMs) {
|
|
|
50
52
|
}
|
|
51
53
|
// Counted as `ocra metrics` counts one report: a failed or timed-out task is
|
|
52
54
|
// failed, a finding without a verification is unchecked, a dismissal outranks
|
|
53
|
-
// a fix, and a fixed or dismissed finding is attributed to the reviewer
|
|
54
|
-
//
|
|
55
|
+
// a fix, and a fixed or dismissed finding is attributed to the reviewer the
|
|
56
|
+
// earlier review recorded for it, else to the reviewer of a finding with its
|
|
57
|
+
// fingerprint in this report, else to none.
|
|
55
58
|
function perReviewer(report) {
|
|
56
59
|
const reviewers = new Map();
|
|
57
60
|
const of = (id) => {
|
|
@@ -84,64 +87,65 @@ function perReviewer(report) {
|
|
|
84
87
|
if (!reviewerOf.has(finding.fingerprint))
|
|
85
88
|
reviewerOf.set(finding.fingerprint, finding.reviewer);
|
|
86
89
|
}
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
const outcomeReviewer = (f) => f.reviewer ?? reviewerOf.get(f.fingerprint);
|
|
91
|
+
const dismissed = new Map((report.rereview?.dismissed ?? []).map((f) => [f.fingerprint, outcomeReviewer(f)]));
|
|
92
|
+
const fixed = new Map((report.rereview?.fixed ?? [])
|
|
93
|
+
.filter((f) => !dismissed.has(f.fingerprint))
|
|
94
|
+
.map((f) => [f.fingerprint, outcomeReviewer(f)]));
|
|
95
|
+
for (const reviewer of fixed.values())
|
|
91
96
|
if (reviewer)
|
|
92
97
|
of(reviewer).fixed += 1;
|
|
93
|
-
|
|
94
|
-
for (const fp of dismissed) {
|
|
95
|
-
const reviewer = reviewerOf.get(fp);
|
|
98
|
+
for (const reviewer of dismissed.values())
|
|
96
99
|
if (reviewer)
|
|
97
100
|
of(reviewer).dismissed += 1;
|
|
98
|
-
}
|
|
99
101
|
return {
|
|
100
102
|
reviewers: Object.fromEntries([...reviewers].sort(([a], [b]) => a.localeCompare(b))),
|
|
101
103
|
verification,
|
|
102
104
|
outcomes: { fixed: fixed.size, dismissed: dismissed.size },
|
|
103
105
|
};
|
|
104
106
|
}
|
|
105
|
-
/** The repository's origin without credentials, or its root path when it has none. */
|
|
106
|
-
async function repositoryId(root) {
|
|
107
|
-
try {
|
|
108
|
-
const { stdout } = await run("git", ["-C", root, "remote", "get-url", "origin"], {
|
|
109
|
-
timeout: 10_000,
|
|
110
|
-
});
|
|
111
|
-
const url = stdout.trim();
|
|
112
|
-
return url
|
|
113
|
-
.replace(/^[a-z+]+:\/\/[^@/]*@/i, (m) => m.slice(0, m.indexOf("//") + 2))
|
|
114
|
-
.replace(/\.git$/, "")
|
|
115
|
-
.toLowerCase();
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
return root;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
107
|
/** This machine's random salt, kept beside the credentials, made on first use. */
|
|
122
108
|
async function machineSalt(credentialsPath) {
|
|
123
109
|
const path = join(dirname(credentialsPath), "upload-salt");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
110
|
+
const existing = await readSalt(path);
|
|
111
|
+
if (existing !== "unreadable") {
|
|
112
|
+
if (existing)
|
|
127
113
|
return existing;
|
|
114
|
+
// Two first reviews at once agree on the salt the first of them made.
|
|
115
|
+
const fresh = randomBytes(32).toString("hex");
|
|
116
|
+
if (await createPrivateFile(path, `${fresh}\n`))
|
|
117
|
+
return fresh;
|
|
118
|
+
const made = await readSalt(path);
|
|
119
|
+
if (made && made !== "unreadable")
|
|
120
|
+
return made;
|
|
128
121
|
}
|
|
129
|
-
catch { }
|
|
130
122
|
const fresh = randomBytes(32).toString("hex");
|
|
131
|
-
await
|
|
132
|
-
await writeFile(path, `${fresh}\n`, { mode: 0o600 });
|
|
123
|
+
await writePrivateFile(path, `${fresh}\n`);
|
|
133
124
|
return fresh;
|
|
134
125
|
}
|
|
135
|
-
|
|
136
|
-
|
|
126
|
+
async function readSalt(path) {
|
|
127
|
+
let text;
|
|
128
|
+
try {
|
|
129
|
+
text = await readFile(path, "utf8");
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const salt = text.trim();
|
|
135
|
+
return /^[0-9a-f]{64}$/.test(salt) ? salt : "unreadable";
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The hash of a repository named as `https://host/owner/repo`
|
|
139
|
+
* (repository-id.ts), salted with the account's salt when given, else this machine's.
|
|
140
|
+
*/
|
|
141
|
+
export async function repoHash(repository, credentialsPath, accountSalt) {
|
|
137
142
|
const salt = accountSalt ?? (await machineSalt(credentialsPath));
|
|
138
|
-
return createHash("sha256")
|
|
139
|
-
.update(`${salt}:${await repositoryId(root)}`)
|
|
140
|
-
.digest("hex");
|
|
143
|
+
return createHash("sha256").update(`${salt}:${repository}`).digest("hex");
|
|
141
144
|
}
|
|
142
145
|
/** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
|
|
143
|
-
export async function cloudEnabled(deps) {
|
|
144
|
-
return (deps.env.OCRA_CLOUD !== "off" &&
|
|
146
|
+
export async function cloudEnabled(deps, warn) {
|
|
147
|
+
return (deps.env.OCRA_CLOUD !== "off" &&
|
|
148
|
+
(await readCredentials(deps.credentialsPath, warn)) !== undefined);
|
|
145
149
|
}
|
|
146
150
|
/**
|
|
147
151
|
* Sends the counts, and the findings when given; answers how many findings
|
|
@@ -150,31 +154,30 @@ export async function cloudEnabled(deps) {
|
|
|
150
154
|
*/
|
|
151
155
|
export async function uploadReview(upload, deps, warn, findings) {
|
|
152
156
|
try {
|
|
153
|
-
const
|
|
154
|
-
|
|
157
|
+
const answer = await new CloudClient(deps).uploadReview(
|
|
158
|
+
// The counts stay in `findings`; the shared list goes in `findingList`.
|
|
159
|
+
findings ? { ...upload, findingList: findings } : upload);
|
|
160
|
+
if (answer.kind === "signed-out")
|
|
161
|
+
return undefined;
|
|
162
|
+
if (answer.kind === "status") {
|
|
163
|
+
warn(`ocra Cloud did not take the review's counts (HTTP ${answer.status})`);
|
|
155
164
|
return undefined;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"content-type": "application/json",
|
|
160
|
-
authorization: `Bearer ${session.access_token}`,
|
|
161
|
-
"user-agent": `ocra/${VERSION}`,
|
|
162
|
-
},
|
|
163
|
-
// The counts stay in `findings`; the shared list goes in `findingList`.
|
|
164
|
-
body: JSON.stringify(findings ? { ...upload, findingList: findings } : upload),
|
|
165
|
-
signal: AbortSignal.timeout(15_000),
|
|
166
|
-
});
|
|
167
|
-
if (!res.ok) {
|
|
168
|
-
warn(`ocra Cloud did not take the review's counts (HTTP ${res.status})`);
|
|
165
|
+
}
|
|
166
|
+
if (answer.kind !== "ok") {
|
|
167
|
+
warn(`could not send the review's counts to ocra Cloud: ${sessionLostReason(answer)}`);
|
|
169
168
|
return undefined;
|
|
170
169
|
}
|
|
171
|
-
|
|
172
|
-
const kept = answer.findings;
|
|
173
|
-
return { findings: typeof kept === "number" && Number.isInteger(kept) && kept > 0 ? kept : 0 };
|
|
170
|
+
return answer.value;
|
|
174
171
|
}
|
|
175
172
|
catch (error) {
|
|
176
|
-
warn(`could not send the review's counts to ocra Cloud: ${error
|
|
173
|
+
warn(`could not send the review's counts to ocra Cloud: ${failureName(error)}`);
|
|
177
174
|
return undefined;
|
|
178
175
|
}
|
|
179
176
|
}
|
|
180
|
-
|
|
177
|
+
// The warning names the error, not its message; for a call that could not be
|
|
178
|
+
// sent, the cause's name (TypeError for a network failure).
|
|
179
|
+
function failureName(error) {
|
|
180
|
+
const cause = error instanceof CloudError && error.cause instanceof Error ? error.cause : error;
|
|
181
|
+
return cause instanceof Error ? cause.name : "error";
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=upload.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CloudDeps } from "../cloud/deps.js";
|
|
2
|
+
import type { Output } from "../io/output.js";
|
|
3
|
+
export declare const LOGIN_USAGE: string;
|
|
4
|
+
export declare function loginCommand(command: "login" | "logout" | "whoami", argv: string[], out: Output, err: Output, deps: CloudDeps): Promise<number>;
|
|
5
|
+
//# sourceMappingURL=login.d.ts.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { errorMessage } from "@open-cr-agent/core";
|
|
4
|
+
import { saveAccountSalt } from "../cloud/account-salt.js";
|
|
5
|
+
import { signInPage } from "../cloud/browser.js";
|
|
6
|
+
import { CloudClient } from "../cloud/client.js";
|
|
7
|
+
import { credentialsHint, loadCredentials, writeCredentials, } from "../cloud/credentials.js";
|
|
8
|
+
import { cloudUrl, DEFAULT_CLOUD_URL } from "../cloud/deps.js";
|
|
9
|
+
import { EXIT } from "../io/exit.js";
|
|
10
|
+
import { forTerminal } from "../io/terminal.js";
|
|
11
|
+
// ocra Cloud sign-in (ADR-0024): `ocra login` runs the OAuth device flow
|
|
12
|
+
// (RFC 8628) against ocra Cloud, `ocra whoami` shows the account, and
|
|
13
|
+
// `ocra logout` ends the session. Nothing here runs unless one of these
|
|
14
|
+
// commands is used: a self-hosted user never sees a login prompt.
|
|
15
|
+
export const LOGIN_USAGE = `Usage: ocra login [--no-browser]
|
|
16
|
+
ocra logout
|
|
17
|
+
ocra whoami
|
|
18
|
+
|
|
19
|
+
Sign in to ocra Cloud with GitHub, so reviews can use the model keys stored
|
|
20
|
+
there. ocra login shows a code, opens the browser to confirm it, and saves the
|
|
21
|
+
session to ${credentialsHint()} (readable only by you).
|
|
22
|
+
|
|
23
|
+
Environment:
|
|
24
|
+
OCRA_CLOUD=off Disable ocra Cloud: these commands refuse to run
|
|
25
|
+
OCRA_CLOUD_URL Another ocra Cloud server (default ${DEFAULT_CLOUD_URL})
|
|
26
|
+
`;
|
|
27
|
+
export async function loginCommand(command, argv, out, err, deps) {
|
|
28
|
+
const { values } = parseArgs({
|
|
29
|
+
args: argv,
|
|
30
|
+
strict: true,
|
|
31
|
+
options: { "no-browser": { type: "boolean" }, help: { type: "boolean", short: "h" } },
|
|
32
|
+
});
|
|
33
|
+
if (values.help) {
|
|
34
|
+
out.write(LOGIN_USAGE);
|
|
35
|
+
return EXIT.ok;
|
|
36
|
+
}
|
|
37
|
+
if (deps.env.OCRA_CLOUD === "off") {
|
|
38
|
+
err.write("ocra Cloud is off (OCRA_CLOUD=off).\n");
|
|
39
|
+
return EXIT.error;
|
|
40
|
+
}
|
|
41
|
+
if (command === "whoami")
|
|
42
|
+
return whoami(out, err, deps);
|
|
43
|
+
if (command === "logout")
|
|
44
|
+
return logout(out, deps);
|
|
45
|
+
return login(out, err, deps, !values["no-browser"]);
|
|
46
|
+
}
|
|
47
|
+
async function whoami(out, err, deps) {
|
|
48
|
+
const client = new CloudClient(deps);
|
|
49
|
+
const session = await client.session();
|
|
50
|
+
if (session.kind === "signed-out") {
|
|
51
|
+
err.write("Not signed in. Run ocra login.\n");
|
|
52
|
+
return EXIT.notSignedIn;
|
|
53
|
+
}
|
|
54
|
+
if (session.kind === "unreachable") {
|
|
55
|
+
err.write(forTerminal(`ocra Cloud could not be reached (${session.reason}).\n`));
|
|
56
|
+
return EXIT.error;
|
|
57
|
+
}
|
|
58
|
+
const me = session.kind === "ok"
|
|
59
|
+
? await client.account(session.credentials.server, session.credentials.access_token)
|
|
60
|
+
: undefined;
|
|
61
|
+
if (session.kind !== "ok" || !me) {
|
|
62
|
+
err.write("Your ocra Cloud session ended. Run ocra login.\n");
|
|
63
|
+
return EXIT.notSignedIn;
|
|
64
|
+
}
|
|
65
|
+
out.write(forTerminal(`${me.login} on ${session.credentials.server}\n`));
|
|
66
|
+
return EXIT.ok;
|
|
67
|
+
}
|
|
68
|
+
async function logout(out, deps) {
|
|
69
|
+
const saved = await loadCredentials(deps.credentialsPath);
|
|
70
|
+
if (saved && saved !== "unreadable") {
|
|
71
|
+
// Ends the session on the server too; the local file goes either way.
|
|
72
|
+
const client = new CloudClient(deps);
|
|
73
|
+
const session = await client.session().catch(() => undefined);
|
|
74
|
+
if (session?.kind === "ok")
|
|
75
|
+
await client.endSession(session.credentials).catch(() => { });
|
|
76
|
+
}
|
|
77
|
+
await rm(deps.credentialsPath, { force: true });
|
|
78
|
+
await saveAccountSalt(deps.credentialsPath, null);
|
|
79
|
+
out.write(saved === "unreadable"
|
|
80
|
+
? "Removed the saved session, which could not be read.\n"
|
|
81
|
+
: saved
|
|
82
|
+
? "Signed out.\n"
|
|
83
|
+
: "Not signed in.\n");
|
|
84
|
+
return EXIT.ok;
|
|
85
|
+
}
|
|
86
|
+
async function login(out, err, deps, browser) {
|
|
87
|
+
const server = cloudUrl(deps.env);
|
|
88
|
+
const client = new CloudClient(deps);
|
|
89
|
+
const start = await client.startLogin(server);
|
|
90
|
+
if (start.kind === "refused") {
|
|
91
|
+
err.write(`ocra Cloud did not start a login (HTTP ${start.status}). Try again in a minute.\n`);
|
|
92
|
+
return EXIT.error;
|
|
93
|
+
}
|
|
94
|
+
const { code } = start;
|
|
95
|
+
out.write(forTerminal(`Confirm this code in your browser: ${code.user_code}\n` +
|
|
96
|
+
` ${code.verification_uri_complete}\n` +
|
|
97
|
+
`(or open ${code.verification_uri} and type it)\n\nWaiting for approval...\n`));
|
|
98
|
+
if (browser) {
|
|
99
|
+
const page = signInPage(code.verification_uri_complete, server);
|
|
100
|
+
if (page)
|
|
101
|
+
deps.openBrowser(page);
|
|
102
|
+
else
|
|
103
|
+
err.write(`ocra opens only pages on ${server}; open the address above yourself.\n`);
|
|
104
|
+
}
|
|
105
|
+
let interval = (code.interval ?? 5) * 1000;
|
|
106
|
+
const deadline = deps.now() + (code.expires_in ?? 600) * 1000;
|
|
107
|
+
while (deps.now() < deadline) {
|
|
108
|
+
await deps.sleep(interval);
|
|
109
|
+
let t;
|
|
110
|
+
try {
|
|
111
|
+
t = await client.pollLogin(server, code.device_code);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// A dropped connection is retried on the next tick.
|
|
115
|
+
err.write(forTerminal(`(${errorMessage(error)}; retrying)\n`));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (t.status === 200 && t.access_token && t.refresh_token && t.expires_in) {
|
|
119
|
+
// The tokens are issued: they are saved even when the login cannot be read.
|
|
120
|
+
let login;
|
|
121
|
+
let unknown = "";
|
|
122
|
+
try {
|
|
123
|
+
login = (await client.account(server, t.access_token))?.login;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
unknown = errorMessage(error);
|
|
127
|
+
}
|
|
128
|
+
const credentials = {
|
|
129
|
+
server,
|
|
130
|
+
login: login ?? "",
|
|
131
|
+
access_token: t.access_token,
|
|
132
|
+
refresh_token: t.refresh_token,
|
|
133
|
+
expires_at: deps.now() + t.expires_in * 1000,
|
|
134
|
+
};
|
|
135
|
+
await writeCredentials(deps.credentialsPath, credentials);
|
|
136
|
+
// Best effort: each signed-in review asks again.
|
|
137
|
+
await client
|
|
138
|
+
.accountSalt()
|
|
139
|
+
.then((salt) => salt.kind === "ok" ? saveAccountSalt(deps.credentialsPath, salt.value) : undefined)
|
|
140
|
+
.catch(() => { });
|
|
141
|
+
out.write(forTerminal(login
|
|
142
|
+
? `Signed in as ${login}.\n`
|
|
143
|
+
: `Signed in; the login is unknown${unknown ? ` (${unknown})` : ""}: ocra whoami asks again.\n`));
|
|
144
|
+
return EXIT.ok;
|
|
145
|
+
}
|
|
146
|
+
if (t.error === "authorization_pending")
|
|
147
|
+
continue;
|
|
148
|
+
if (t.error === "slow_down") {
|
|
149
|
+
interval = typeof t.interval === "number" ? t.interval * 1000 : interval + 5000;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (t.error === "access_denied") {
|
|
153
|
+
err.write("Login denied in the browser.\n");
|
|
154
|
+
return EXIT.notSignedIn;
|
|
155
|
+
}
|
|
156
|
+
if (t.error === "expired_token")
|
|
157
|
+
break;
|
|
158
|
+
err.write(forTerminal(`Login failed: ${t.error ?? `HTTP ${t.status}`}\n`));
|
|
159
|
+
return EXIT.error;
|
|
160
|
+
}
|
|
161
|
+
err.write("The code expired before it was confirmed. Run ocra login again.\n");
|
|
162
|
+
return EXIT.notSignedIn;
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { constants, existsSync } from "node:fs";
|
|
2
|
-
import { lstat, mkdir, open,
|
|
2
|
+
import { lstat, mkdir, open, readFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { MEMORY_PATH, parseMemory, serializeMemory } from "@open-cr-agent/core/internal";
|
|
5
|
+
import { isNotFound, MEMORY_PATH, parseMemory, readReport, serializeMemory, } from "@open-cr-agent/core/internal";
|
|
6
6
|
import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
7
|
+
import { EXIT } from "../io/exit.js";
|
|
8
|
+
import { forTerminal } from "../io/terminal.js";
|
|
9
|
+
import { UsageError } from "../io/usage-error.js";
|
|
10
|
+
import { listSessions, reportPath, SESSIONS_DIR, sessionsDir } from "../session/store.js";
|
|
11
|
+
const MEMORY_USAGE = `Usage: ocra memory <command>
|
|
11
12
|
|
|
12
13
|
Remember findings the team accepts, so ocra stops reporting them.
|
|
13
14
|
Entries live in ${MEMORY_PATH}; commit it to share them.
|
|
@@ -31,7 +32,7 @@ export async function memoryCommand(argv, out, cwd, now = new Date()) {
|
|
|
31
32
|
const [command, id] = positionals;
|
|
32
33
|
if (values.help || command === undefined) {
|
|
33
34
|
out.write(MEMORY_USAGE);
|
|
34
|
-
return
|
|
35
|
+
return EXIT.ok;
|
|
35
36
|
}
|
|
36
37
|
const root = await findRepositoryRoot(cwd);
|
|
37
38
|
const memory = await readMemory(root);
|
|
@@ -41,7 +42,7 @@ export async function memoryCommand(argv, out, cwd, now = new Date()) {
|
|
|
41
42
|
for (const e of memory) {
|
|
42
43
|
out.write(forTerminal(`${e.fingerprint.slice(0, 8)} ${e.file} ${e.title}\n ${e.reason}\n`));
|
|
43
44
|
}
|
|
44
|
-
return
|
|
45
|
+
return EXIT.ok;
|
|
45
46
|
}
|
|
46
47
|
if (command !== "add")
|
|
47
48
|
throw new UsageError(`Unknown memory command: ${command}`);
|
|
@@ -50,10 +51,10 @@ export async function memoryCommand(argv, out, cwd, now = new Date()) {
|
|
|
50
51
|
if (!values.reason?.trim())
|
|
51
52
|
throw new UsageError("ocra memory add needs --reason");
|
|
52
53
|
const report = values.session ? resolve(cwd, values.session) : await newestSession(root);
|
|
53
|
-
const finding = await findFinding(
|
|
54
|
+
const finding = await findFinding(reportPath(report), id);
|
|
54
55
|
if (memory.some((e) => e.fingerprint === finding.fingerprint)) {
|
|
55
56
|
out.write(`Already remembered: ${forTerminal(finding.title)}\n`);
|
|
56
|
-
return
|
|
57
|
+
return EXIT.ok;
|
|
57
58
|
}
|
|
58
59
|
const entry = {
|
|
59
60
|
fingerprint: finding.fingerprint,
|
|
@@ -64,7 +65,7 @@ export async function memoryCommand(argv, out, cwd, now = new Date()) {
|
|
|
64
65
|
};
|
|
65
66
|
await writeRepositoryFile(root, MEMORY_PATH, serializeMemory([...memory, entry]));
|
|
66
67
|
out.write(forTerminal(`Remembered ${finding.fingerprint.slice(0, 8)}: ${finding.title}\nCommit ${MEMORY_PATH} to share it.\n`));
|
|
67
|
-
return
|
|
68
|
+
return EXIT.ok;
|
|
68
69
|
}
|
|
69
70
|
// The repository may be someone else's clone: a planted symlink at
|
|
70
71
|
// .ocra/memory.json, or at .ocra, must not make ocra write elsewhere.
|
|
@@ -89,26 +90,26 @@ async function readMemory(root) {
|
|
|
89
90
|
return parseMemory(await readFile(join(root, MEMORY_PATH), "utf8"));
|
|
90
91
|
}
|
|
91
92
|
catch (error) {
|
|
92
|
-
if (error
|
|
93
|
+
if (isNotFound(error))
|
|
93
94
|
return [];
|
|
94
95
|
throw error;
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
async function newestSession(root) {
|
|
98
|
-
const dir =
|
|
99
|
-
const sessions = (await readdir(dir).catch(() => [])).filter((n) => !n.startsWith(".")).sort();
|
|
99
|
+
const dir = sessionsDir(root);
|
|
100
100
|
// An interrupted run leaves a session without a report.
|
|
101
|
-
for (const name of
|
|
102
|
-
if (existsSync(join(dir, name
|
|
101
|
+
for (const name of (await listSessions(dir)).reverse()) {
|
|
102
|
+
if (existsSync(reportPath(join(dir, name))))
|
|
103
103
|
return join(dir, name);
|
|
104
104
|
}
|
|
105
105
|
throw new UsageError(`No finished review in ${SESSIONS_DIR}; run ocra review first`);
|
|
106
106
|
}
|
|
107
107
|
async function findFinding(reportPath, id) {
|
|
108
|
-
const report =
|
|
109
|
-
const matches =
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
const report = await readReport(reportPath);
|
|
109
|
+
const matches = report.findings.filter((f) => f.fingerprint.startsWith(id));
|
|
110
|
+
const [only] = matches;
|
|
111
|
+
if (only && matches.length === 1)
|
|
112
|
+
return only;
|
|
112
113
|
throw new UsageError(matches.length === 0
|
|
113
114
|
? `No finding ${id} in ${reportPath}`
|
|
114
115
|
: `${id} matches ${matches.length} findings; use more characters`);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Severity, Verification } from "@open-cr-agent/core";
|
|
2
|
+
import type { Output } from "../io/output.js";
|
|
3
|
+
declare const METRICS_VERSION = 1;
|
|
4
|
+
interface ReviewerMetrics {
|
|
5
|
+
tasks: number;
|
|
6
|
+
failedTasks: number;
|
|
7
|
+
findings: number;
|
|
8
|
+
costUsd: number;
|
|
9
|
+
fixed: number;
|
|
10
|
+
dismissed: number;
|
|
11
|
+
acceptanceRate: number | null;
|
|
12
|
+
}
|
|
13
|
+
export interface Metrics {
|
|
14
|
+
version: typeof METRICS_VERSION;
|
|
15
|
+
sessionsDir: string;
|
|
16
|
+
since?: string;
|
|
17
|
+
runs: {
|
|
18
|
+
total: number;
|
|
19
|
+
byVerdict: Record<string, number>;
|
|
20
|
+
incomplete: number;
|
|
21
|
+
unreadable: number;
|
|
22
|
+
};
|
|
23
|
+
cost: {
|
|
24
|
+
usd: number;
|
|
25
|
+
inputTokens: number;
|
|
26
|
+
outputTokens: number;
|
|
27
|
+
usdPerRun: number | null;
|
|
28
|
+
};
|
|
29
|
+
findings: {
|
|
30
|
+
reported: number;
|
|
31
|
+
unique: number;
|
|
32
|
+
bySeverity: Record<Severity, number>;
|
|
33
|
+
byVerification: Record<Verification, number>;
|
|
34
|
+
};
|
|
35
|
+
outcomes: {
|
|
36
|
+
fixed: number;
|
|
37
|
+
dismissed: number;
|
|
38
|
+
acceptanceRate: number | null;
|
|
39
|
+
};
|
|
40
|
+
reviewers: Record<string, ReviewerMetrics>;
|
|
41
|
+
}
|
|
42
|
+
export declare function metricsCommand(argv: string[], out: Output, cwd: string): Promise<number>;
|
|
43
|
+
export declare function collectMetrics(dir: string, since?: Date): Promise<Metrics>;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=metrics.d.ts.map
|