@open-cr-agent/cli 0.4.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 (132) hide show
  1. package/dist/cloud/account-salt.d.ts +6 -0
  2. package/dist/cloud/account-salt.js +33 -0
  3. package/dist/cloud/account-settings.d.ts +100 -0
  4. package/dist/cloud/account-settings.js +107 -0
  5. package/dist/cloud/browser.d.ts +17 -0
  6. package/dist/cloud/browser.js +37 -0
  7. package/dist/cloud/client.d.ts +93 -0
  8. package/dist/cloud/client.js +221 -0
  9. package/dist/cloud/credentials.d.ts +18 -0
  10. package/dist/cloud/credentials.js +52 -0
  11. package/dist/cloud/deps.d.ts +14 -0
  12. package/dist/cloud/deps.js +34 -0
  13. package/dist/cloud/file-lock.d.ts +14 -0
  14. package/dist/cloud/file-lock.js +77 -0
  15. package/dist/cloud/findings.d.ts +8 -0
  16. package/dist/cloud/findings.js +58 -0
  17. package/dist/cloud/gateway-token.d.ts +18 -0
  18. package/dist/cloud/gateway-token.js +63 -0
  19. package/dist/cloud/memory.d.ts +8 -0
  20. package/dist/cloud/memory.js +68 -0
  21. package/dist/cloud/private-file.d.ts +8 -0
  22. package/dist/cloud/private-file.js +46 -0
  23. package/dist/cloud/providers.d.ts +18 -0
  24. package/dist/cloud/providers.js +132 -0
  25. package/dist/cloud/review.d.ts +28 -0
  26. package/dist/cloud/review.js +76 -0
  27. package/dist/cloud/settings.d.ts +22 -0
  28. package/dist/cloud/settings.js +64 -0
  29. package/dist/cloud/upload.d.ts +20 -0
  30. package/dist/cloud/upload.js +183 -0
  31. package/dist/commands/login.d.ts +5 -0
  32. package/dist/commands/login.js +164 -0
  33. package/dist/commands/memory.d.ts +3 -0
  34. package/dist/{memory.js → commands/memory.js} +21 -20
  35. package/dist/commands/metrics.d.ts +45 -0
  36. package/dist/{metrics.js → commands/metrics.js} +32 -45
  37. package/dist/commands/plugins.d.ts +9 -0
  38. package/dist/commands/plugins.js +159 -0
  39. package/dist/{review → commands/review}/args.d.ts +7 -10
  40. package/dist/{review → commands/review}/args.js +11 -16
  41. package/dist/commands/review/deliver.d.ts +7 -0
  42. package/dist/commands/review/deliver.js +56 -0
  43. package/dist/commands/review/deps.d.ts +18 -0
  44. package/dist/commands/review/deps.js +2 -0
  45. package/dist/commands/review/execute-run.d.ts +13 -0
  46. package/dist/commands/review/execute-run.js +97 -0
  47. package/dist/commands/review/exit-code.d.ts +4 -0
  48. package/dist/commands/review/exit-code.js +29 -0
  49. package/dist/commands/review/plan-prices.d.ts +3 -0
  50. package/dist/commands/review/plan-prices.js +18 -0
  51. package/dist/{review → commands/review}/plan-render.js +22 -2
  52. package/dist/commands/review/plan-run.d.ts +5 -0
  53. package/dist/commands/review/plan-run.js +38 -0
  54. package/dist/commands/review/plan-schema.d.ts +97 -0
  55. package/dist/commands/review/plan-schema.js +34 -0
  56. package/dist/{review → commands/review}/progress.d.ts +3 -5
  57. package/dist/{review → commands/review}/progress.js +3 -3
  58. package/dist/{review → commands/review}/provenance.d.ts +4 -2
  59. package/dist/{review → commands/review}/provenance.js +11 -2
  60. package/dist/{review → commands/review}/render.js +8 -3
  61. package/dist/commands/review/resolve-run.d.ts +29 -0
  62. package/dist/commands/review/resolve-run.js +125 -0
  63. package/dist/commands/review/review-options.d.ts +6 -0
  64. package/dist/commands/review/review-options.js +35 -0
  65. package/dist/{review → commands/review}/runtimes.js +1 -1
  66. package/dist/{review → commands/review}/sarif-input.d.ts +0 -1
  67. package/dist/{review → commands/review}/sarif-input.js +5 -5
  68. package/dist/commands/review/sarif.d.ts +96 -0
  69. package/dist/{review → commands/review}/sarif.js +29 -7
  70. package/dist/commands/review/settings-sources.d.ts +17 -0
  71. package/dist/commands/review/settings-sources.js +30 -0
  72. package/dist/commands/review/target.d.ts +38 -0
  73. package/dist/commands/review/target.js +126 -0
  74. package/dist/commands/review.d.ts +7 -0
  75. package/dist/commands/review.js +34 -0
  76. package/dist/config/cli-config.d.ts +41 -0
  77. package/dist/config/cli-config.js +199 -0
  78. package/dist/{review/remote-config.d.ts → config/remote.d.ts} +3 -3
  79. package/dist/{review/remote-config.js → config/remote.js} +5 -23
  80. package/dist/{review/config.d.ts → config/schema.d.ts} +13 -25
  81. package/dist/config/schema.js +138 -0
  82. package/dist/config/settings.d.ts +37 -0
  83. package/dist/config/settings.js +133 -0
  84. package/dist/config/user-dir.d.ts +3 -0
  85. package/dist/config/user-dir.js +10 -0
  86. package/dist/index.d.ts +1 -1
  87. package/dist/index.js +10 -1
  88. package/dist/internal.d.ts +6 -0
  89. package/dist/internal.js +7 -0
  90. package/dist/io/exit.d.ts +9 -0
  91. package/dist/io/exit.js +12 -0
  92. package/dist/io/output.d.ts +4 -0
  93. package/dist/io/output.js +2 -0
  94. package/dist/io/usage-error.d.ts +5 -0
  95. package/dist/io/usage-error.js +8 -0
  96. package/dist/main.js +2 -2
  97. package/dist/plugins/account.d.ts +23 -0
  98. package/dist/plugins/account.js +54 -0
  99. package/dist/plugins/load.d.ts +13 -0
  100. package/dist/plugins/load.js +78 -0
  101. package/dist/plugins/npm.d.ts +8 -0
  102. package/dist/plugins/npm.js +24 -0
  103. package/dist/plugins/store.d.ts +20 -0
  104. package/dist/plugins/store.js +85 -0
  105. package/dist/repository-id.d.ts +12 -0
  106. package/dist/repository-id.js +72 -0
  107. package/dist/run.d.ts +2 -2
  108. package/dist/run.js +32 -10
  109. package/dist/session/store.d.ts +9 -0
  110. package/dist/session/store.js +31 -0
  111. package/package.json +17 -7
  112. package/dist/cloud.d.ts +0 -32
  113. package/dist/cloud.js +0 -262
  114. package/dist/memory.d.ts +0 -4
  115. package/dist/metrics.d.ts +0 -47
  116. package/dist/review/cloud-providers.d.ts +0 -12
  117. package/dist/review/cloud-providers.js +0 -59
  118. package/dist/review/cloud-upload.d.ts +0 -40
  119. package/dist/review/cloud-upload.js +0 -135
  120. package/dist/review/command.d.ts +0 -31
  121. package/dist/review/command.js +0 -240
  122. package/dist/review/config.js +0 -280
  123. package/dist/review/plugins.d.ts +0 -3
  124. package/dist/review/plugins.js +0 -32
  125. package/dist/review/sarif.d.ts +0 -4
  126. package/dist/review/target.d.ts +0 -17
  127. package/dist/review/target.js +0 -207
  128. /package/dist/{review → commands/review}/plan-render.d.ts +0 -0
  129. /package/dist/{review → commands/review}/render.d.ts +0 -0
  130. /package/dist/{review → commands/review}/runtimes.d.ts +0 -0
  131. /package/dist/{review → io}/terminal.d.ts +0 -0
  132. /package/dist/{review → io}/terminal.js +0 -0
