@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
@@ -0,0 +1,6 @@
1
+ export declare function accountSaltPath(credentialsPath: string): string;
2
+ /** Keeps the salt beside the credentials (0600), or removes it when the account has none. */
3
+ export declare function saveAccountSalt(credentialsPath: string, salt: string | null): Promise<void>;
4
+ /** The salt kept beside the credentials, or undefined when there is none or it is not a salt. */
5
+ export declare function readAccountSalt(credentialsPath: string): Promise<string | undefined>;
6
+ //# sourceMappingURL=account-salt.d.ts.map
@@ -0,0 +1,33 @@
1
+ import { readFile, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { writePrivateFile } from "./private-file.js";
4
+ // The account's repository-hash salt (ADR-0028, 4). The server answers one
5
+ // only while the account shares findings, so that a repository hashes alike
6
+ // on every machine of the account; null means the per-machine salt stays.
7
+ // It is kept beside the credentials, readable only by this user, and
8
+ // refreshed at login and by each signed-in review; a review that cannot
9
+ // reach ocra Cloud hashes with the one kept, so its counts group alike.
10
+ const SALT = /^[0-9a-f]{64}$/;
11
+ export function accountSaltPath(credentialsPath) {
12
+ return join(dirname(credentialsPath), "account-salt");
13
+ }
14
+ /** Keeps the salt beside the credentials (0600), or removes it when the account has none. */
15
+ export async function saveAccountSalt(credentialsPath, salt) {
16
+ const path = accountSaltPath(credentialsPath);
17
+ if (salt === null) {
18
+ await rm(path, { force: true });
19
+ return;
20
+ }
21
+ await writePrivateFile(path, `${salt}\n`);
22
+ }
23
+ /** The salt kept beside the credentials, or undefined when there is none or it is not a salt. */
24
+ export async function readAccountSalt(credentialsPath) {
25
+ try {
26
+ const salt = (await readFile(accountSaltPath(credentialsPath), "utf8")).trim();
27
+ return SALT.test(salt) ? salt : undefined;
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ //# sourceMappingURL=account-salt.js.map
@@ -1,7 +1,7 @@
1
+ import { CLOUD_PREFIX, isCloudModel } from "@open-cr-agent/cloud-contract";
1
2
  import { repoRuleSchema } from "@open-cr-agent/core/internal";
2
3
  import { z } from "zod";
3
- import { CLOUD_PREFIX } from "./cloud-providers.js";
4
- import { configSchema } from "./config.js";
4
+ import { configSchema } from "../config/schema.js";
5
5
  // What ocra Cloud's GET /api/preferences may set (ADR-0025, ADR-0027),
6
6
  // checked key by key with the configuration file's own schema: a key that
7
7
  // does not match is refused alone, and a key this CLI does not know is
@@ -15,8 +15,9 @@ const accountRules = z
15
15
  .max(50);
16
16
  // Account models go through the gateway with the account's stored keys
17
17
  // (ADR-0027): a chain naming any other provider could send the code where
18
- // this machine's own keys reach.
19
- const cloudModel = (chain) => chain.every((model) => model.startsWith(CLOUD_PREFIX) && model.indexOf("/") > 0);
18
+ // this machine's own keys reach. Provider names and model ids are checked
19
+ // against what ocra Cloud may name before they reach the runtime.
20
+ const cloudModel = (chain) => chain.every(isCloudModel);
20
21
  const shape = configSchema.shape;
21
22
  const SETTINGS = {
22
23
  concurrency: shape.concurrency,
@@ -0,0 +1,17 @@
1
+ type Spawner = (command: string, args: string[], options: {
2
+ stdio: "ignore";
3
+ detached: true;
4
+ }) => {
5
+ on(event: "error", listener: () => void): unknown;
6
+ unref(): void;
7
+ };
8
+ /**
9
+ * The sign-in page's address when ocra may open it: an https URL (http only
10
+ * on this machine) on the server in use. Undefined otherwise, and the user
11
+ * opens the printed address themselves.
12
+ */
13
+ export declare function signInPage(candidate: string, server: string): string | undefined;
14
+ /** Opens an address that signInPage accepted in the default browser. */
15
+ export declare function openBrowser(href: string, platform?: NodeJS.Platform, spawner?: Spawner): void;
16
+ export {};
17
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1,37 @@
1
+ import { spawn } from "node:child_process";
2
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
3
+ /**
4
+ * The sign-in page's address when ocra may open it: an https URL (http only
5
+ * on this machine) on the server in use. Undefined otherwise, and the user
6
+ * opens the printed address themselves.
7
+ */
8
+ export function signInPage(candidate, server) {
9
+ let url;
10
+ let origin;
11
+ try {
12
+ url = new URL(candidate);
13
+ origin = new URL(server).origin;
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ const scheme = url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK.has(url.hostname));
19
+ return scheme && url.origin === origin ? url.href : undefined;
20
+ }
21
+ /** Opens an address that signInPage accepted in the default browser. */
22
+ export function openBrowser(href, platform = process.platform, spawner = spawn) {
23
+ const [cmd, args] = platform === "darwin"
24
+ ? ["open", [href]]
25
+ : platform === "win32"
26
+ ? ["rundll32", ["url.dll,FileProtocolHandler", href]]
27
+ : ["xdg-open", [href]];
28
+ try {
29
+ const child = spawner(cmd, args, { stdio: "ignore", detached: true });
30
+ child.on("error", () => { });
31
+ child.unref();
32
+ }
33
+ catch {
34
+ // The URL is printed as well; a missing opener is not an error.
35
+ }
36
+ }
37
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1,93 @@
1
+ import { type DeviceCode, type ProviderEntry, type TokenAnswer } from "@open-cr-agent/cloud-contract";
2
+ import { OcraError } from "@open-cr-agent/core";
3
+ import { type Credentials } from "./credentials.js";
4
+ import type { CloudDeps } from "./deps.js";
5
+ /** A call to ocra Cloud that could not be sent or whose answer cannot be used. */
6
+ export declare class CloudError extends OcraError {
7
+ constructor(message: string, options?: {
8
+ cause?: unknown;
9
+ });
10
+ }
11
+ /** The saved session, or why there is none to use for this run. */
12
+ export type CloudSession = {
13
+ kind: "ok";
14
+ credentials: Credentials;
15
+ } | {
16
+ kind: "signed-out";
17
+ } | {
18
+ kind: "revoked";
19
+ } | {
20
+ kind: "unreachable";
21
+ reason: string;
22
+ };
23
+ export type CloudSessionLost = Exclude<CloudSession, {
24
+ kind: "ok";
25
+ }>;
26
+ /** Why the session cannot be used, as the reason a call to ocra Cloud failed. */
27
+ export declare function sessionLostReason(lost: Exclude<CloudSessionLost, {
28
+ kind: "signed-out";
29
+ }>): string;
30
+ /** An account call's outcome: the value, the HTTP status of a refusal, an answer that is not one, or no session. */
31
+ export type CloudResult<T> = {
32
+ kind: "ok";
33
+ value: T;
34
+ } | {
35
+ kind: "status";
36
+ status: number;
37
+ } | {
38
+ kind: "malformed";
39
+ } | CloudSessionLost;
40
+ export type LoginStart = {
41
+ kind: "started";
42
+ code: DeviceCode;
43
+ } | {
44
+ kind: "refused";
45
+ status: number;
46
+ };
47
+ export declare class CloudClient {
48
+ private readonly deps;
49
+ constructor(deps: CloudDeps);
50
+ /** Starts the device flow (RFC 8628, 3.1). */
51
+ startLogin(server: string): Promise<LoginStart>;
52
+ /** One poll of the device flow (RFC 8628, 3.4); an answer that is not one has no field. */
53
+ pollLogin(server: string, deviceCode: string): Promise<TokenAnswer & {
54
+ status: number;
55
+ }>;
56
+ /** The account a token belongs to; undefined when the server refuses the token. */
57
+ account(server: string, token: string): Promise<{
58
+ login: string;
59
+ } | undefined>;
60
+ /** Ends the session on the server; the caller removes the local file. */
61
+ endSession(credentials: Credentials): Promise<void>;
62
+ /** The gateway's providers, listed without a session. */
63
+ providers(server: string): Promise<Exclude<CloudResult<ProviderEntry[]>, CloudSessionLost>>;
64
+ /**
65
+ * The saved session with a live access token, refreshed (and saved) when
66
+ * it has expired or is about to, or when the server refused `rejected`,
67
+ * the access token a call just got 401 for.
68
+ */
69
+ session(minValidityMs?: number, rejected?: string): Promise<CloudSession>;
70
+ /** The account's repository-hash salt; null while it shares no findings, or on a server without salts. */
71
+ accountSalt(): Promise<CloudResult<string | null>>;
72
+ /** The account's settings, as an object for the settings parsers. */
73
+ preferences(): Promise<CloudResult<Record<string, unknown>>>;
74
+ /** The findings the account remembers, for one repository's hash or for all. */
75
+ memory(repoHash?: string): Promise<CloudResult<unknown[]>>;
76
+ /** Sends a review's counts (and shared findings); answers how many findings the server kept. */
77
+ uploadReview(upload: unknown): Promise<Exclude<CloudResult<{
78
+ findings: number;
79
+ }>, {
80
+ kind: "malformed";
81
+ }>>;
82
+ /**
83
+ * A call for the account with the session's token. A 401 for a token this
84
+ * machine still holds live refreshes the session once and retries once.
85
+ * A failure to send the call throws a CloudError.
86
+ */
87
+ private call;
88
+ /** The new pair, saved; revoked when the server refuses the refresh token. */
89
+ private refresh;
90
+ private tokens;
91
+ private request;
92
+ }
93
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,221 @@
1
+ import { accountSchema, deviceCodeSchema, memoryAnswerSchema, preferencesAnswerSchema, providerEntrySchema, providersAnswerSchema, saltSchema, tokenAnswerSchema, uploadAnswerSchema, } from "@open-cr-agent/cloud-contract";
2
+ import { errorMessage, OcraError } from "@open-cr-agent/core";
3
+ import { VERSION } from "../version.js";
4
+ import { readCredentials, writeCredentials } from "./credentials.js";
5
+ import { withFileLock } from "./file-lock.js";
6
+ // The one way to ocra Cloud (ADR-0024): every call goes through request(),
7
+ // with its user agent and timeout, and every answer is checked against its
8
+ // schema (@open-cr-agent/cloud-contract) before a caller sees it. Calls for the account carry the
9
+ // saved session's token and renew it on the way.
10
+ const USER_AGENT = `ocra/${VERSION}`;
11
+ const CLOUD_TIMEOUT_MS = 30_000;
12
+ /** A call to ocra Cloud that could not be sent or whose answer cannot be used. */
13
+ export class CloudError extends OcraError {
14
+ constructor(message, options) {
15
+ super("CLOUD_API_FAILED", message, options);
16
+ this.name = "CloudError";
17
+ }
18
+ }
19
+ /** Why the session cannot be used, as the reason a call to ocra Cloud failed. */
20
+ export function sessionLostReason(lost) {
21
+ return lost.kind === "revoked" ? "your ocra Cloud session ended: run ocra login" : lost.reason;
22
+ }
23
+ export class CloudClient {
24
+ deps;
25
+ constructor(deps) {
26
+ this.deps = deps;
27
+ }
28
+ /** Starts the device flow (RFC 8628, 3.1). */
29
+ async startLogin(server) {
30
+ const res = await this.request(`${server}/api/device/code`, {
31
+ method: "POST",
32
+ body: { client_name: this.deps.clientName },
33
+ });
34
+ const code = res.status === 200 ? await answerOf(res, deviceCodeSchema) : undefined;
35
+ return code ? { kind: "started", code } : { kind: "refused", status: res.status };
36
+ }
37
+ /** One poll of the device flow (RFC 8628, 3.4); an answer that is not one has no field. */
38
+ async pollLogin(server, deviceCode) {
39
+ return this.tokens(`${server}/api/device/token`, { device_code: deviceCode });
40
+ }
41
+ /** The account a token belongs to; undefined when the server refuses the token. */
42
+ async account(server, token) {
43
+ const res = await this.request(`${server}/api/me`, { token });
44
+ if (res.status === 401)
45
+ return undefined;
46
+ if (!res.ok)
47
+ throw new CloudError(`ocra Cloud answered ${res.status}`);
48
+ const me = await answerOf(res, accountSchema);
49
+ if (!me)
50
+ throw new CloudError("ocra Cloud's answer names no account");
51
+ return me;
52
+ }
53
+ /** Ends the session on the server; the caller removes the local file. */
54
+ async endSession(credentials) {
55
+ await this.request(`${credentials.server}/api/auth/logout`, {
56
+ method: "POST",
57
+ body: {},
58
+ token: credentials.access_token,
59
+ });
60
+ }
61
+ /** The gateway's providers, listed without a session. */
62
+ async providers(server) {
63
+ const res = await this.request(`${server}/api/providers`, {});
64
+ if (!res.ok)
65
+ return { kind: "status", status: res.status };
66
+ const body = await answerOf(res, providersAnswerSchema);
67
+ if (!body)
68
+ return { kind: "malformed" };
69
+ const entries = body.providers.flatMap((entry) => {
70
+ const parsed = providerEntrySchema.safeParse(entry);
71
+ return parsed.success ? [parsed.data] : [];
72
+ });
73
+ return { kind: "ok", value: entries };
74
+ }
75
+ /**
76
+ * The saved session with a live access token, refreshed (and saved) when
77
+ * it has expired or is about to, or when the server refused `rejected`,
78
+ * the access token a call just got 401 for.
79
+ */
80
+ async session(minValidityMs = 60_000, rejected) {
81
+ const { deps } = this;
82
+ const live = (c) => c.access_token !== rejected && c.expires_at - deps.now() > minValidityMs;
83
+ const saved = await readCredentials(deps.credentialsPath);
84
+ if (!saved)
85
+ return { kind: "signed-out" };
86
+ if (live(saved))
87
+ return { kind: "ok", credentials: saved };
88
+ // One refresh at a time on this machine: the server rotates the refresh
89
+ // token, so of two processes refreshing with one token, one is refused.
90
+ return withFileLock(`${deps.credentialsPath}.lock`, async () => {
91
+ const current = await readCredentials(deps.credentialsPath);
92
+ if (!current)
93
+ return { kind: "signed-out" };
94
+ // Another process may have refreshed while this one waited for the lock.
95
+ if (live(current))
96
+ return { kind: "ok", credentials: current };
97
+ const refreshed = await this.refresh(current);
98
+ if (refreshed.kind !== "revoked")
99
+ return refreshed;
100
+ // Rotated by a process that did not wait for the lock (an older ocra,
101
+ // or one that gave up waiting): its pair is in the file.
102
+ const latest = await readCredentials(deps.credentialsPath);
103
+ if (!latest || latest.refresh_token === current.refresh_token)
104
+ return refreshed;
105
+ if (live(latest))
106
+ return { kind: "ok", credentials: latest };
107
+ return this.refresh(latest);
108
+ });
109
+ }
110
+ /** The account's repository-hash salt; null while it shares no findings, or on a server without salts. */
111
+ async accountSalt() {
112
+ const answer = await this.call("/api/account/salt", saltSchema);
113
+ if (answer.kind === "status" && answer.status === 404)
114
+ return { kind: "ok", value: null };
115
+ return answer.kind === "ok" ? { kind: "ok", value: answer.value.salt } : answer;
116
+ }
117
+ /** The account's settings, as an object for the settings parsers. */
118
+ preferences() {
119
+ return this.call("/api/preferences", preferencesAnswerSchema);
120
+ }
121
+ /** The findings the account remembers, for one repository's hash or for all. */
122
+ async memory(repoHash) {
123
+ const query = repoHash === undefined ? "" : `?repo=${encodeURIComponent(repoHash)}`;
124
+ const answer = await this.call(`/api/memory${query}`, memoryAnswerSchema);
125
+ return answer.kind === "ok" ? { kind: "ok", value: answer.value.entries } : answer;
126
+ }
127
+ /** Sends a review's counts (and shared findings); answers how many findings the server kept. */
128
+ async uploadReview(upload) {
129
+ const answer = await this.call("/api/reviews", uploadAnswerSchema, {
130
+ method: "POST",
131
+ body: upload,
132
+ });
133
+ // An answer without a count keeps the counts it acknowledges.
134
+ if (answer.kind === "malformed")
135
+ return { kind: "ok", value: { findings: 0 } };
136
+ return answer.kind === "ok"
137
+ ? { kind: "ok", value: { findings: answer.value.findings ?? 0 } }
138
+ : answer;
139
+ }
140
+ /**
141
+ * A call for the account with the session's token. A 401 for a token this
142
+ * machine still holds live refreshes the session once and retries once.
143
+ * A failure to send the call throws a CloudError.
144
+ */
145
+ async call(path, schema, init = {}) {
146
+ let session = await this.session();
147
+ if (session.kind !== "ok")
148
+ return session;
149
+ const send = (c) => this.request(`${c.server}${path}`, { ...init, token: c.access_token });
150
+ let res = await send(session.credentials);
151
+ if (res.status === 401) {
152
+ session = await this.session(undefined, session.credentials.access_token);
153
+ if (session.kind !== "ok")
154
+ return session;
155
+ res = await send(session.credentials);
156
+ }
157
+ if (!res.ok)
158
+ return { kind: "status", status: res.status };
159
+ const value = await answerOf(res, schema);
160
+ return value === undefined ? { kind: "malformed" } : { kind: "ok", value };
161
+ }
162
+ /** The new pair, saved; revoked when the server refuses the refresh token. */
163
+ async refresh(saved) {
164
+ let t;
165
+ try {
166
+ t = await this.tokens(`${saved.server}/api/device/refresh`, {
167
+ refresh_token: saved.refresh_token,
168
+ });
169
+ }
170
+ catch (error) {
171
+ return { kind: "unreachable", reason: errorMessage(error) };
172
+ }
173
+ if (t.status === 401 || t.error === "invalid_grant")
174
+ return { kind: "revoked" };
175
+ if (t.status !== 200 || !t.access_token || !t.refresh_token || !t.expires_in) {
176
+ return { kind: "unreachable", reason: `HTTP ${t.status}` };
177
+ }
178
+ const next = {
179
+ ...saved,
180
+ access_token: t.access_token,
181
+ refresh_token: t.refresh_token,
182
+ expires_at: this.deps.now() + t.expires_in * 1000,
183
+ };
184
+ await writeCredentials(this.deps.credentialsPath, next);
185
+ return { kind: "ok", credentials: next };
186
+ }
187
+ async tokens(url, body) {
188
+ const res = await this.request(url, { method: "POST", body });
189
+ return { ...((await answerOf(res, tokenAnswerSchema)) ?? {}), status: res.status };
190
+ }
191
+ async request(url, init) {
192
+ try {
193
+ return await this.deps.fetch(url, {
194
+ ...(init.method ? { method: init.method } : {}),
195
+ headers: {
196
+ "user-agent": USER_AGENT,
197
+ ...(init.token ? { authorization: `Bearer ${init.token}` } : {}),
198
+ ...(init.body !== undefined ? { "content-type": "application/json" } : {}),
199
+ },
200
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
201
+ signal: AbortSignal.timeout(CLOUD_TIMEOUT_MS),
202
+ });
203
+ }
204
+ catch (error) {
205
+ throw new CloudError(errorMessage(error), { cause: error });
206
+ }
207
+ }
208
+ }
209
+ /** The answer's JSON when it fits the schema; undefined when it is not JSON or does not fit. */
210
+ async function answerOf(res, schema) {
211
+ let body;
212
+ try {
213
+ body = await res.json();
214
+ }
215
+ catch {
216
+ return undefined;
217
+ }
218
+ const parsed = schema.safeParse(body);
219
+ return parsed.success ? parsed.data : undefined;
220
+ }
221
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,18 @@
1
+ export type Credentials = {
2
+ server: string;
3
+ login: string;
4
+ access_token: string;
5
+ refresh_token: string;
6
+ /** Epoch milliseconds when the access token expires. */
7
+ expires_at: number;
8
+ };
9
+ export declare function credentialsHint(): string;
10
+ export declare function credentialsPath(env: Readonly<Record<string, string | undefined>>): string;
11
+ /**
12
+ * The saved session; undefined when there is none or the file does not hold
13
+ * one, which `warn` hears about (a file cut short by a crash, or edited).
14
+ */
15
+ export declare function readCredentials(path: string, warn?: (message: string) => void): Promise<Credentials | undefined>;
16
+ export declare function loadCredentials(path: string): Promise<Credentials | "unreadable" | undefined>;
17
+ export declare function writeCredentials(path: string, c: Credentials): Promise<void>;
18
+ //# sourceMappingURL=credentials.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { ocraConfigDir } from "../config/user-dir.js";
4
+ import { writePrivateFile } from "./private-file.js";
5
+ export function credentialsHint() {
6
+ return process.platform === "win32"
7
+ ? "%APPDATA%\\ocra\\credentials.json"
8
+ : "~/.config/ocra/credentials.json";
9
+ }
10
+ export function credentialsPath(env) {
11
+ return join(ocraConfigDir(env), "credentials.json");
12
+ }
13
+ /**
14
+ * The saved session; undefined when there is none or the file does not hold
15
+ * one, which `warn` hears about (a file cut short by a crash, or edited).
16
+ */
17
+ export async function readCredentials(path, warn) {
18
+ const saved = await loadCredentials(path);
19
+ if (saved !== "unreadable")
20
+ return saved;
21
+ warn?.(`ignoring ${path}: it holds no ocra Cloud session; run ocra login to sign in again`);
22
+ return undefined;
23
+ }
24
+ export async function loadCredentials(path) {
25
+ let text;
26
+ try {
27
+ text = await readFile(path, "utf8");
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ let c;
33
+ try {
34
+ c = JSON.parse(text);
35
+ }
36
+ catch {
37
+ return "unreadable";
38
+ }
39
+ if (typeof c !== "object" ||
40
+ c === null ||
41
+ !c.server ||
42
+ !c.access_token ||
43
+ !c.refresh_token ||
44
+ typeof c.expires_at !== "number") {
45
+ return "unreadable";
46
+ }
47
+ return c;
48
+ }
49
+ export async function writeCredentials(path, c) {
50
+ await writePrivateFile(path, `${JSON.stringify(c, null, 2)}\n`);
51
+ }
52
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1,14 @@
1
+ /** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
2
+ export declare const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
3
+ export type CloudDeps = {
4
+ env: Readonly<Record<string, string | undefined>>;
5
+ fetch: typeof fetch;
6
+ now: () => number;
7
+ sleep: (ms: number) => Promise<void>;
8
+ openBrowser: (url: string) => void;
9
+ credentialsPath: string;
10
+ clientName: string;
11
+ };
12
+ export declare function defaultCloudDeps(env?: CloudDeps["env"]): CloudDeps;
13
+ export declare function cloudUrl(env: CloudDeps["env"]): string;
14
+ //# sourceMappingURL=deps.d.ts.map
@@ -0,0 +1,34 @@
1
+ import { hostname } from "node:os";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import { UsageError } from "../io/usage-error.js";
4
+ import { VERSION } from "../version.js";
5
+ import { openBrowser } from "./browser.js";
6
+ import { credentialsPath } from "./credentials.js";
7
+ /** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
8
+ export const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
9
+ export function defaultCloudDeps(env = process.env) {
10
+ return {
11
+ env,
12
+ fetch: globalThis.fetch,
13
+ now: Date.now,
14
+ sleep: (ms) => sleep(ms),
15
+ openBrowser: (url) => openBrowser(url),
16
+ credentialsPath: credentialsPath(env),
17
+ clientName: `${hostname()} (ocra ${VERSION})`,
18
+ };
19
+ }
20
+ export function cloudUrl(env) {
21
+ const named = env.OCRA_CLOUD_URL || DEFAULT_CLOUD_URL;
22
+ let url;
23
+ try {
24
+ url = new URL(named);
25
+ }
26
+ catch {
27
+ throw new UsageError(`OCRA_CLOUD_URL is not a URL: ${named}`);
28
+ }
29
+ if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
30
+ throw new UsageError(`OCRA_CLOUD_URL must use https: ${url}`);
31
+ }
32
+ return url.origin;
33
+ }
34
+ //# sourceMappingURL=deps.js.map
@@ -0,0 +1,14 @@
1
+ export type LockTiming = {
2
+ /** How long to wait for another holder before going on without the lock. */
3
+ waitMs: number;
4
+ /** A lock older than this was left by a process that died holding it. */
5
+ staleMs: number;
6
+ pollMs: number;
7
+ };
8
+ /**
9
+ * Runs `fn` holding the lock at `path`. When it cannot be had within
10
+ * `waitMs`, `fn` runs anyway: the lock only narrows races its callers must
11
+ * still survive, and a stuck holder must not stop a review.
12
+ */
13
+ export declare function withFileLock<T>(path: string, fn: () => Promise<T>, timing?: LockTiming): Promise<T>;
14
+ //# sourceMappingURL=file-lock.d.ts.map
@@ -0,0 +1,77 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { errnoCode, isNotFound } from "@open-cr-agent/core/internal";
5
+ const TIMING = { waitMs: 10_000, staleMs: 30_000, pollMs: 50 };
6
+ /**
7
+ * Runs `fn` holding the lock at `path`. When it cannot be had within
8
+ * `waitMs`, `fn` runs anyway: the lock only narrows races its callers must
9
+ * still survive, and a stuck holder must not stop a review.
10
+ */
11
+ export async function withFileLock(path, fn, timing = TIMING) {
12
+ const token = await acquire(path, timing);
13
+ try {
14
+ return await fn();
15
+ }
16
+ finally {
17
+ if (token)
18
+ await release(path, token);
19
+ }
20
+ }
21
+ async function acquire(path, timing) {
22
+ const token = randomBytes(8).toString("hex");
23
+ const deadline = Date.now() + timing.waitMs;
24
+ for (;;) {
25
+ try {
26
+ await writeFile(path, JSON.stringify({ token, at: Date.now(), pid: process.pid }), {
27
+ flag: "wx",
28
+ mode: 0o600,
29
+ });
30
+ return token;
31
+ }
32
+ catch (error) {
33
+ // A lock that cannot be made at all (a read-only directory) is no lock.
34
+ if (errnoCode(error) !== "EEXIST")
35
+ return undefined;
36
+ }
37
+ const age = await ageOf(path);
38
+ if (age === undefined)
39
+ continue;
40
+ if (age > timing.staleMs) {
41
+ await rm(path, { force: true });
42
+ continue;
43
+ }
44
+ if (Date.now() >= deadline)
45
+ return undefined;
46
+ await sleep(timing.pollMs);
47
+ }
48
+ }
49
+ /** Milliseconds since the lock was taken; undefined when it is gone. */
50
+ async function ageOf(path) {
51
+ try {
52
+ const at = JSON.parse(await readFile(path, "utf8")).at;
53
+ if (typeof at === "number")
54
+ return Date.now() - at;
55
+ }
56
+ catch (error) {
57
+ if (isNotFound(error))
58
+ return undefined;
59
+ }
60
+ // Being written, or not a lock of ours: its file's own time stands in.
61
+ try {
62
+ return Date.now() - (await stat(path)).mtimeMs;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ async function release(path, token) {
69
+ try {
70
+ const held = JSON.parse(await readFile(path, "utf8"));
71
+ // Broken as stale and taken by another: theirs now.
72
+ if (held.token === token)
73
+ await rm(path, { force: true });
74
+ }
75
+ catch { }
76
+ }
77
+ //# sourceMappingURL=file-lock.js.map
@@ -0,0 +1,8 @@
1
+ import { type SharedFinding } from "@open-cr-agent/cloud-contract";
2
+ import type { ReviewReport } from "@open-cr-agent/core";
3
+ /** The report's findings as uploaded, redacted and bounded; `left` counts those not sent. */
4
+ export declare function sharedFindings(report: Pick<ReviewReport, "findings">): {
5
+ findings: SharedFinding[];
6
+ left: number;
7
+ };
8
+ //# sourceMappingURL=findings.d.ts.map