@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.
Files changed (139) hide show
  1. package/dist/cloud/account-salt.d.ts +6 -0
  2. package/dist/cloud/account-salt.js +33 -0
  3. package/dist/{review → cloud}/account-settings.js +5 -4
  4. package/dist/cloud/browser.d.ts +17 -0
  5. package/dist/cloud/browser.js +37 -0
  6. package/dist/cloud/client.d.ts +93 -0
  7. package/dist/cloud/client.js +221 -0
  8. package/dist/cloud/credentials.d.ts +18 -0
  9. package/dist/cloud/credentials.js +52 -0
  10. package/dist/cloud/deps.d.ts +14 -0
  11. package/dist/cloud/deps.js +34 -0
  12. package/dist/cloud/file-lock.d.ts +14 -0
  13. package/dist/cloud/file-lock.js +77 -0
  14. package/dist/cloud/findings.d.ts +8 -0
  15. package/dist/{review/cloud-findings.js → cloud/findings.js} +10 -32
  16. package/dist/cloud/gateway-token.d.ts +18 -0
  17. package/dist/cloud/gateway-token.js +63 -0
  18. package/dist/cloud/memory.d.ts +8 -0
  19. package/dist/cloud/memory.js +68 -0
  20. package/dist/cloud/private-file.d.ts +8 -0
  21. package/dist/cloud/private-file.js +46 -0
  22. package/dist/{review/cloud-providers.d.ts → cloud/providers.d.ts} +12 -6
  23. package/dist/cloud/providers.js +132 -0
  24. package/dist/cloud/review.d.ts +28 -0
  25. package/dist/cloud/review.js +76 -0
  26. package/dist/cloud/settings.d.ts +22 -0
  27. package/dist/cloud/settings.js +64 -0
  28. package/dist/cloud/upload.d.ts +20 -0
  29. package/dist/{review/cloud-upload.js → cloud/upload.js} +74 -71
  30. package/dist/commands/login.d.ts +5 -0
  31. package/dist/commands/login.js +164 -0
  32. package/dist/commands/memory.d.ts +3 -0
  33. package/dist/{memory.js → commands/memory.js} +21 -20
  34. package/dist/commands/metrics.d.ts +45 -0
  35. package/dist/{metrics.js → commands/metrics.js} +32 -45
  36. package/dist/{plugins-command.d.ts → commands/plugins.d.ts} +3 -9
  37. package/dist/{plugins-command.js → commands/plugins.js} +29 -53
  38. package/dist/{review → commands/review}/args.d.ts +7 -10
  39. package/dist/{review → commands/review}/args.js +11 -16
  40. package/dist/commands/review/deliver.d.ts +7 -0
  41. package/dist/commands/review/deliver.js +56 -0
  42. package/dist/commands/review/deps.d.ts +18 -0
  43. package/dist/commands/review/deps.js +2 -0
  44. package/dist/commands/review/execute-run.d.ts +13 -0
  45. package/dist/commands/review/execute-run.js +97 -0
  46. package/dist/commands/review/exit-code.d.ts +4 -0
  47. package/dist/commands/review/exit-code.js +29 -0
  48. package/dist/commands/review/plan-prices.d.ts +3 -0
  49. package/dist/commands/review/plan-prices.js +18 -0
  50. package/dist/{review → commands/review}/plan-render.js +22 -2
  51. package/dist/commands/review/plan-run.d.ts +5 -0
  52. package/dist/commands/review/plan-run.js +38 -0
  53. package/dist/commands/review/plan-schema.d.ts +97 -0
  54. package/dist/commands/review/plan-schema.js +34 -0
  55. package/dist/{review → commands/review}/progress.d.ts +3 -5
  56. package/dist/{review → commands/review}/progress.js +3 -3
  57. package/dist/{review → commands/review}/provenance.d.ts +4 -2
  58. package/dist/{review → commands/review}/provenance.js +2 -3
  59. package/dist/{review → commands/review}/render.js +1 -1
  60. package/dist/commands/review/resolve-run.d.ts +29 -0
  61. package/dist/commands/review/resolve-run.js +125 -0
  62. package/dist/commands/review/review-options.d.ts +6 -0
  63. package/dist/commands/review/review-options.js +35 -0
  64. package/dist/{review → commands/review}/runtimes.js +1 -1
  65. package/dist/{review → commands/review}/sarif-input.d.ts +0 -1
  66. package/dist/{review → commands/review}/sarif-input.js +5 -5
  67. package/dist/commands/review/sarif.d.ts +96 -0
  68. package/dist/{review → commands/review}/sarif.js +29 -7
  69. package/dist/commands/review/settings-sources.d.ts +17 -0
  70. package/dist/commands/review/settings-sources.js +30 -0
  71. package/dist/commands/review/target.d.ts +38 -0
  72. package/dist/commands/review/target.js +126 -0
  73. package/dist/commands/review.d.ts +7 -0
  74. package/dist/commands/review.js +34 -0
  75. package/dist/config/cli-config.d.ts +41 -0
  76. package/dist/config/cli-config.js +199 -0
  77. package/dist/{review/remote-config.d.ts → config/remote.d.ts} +3 -3
  78. package/dist/{review/remote-config.js → config/remote.js} +5 -23
  79. package/dist/{review/config.d.ts → config/schema.d.ts} +8 -31
  80. package/dist/config/schema.js +138 -0
  81. package/dist/config/settings.d.ts +37 -0
  82. package/dist/config/settings.js +133 -0
  83. package/dist/config/user-dir.d.ts +3 -0
  84. package/dist/config/user-dir.js +10 -0
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.js +10 -1
  87. package/dist/internal.d.ts +6 -0
  88. package/dist/internal.js +7 -0
  89. package/dist/io/exit.d.ts +9 -0
  90. package/dist/io/exit.js +12 -0
  91. package/dist/io/output.d.ts +4 -0
  92. package/dist/io/output.js +2 -0
  93. package/dist/io/usage-error.d.ts +5 -0
  94. package/dist/io/usage-error.js +8 -0
  95. package/dist/main.js +2 -2
  96. package/dist/{review/account-plugins.d.ts → plugins/account.d.ts} +2 -2
  97. package/dist/{review/account-plugins.js → plugins/account.js} +7 -7
  98. package/dist/{review/plugins.d.ts → plugins/load.d.ts} +2 -2
  99. package/dist/{review/plugins.js → plugins/load.js} +6 -5
  100. package/dist/plugins/npm.d.ts +8 -0
  101. package/dist/plugins/npm.js +24 -0
  102. package/dist/{plugin-store.d.ts → plugins/store.d.ts} +1 -1
  103. package/dist/{plugin-store.js → plugins/store.js} +5 -4
  104. package/dist/repository-id.d.ts +12 -0
  105. package/dist/repository-id.js +72 -0
  106. package/dist/run.d.ts +2 -2
  107. package/dist/run.js +15 -12
  108. package/dist/session/store.d.ts +9 -0
  109. package/dist/session/store.js +31 -0
  110. package/package.json +17 -7
  111. package/dist/account-salt.d.ts +0 -7
  112. package/dist/account-salt.js +0 -43
  113. package/dist/cloud.d.ts +0 -34
  114. package/dist/cloud.js +0 -272
  115. package/dist/memory.d.ts +0 -4
  116. package/dist/metrics.d.ts +0 -47
  117. package/dist/review/cloud-findings.d.ts +0 -31
  118. package/dist/review/cloud-memory.d.ts +0 -6
  119. package/dist/review/cloud-memory.js +0 -58
  120. package/dist/review/cloud-providers.js +0 -60
  121. package/dist/review/cloud-review.d.ts +0 -19
  122. package/dist/review/cloud-review.js +0 -51
  123. package/dist/review/cloud-settings.d.ts +0 -16
  124. package/dist/review/cloud-settings.js +0 -118
  125. package/dist/review/cloud-upload.d.ts +0 -60
  126. package/dist/review/command.d.ts +0 -33
  127. package/dist/review/command.js +0 -293
  128. package/dist/review/config.js +0 -304
  129. package/dist/review/sarif.d.ts +0 -4
  130. package/dist/review/settings-sources.d.ts +0 -11
  131. package/dist/review/settings-sources.js +0 -58
  132. package/dist/review/target.d.ts +0 -18
  133. package/dist/review/target.js +0 -210
  134. /package/dist/{review → cloud}/account-settings.d.ts +0 -0
  135. /package/dist/{review → commands/review}/plan-render.d.ts +0 -0
  136. /package/dist/{review → commands/review}/render.d.ts +0 -0
  137. /package/dist/{review → commands/review}/runtimes.d.ts +0 -0
  138. /package/dist/{review → io}/terminal.d.ts +0 -0
  139. /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 { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { readFile } from "node:fs/promises";
4
3
  import { dirname, join } from "node:path";
5
- import { promisify } from "node:util";
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, cloud-findings.ts). The repository is
14
- // a salted hash, so the server can group reviews of one repository without
15
- // learning which it is: the salt stays on this machine, or is the account's
16
- // while it shares findings, so the hash matches on all its machines.
17
- const run = promisify(execFile);
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
- complete: gaps.notReviewed === 0,
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 of a
54
- // finding with its fingerprint in this report, or to none.
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 dismissed = new Set((report.rereview?.dismissed ?? []).map((f) => f.fingerprint));
88
- const fixed = new Set((report.rereview?.fixed ?? []).map((f) => f.fingerprint).filter((fp) => !dismissed.has(fp)));
89
- for (const fp of fixed) {
90
- const reviewer = reviewerOf.get(fp);
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
- try {
125
- const existing = (await readFile(path, "utf8")).trim();
126
- if (/^[0-9a-f]{64}$/.test(existing))
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 mkdir(dirname(path), { recursive: true, mode: 0o700 });
132
- await writeFile(path, `${fresh}\n`, { mode: 0o600 });
123
+ await writePrivateFile(path, `${fresh}\n`);
133
124
  return fresh;
134
125
  }
135
- /** The repository's hash, salted with the account's salt when given, else this machine's. */
136
- export async function repoHash(root, credentialsPath, accountSalt) {
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" && (await readCredentials(deps.credentialsPath)) !== undefined);
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 session = await cloudSession(deps);
154
- if (!session)
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
- const res = await deps.fetch(`${session.server}/api/reviews`, {
157
- method: "POST",
158
- headers: {
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
- const answer = (await res.json().catch(() => ({})));
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 instanceof Error ? error.name : "error"}`);
173
+ warn(`could not send the review's counts to ocra Cloud: ${failureName(error)}`);
177
174
  return undefined;
178
175
  }
179
176
  }
180
- //# sourceMappingURL=cloud-upload.js.map
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
@@ -0,0 +1,3 @@
1
+ import type { Output } from "../io/output.js";
2
+ export declare function memoryCommand(argv: string[], out: Output, cwd: string, now?: Date): Promise<number>;
3
+ //# sourceMappingURL=memory.d.ts.map
@@ -1,13 +1,14 @@
1
1
  import { constants, existsSync } from "node:fs";
2
- import { lstat, mkdir, open, readdir, readFile } from "node:fs/promises";
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 { UsageError } from "./review/args.js";
8
- import { SESSIONS_DIR } from "./review/command.js";
9
- import { forTerminal } from "./review/terminal.js";
10
- export const MEMORY_USAGE = `Usage: ocra memory <command>
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 0;
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 0;
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(join(report, "report.json"), id);
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 0;
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 0;
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.code === "ENOENT")
93
+ if (isNotFound(error))
93
94
  return [];
94
95
  throw error;
95
96
  }
96
97
  }
97
98
  async function newestSession(root) {
98
- const dir = join(root, SESSIONS_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 sessions.reverse()) {
102
- if (existsSync(join(dir, name, "report.json")))
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 = JSON.parse(await readFile(reportPath, "utf8"));
109
- const matches = (report.findings ?? []).filter((f) => f.fingerprint.startsWith(id));
110
- if (matches.length === 1)
111
- return matches[0];
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