@@ -0,0 +1,76 @@
1
+ import { originRepository } from "../repository-id.js";
2
+ import { readAccountSalt, saveAccountSalt } from "./account-salt.js";
3
+ import { CloudClient, sessionLostReason } from "./client.js";
4
+ import { sharedFindings } from "./findings.js";
5
+ import { accountHasMemory, fetchAccountMemory } from "./memory.js";
6
+ import { repoHash, uploadOf, uploadReview } from "./upload.js";
7
+ /** What a review loses when ocra Cloud cannot be used for it, said once. */
8
+ export function sessionLostWarning(lost) {
9
+ const cause = lost.kind === "revoked"
10
+ ? sessionLostReason(lost)
11
+ : `could not reach ocra Cloud (${lost.reason})`;
12
+ return `${cause}; this review runs without your account's rules, limits (maxCostUsd included), models and memory, and uploads nothing`;
13
+ }
14
+ /**
15
+ * Undefined when the saved session is gone; a failure to reach ocra Cloud is
16
+ * one warning. The repository is the pull or merge request's when given,
17
+ * else origin's.
18
+ */
19
+ export async function prepareCloudReview(root, deps, warn, repository) {
20
+ const id = repository ?? (await originRepository(root));
21
+ let salt;
22
+ try {
23
+ const answer = await new CloudClient(deps).accountSalt();
24
+ if (answer.kind === "signed-out")
25
+ return undefined;
26
+ if (answer.kind === "status")
27
+ throw new Error(`HTTP ${answer.status}`);
28
+ if (answer.kind === "malformed")
29
+ throw new Error("the answer is not a salt");
30
+ if (answer.kind !== "ok")
31
+ throw new Error(sessionLostReason(answer));
32
+ salt = answer.value;
33
+ }
34
+ catch (error) {
35
+ // The salt kept from the last answer, else this machine's, still groups
36
+ // the counts. Whether the account still shares findings is unknown, so
37
+ // none is sent and the account's memory is not applied.
38
+ warn(`could not read your ocra Cloud account (${error instanceof Error ? error.message : "error"}); this review sends no findings and applies no account memory`);
39
+ const kept = await readAccountSalt(deps.credentialsPath);
40
+ return {
41
+ repoHash: await repoHash(id, deps.credentialsPath, kept),
42
+ shareFindings: false,
43
+ memory: [],
44
+ };
45
+ }
46
+ await saveAccountSalt(deps.credentialsPath, salt);
47
+ const hash = await repoHash(id, deps.credentialsPath, salt ?? undefined);
48
+ if (salt === null) {
49
+ // Memory is keyed by the account's hash, which only a sharing account has.
50
+ if (await accountHasMemory(deps)) {
51
+ warn("your ocra Cloud account remembers findings, but applies them only while it shares findings (Settings in ocra Cloud); this review applies the repository's memory alone");
52
+ }
53
+ return { repoHash: hash, shareFindings: false, memory: [] };
54
+ }
55
+ return {
56
+ repoHash: hash,
57
+ shareFindings: true,
58
+ memory: await fetchAccountMemory(deps, hash, warn),
59
+ };
60
+ }
61
+ /** Sends the counts, and the findings when the account shares them. */
62
+ export async function sendToCloud(report, cloud, deps, run) {
63
+ const upload = uploadOf(report, run.source, cloud.repoHash, run.durationMs);
64
+ const shared = cloud.shareFindings ? sharedFindings(report) : undefined;
65
+ const sent = await uploadReview(upload, deps, run.warn, shared?.findings);
66
+ if (!sent)
67
+ return;
68
+ if (sent.findings > 0) {
69
+ const left = shared?.left ? ` (${shared.left} left out by the size limit)` : "";
70
+ run.err.write(`[ocra] Sent this review's counts and ${sent.findings} finding(s) to ocra Cloud${left} (--no-upload to skip)\n`);
71
+ }
72
+ else {
73
+ run.err.write("[ocra] Sent this review's counts to ocra Cloud (--no-upload to skip)\n");
74
+ }
75
+ }
76
+ //# sourceMappingURL=review.js.map
@@ -0,0 +1,22 @@
1
+ import type { SettingsLayer } from "../config/settings.js";
2
+ import { type AccountPlugins } from "../plugins/account.js";
3
+ import { type AccountSettings } from "./account-settings.js";
4
+ import { type CloudSessionLost } from "./client.js";
5
+ import type { CloudDeps } from "./deps.js";
6
+ export type AccountRead = {
7
+ kind: "read";
8
+ settings?: AccountSettings;
9
+ plugins: AccountPlugins;
10
+ } | CloudSessionLost;
11
+ /**
12
+ * The account's settings. A server that cannot be reached, or a session
13
+ * that ended, is answered for the caller to say once what the run loses;
14
+ * an answer that cannot be used is a warning here and no settings.
15
+ */
16
+ export declare function fetchAccountSettings(deps: CloudDeps, warn: (message: string) => void): Promise<AccountRead>;
17
+ /**
18
+ * The account's settings as the layer under the configuration: they fill
19
+ * what it leaves out, and add to its include, exclude and rules.
20
+ */
21
+ export declare function accountLayer(account: AccountSettings): SettingsLayer;
22
+ //# sourceMappingURL=settings.d.ts.map
@@ -0,0 +1,64 @@
1
+ import { errorMessage } from "@open-cr-agent/core";
2
+ import { parseAccountPlugins } from "../plugins/account.js";
3
+ import { parseAccountSettings } from "./account-settings.js";
4
+ import { CloudClient } from "./client.js";
5
+ const NO_PLUGINS = { plugins: [], pluginSettings: {} };
6
+ /**
7
+ * The account's settings. A server that cannot be reached, or a session
8
+ * that ended, is answered for the caller to say once what the run loses;
9
+ * an answer that cannot be used is a warning here and no settings.
10
+ */
11
+ export async function fetchAccountSettings(deps, warn) {
12
+ let answer;
13
+ try {
14
+ answer = await new CloudClient(deps).preferences();
15
+ }
16
+ catch (error) {
17
+ return { kind: "unreachable", reason: errorMessage(error) };
18
+ }
19
+ if (answer.kind === "status") {
20
+ // A server without account settings has none to apply.
21
+ if (answer.status === 404)
22
+ return { kind: "read", plugins: NO_PLUGINS };
23
+ if (answer.status >= 500 || answer.status === 429) {
24
+ return { kind: "unreachable", reason: `HTTP ${answer.status}` };
25
+ }
26
+ warn(`could not read your ocra Cloud settings (HTTP ${answer.status}); the review uses the repository's`);
27
+ return { kind: "read", plugins: NO_PLUGINS };
28
+ }
29
+ if (answer.kind === "malformed") {
30
+ warn("ignoring your ocra Cloud settings: the server's answer is not an object");
31
+ return { kind: "read", plugins: NO_PLUGINS };
32
+ }
33
+ if (answer.kind !== "ok")
34
+ return answer;
35
+ const body = answer.value;
36
+ const { settings, warnings } = parseAccountSettings(body);
37
+ for (const warning of warnings)
38
+ warn(warning);
39
+ return {
40
+ kind: "read",
41
+ ...(settings ? { settings } : {}),
42
+ plugins: parseAccountPlugins(body, warn),
43
+ };
44
+ }
45
+ /**
46
+ * The account's settings as the layer under the configuration: they fill
47
+ * what it leaves out, and add to its include, exclude and rules.
48
+ */
49
+ export function accountLayer(account) {
50
+ const { version: _, rules, ultra, ...settings } = account;
51
+ return {
52
+ source: "account",
53
+ under: true,
54
+ settings: {
55
+ ...settings,
56
+ ...(rules
57
+ ? { rules: rules.map((rule) => ({ ...rule, source: "account" })) }
58
+ : {}),
59
+ // The account's default for --ultra; there is no --no-ultra to refuse it.
60
+ ...(ultra === true ? { ultra } : {}),
61
+ },
62
+ };
63
+ }
64
+ //# sourceMappingURL=settings.js.map
@@ -0,0 +1,20 @@
1
+ import type { ReviewSource, ReviewUpload, SharedFinding } from "@open-cr-agent/cloud-contract";
2
+ import { type ReviewReport } from "@open-cr-agent/core";
3
+ import type { CloudDeps } from "./deps.js";
4
+ export declare function uploadOf(report: ReviewReport, source: ReviewSource, repoHash: string, durationMs: number): ReviewUpload;
5
+ /**
6
+ * The hash of a repository named as `https://host/owner/repo`
7
+ * (repository-id.ts), salted with the account's salt when given, else this machine's.
8
+ */
9
+ export declare function repoHash(repository: string, credentialsPath: string, accountSalt?: string): Promise<string>;
10
+ /** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
11
+ export declare function cloudEnabled(deps: CloudDeps, warn: (message: string) => void): Promise<boolean>;
12
+ /**
13
+ * Sends the counts, and the findings when given; answers how many findings
14
+ * the server kept, or undefined when it took nothing. A failure is a
15
+ * warning, never a failed review.
16
+ */
17
+ export declare function uploadReview(upload: ReviewUpload, deps: CloudDeps, warn: (message: string) => void, findings?: readonly SharedFinding[]): Promise<{
18
+ findings: number;
19
+ } | undefined>;
20
+ //# sourceMappingURL=upload.d.ts.map
@@ -0,0 +1,183 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { coverageGaps } from "@open-cr-agent/core";
5
+ import { verificationSchema } from "@open-cr-agent/core/internal";
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
+ // After a review, a signed-in CLI sends ocra Cloud its counts (ADR-0024):
11
+ // the verdict, how many findings of each severity, files and tasks, tokens
12
+ // and time. A path, a title, a finding's text or code go only when the
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
+ export function uploadOf(report, source, repoHash, durationMs) {
19
+ const severity = (s) => report.findings.filter((f) => f.severity === s).length;
20
+ const gaps = coverageGaps(report);
21
+ return {
22
+ runId: report.runId,
23
+ repoHash,
24
+ source,
25
+ tier: report.tier,
26
+ verdict: report.verdict,
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,
30
+ findings: {
31
+ critical: severity("critical"),
32
+ warning: severity("warning"),
33
+ suggestion: severity("suggestion"),
34
+ },
35
+ files: {
36
+ reviewed: report.coverage.filter((c) => c.status === "reviewed").length,
37
+ notReviewed: gaps.notReviewed,
38
+ },
39
+ tasks: {
40
+ completed: report.tasks.filter((t) => t.status === "completed").length,
41
+ failed: report.tasks.filter((t) => t.status === "failed" || t.status === "timed_out").length,
42
+ },
43
+ usage: {
44
+ inputTokens: report.usage.inputTokens,
45
+ outputTokens: report.usage.outputTokens,
46
+ costUsd: report.usage.costUsd ?? 0,
47
+ },
48
+ durationMs: Math.round(durationMs),
49
+ ocraVersion: VERSION,
50
+ ...perReviewer(report),
51
+ };
52
+ }
53
+ // Counted as `ocra metrics` counts one report: a failed or timed-out task is
54
+ // failed, a finding without a verification is unchecked, a dismissal outranks
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.
58
+ function perReviewer(report) {
59
+ const reviewers = new Map();
60
+ const of = (id) => {
61
+ let r = reviewers.get(id);
62
+ if (!r) {
63
+ r = {
64
+ tasks: 0,
65
+ failedTasks: 0,
66
+ findings: { critical: 0, warning: 0, suggestion: 0 },
67
+ costUsd: 0,
68
+ fixed: 0,
69
+ dismissed: 0,
70
+ };
71
+ reviewers.set(id, r);
72
+ }
73
+ return r;
74
+ };
75
+ for (const task of report.tasks) {
76
+ const r = of(task.reviewer);
77
+ r.tasks += 1;
78
+ if (task.status === "failed" || task.status === "timed_out")
79
+ r.failedTasks += 1;
80
+ r.costUsd += task.usage.costUsd;
81
+ }
82
+ const verification = Object.fromEntries(verificationSchema.options.map((v) => [v, 0]));
83
+ const reviewerOf = new Map();
84
+ for (const finding of report.findings) {
85
+ of(finding.reviewer).findings[finding.severity] += 1;
86
+ verification[finding.verification ?? "unchecked"] += 1;
87
+ if (!reviewerOf.has(finding.fingerprint))
88
+ reviewerOf.set(finding.fingerprint, finding.reviewer);
89
+ }
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())
96
+ if (reviewer)
97
+ of(reviewer).fixed += 1;
98
+ for (const reviewer of dismissed.values())
99
+ if (reviewer)
100
+ of(reviewer).dismissed += 1;
101
+ return {
102
+ reviewers: Object.fromEntries([...reviewers].sort(([a], [b]) => a.localeCompare(b))),
103
+ verification,
104
+ outcomes: { fixed: fixed.size, dismissed: dismissed.size },
105
+ };
106
+ }
107
+ /** This machine's random salt, kept beside the credentials, made on first use. */
108
+ async function machineSalt(credentialsPath) {
109
+ const path = join(dirname(credentialsPath), "upload-salt");
110
+ const existing = await readSalt(path);
111
+ if (existing !== "unreadable") {
112
+ if (existing)
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;
121
+ }
122
+ const fresh = randomBytes(32).toString("hex");
123
+ await writePrivateFile(path, `${fresh}\n`);
124
+ return fresh;
125
+ }
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) {
142
+ const salt = accountSalt ?? (await machineSalt(credentialsPath));
143
+ return createHash("sha256").update(`${salt}:${repository}`).digest("hex");
144
+ }
145
+ /** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
146
+ export async function cloudEnabled(deps, warn) {
147
+ return (deps.env.OCRA_CLOUD !== "off" &&
148
+ (await readCredentials(deps.credentialsPath, warn)) !== undefined);
149
+ }
150
+ /**
151
+ * Sends the counts, and the findings when given; answers how many findings
152
+ * the server kept, or undefined when it took nothing. A failure is a
153
+ * warning, never a failed review.
154
+ */
155
+ export async function uploadReview(upload, deps, warn, findings) {
156
+ try {
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})`);
164
+ return undefined;
165
+ }
166
+ if (answer.kind !== "ok") {
167
+ warn(`could not send the review's counts to ocra Cloud: ${sessionLostReason(answer)}`);
168
+ return undefined;
169
+ }
170
+ return answer.value;
171
+ }
172
+ catch (error) {
173
+ warn(`could not send the review's counts to ocra Cloud: ${failureName(error)}`);
174
+ return undefined;
175
+ }
176
+ }
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`);