@open-cr-agent/cli 0.3.0 → 0.5.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 (45) hide show
  1. package/README.md +3 -3
  2. package/dist/account-salt.d.ts +7 -0
  3. package/dist/account-salt.js +43 -0
  4. package/dist/cloud.d.ts +34 -0
  5. package/dist/cloud.js +272 -0
  6. package/dist/plugin-store.d.ts +20 -0
  7. package/dist/plugin-store.js +84 -0
  8. package/dist/plugins-command.d.ts +15 -0
  9. package/dist/plugins-command.js +183 -0
  10. package/dist/review/account-plugins.d.ts +23 -0
  11. package/dist/review/account-plugins.js +54 -0
  12. package/dist/review/account-settings.d.ts +100 -0
  13. package/dist/review/account-settings.js +106 -0
  14. package/dist/review/args.d.ts +2 -1
  15. package/dist/review/args.js +5 -0
  16. package/dist/review/cloud-findings.d.ts +31 -0
  17. package/dist/review/cloud-findings.js +80 -0
  18. package/dist/review/cloud-memory.d.ts +6 -0
  19. package/dist/review/cloud-memory.js +58 -0
  20. package/dist/review/cloud-providers.d.ts +12 -0
  21. package/dist/review/cloud-providers.js +60 -0
  22. package/dist/review/cloud-review.d.ts +19 -0
  23. package/dist/review/cloud-review.js +51 -0
  24. package/dist/review/cloud-settings.d.ts +16 -0
  25. package/dist/review/cloud-settings.js +118 -0
  26. package/dist/review/cloud-upload.d.ts +60 -0
  27. package/dist/review/cloud-upload.js +180 -0
  28. package/dist/review/command.d.ts +4 -0
  29. package/dist/review/command.js +91 -14
  30. package/dist/review/config.d.ts +65 -6
  31. package/dist/review/config.js +71 -7
  32. package/dist/review/plan-render.js +1 -1
  33. package/dist/review/plugins.d.ts +10 -0
  34. package/dist/review/plugins.js +46 -1
  35. package/dist/review/provenance.js +15 -2
  36. package/dist/review/remote-config.d.ts +6 -0
  37. package/dist/review/remote-config.js +3 -1
  38. package/dist/review/render.js +7 -2
  39. package/dist/review/sarif.js +1 -1
  40. package/dist/review/settings-sources.d.ts +11 -0
  41. package/dist/review/settings-sources.js +58 -0
  42. package/dist/review/target.d.ts +1 -0
  43. package/dist/review/target.js +4 -1
  44. package/dist/run.js +36 -0
  45. package/package.json +8 -8
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `ocra` is the command line of [Open-CR-Agent](https://github.com/jma49/Open-CR-Agent), an open-source multi-agent code reviewer for local changes and GitHub pull requests. Deterministic code selects and bundles the files, matches rules and anchors comments; LLM reviewers make the judgment calls, and a verification pass checks what they report.
4
4
 
5
- This is an early 0.x release: options and output may change between minor versions. What the reviews find and miss is measured on the [quality page](https://ocra.majincheng.com/en/docs/quality).
5
+ This is an early 0.x release: options and output may change between minor versions. What the reviews find and miss is measured on the [quality page](https://ocracloud.com/en/docs/quality).
6
6
 
7
7
  ## Install
8
8
 
@@ -34,8 +34,8 @@ Pull requests can also be reviewed by the GitHub Action: inline comments, one su
34
34
 
35
35
  ## Documentation
36
36
 
37
- - [Quickstart](https://ocra.majincheng.com/en/docs/quickstart) and the manual, in [English](https://ocra.majincheng.com/en/docs) and [中文](https://ocra.majincheng.com/zh/docs)
38
- - [GitHub pull requests](https://ocra.majincheng.com/en/docs/github)
37
+ - [Quickstart](https://ocracloud.com/en/docs/quickstart) and the manual, in [English](https://ocracloud.com/en/docs) and [中文](https://ocracloud.com/zh/docs)
38
+ - [GitHub pull requests](https://ocracloud.com/en/docs/github)
39
39
  - [Changelog](https://github.com/jma49/Open-CR-Agent/blob/main/CHANGELOG.md) and [issues](https://github.com/jma49/Open-CR-Agent/issues)
40
40
 
41
41
  ## License
@@ -0,0 +1,7 @@
1
+ import type { CloudDeps } from "./cloud.js";
2
+ export declare function accountSaltPath(credentialsPath: string): string;
3
+ /** The account's salt, or null; throws when the server cannot be asked. */
4
+ export declare function fetchAccountSalt(deps: Pick<CloudDeps, "fetch">, server: string, token: string): Promise<string | null>;
5
+ /** Keeps the salt beside the credentials (0600), or removes it when the account has none. */
6
+ export declare function saveAccountSalt(credentialsPath: string, salt: string | null): Promise<void>;
7
+ //# sourceMappingURL=account-salt.d.ts.map
@@ -0,0 +1,43 @@
1
+ import { chmod, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { VERSION } from "./version.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.
9
+ const SALT = /^[0-9a-f]{64}$/;
10
+ export function accountSaltPath(credentialsPath) {
11
+ return join(dirname(credentialsPath), "account-salt");
12
+ }
13
+ /** The account's salt, or null; throws when the server cannot be asked. */
14
+ export async function fetchAccountSalt(deps, server, token) {
15
+ const res = await deps.fetch(`${server}/api/account/salt`, {
16
+ headers: { authorization: `Bearer ${token}`, "user-agent": `ocra/${VERSION}` },
17
+ signal: AbortSignal.timeout(15_000),
18
+ });
19
+ // A server without account salts shares no findings.
20
+ if (res.status === 404)
21
+ return null;
22
+ if (!res.ok)
23
+ throw new Error(`HTTP ${res.status}`);
24
+ const body = (await res.json());
25
+ if (body.salt === null)
26
+ return null;
27
+ if (typeof body.salt === "string" && SALT.test(body.salt))
28
+ return body.salt;
29
+ throw new Error("the answer is not a salt");
30
+ }
31
+ /** Keeps the salt beside the credentials (0600), or removes it when the account has none. */
32
+ export async function saveAccountSalt(credentialsPath, salt) {
33
+ const path = accountSaltPath(credentialsPath);
34
+ if (salt === null) {
35
+ await rm(path, { force: true });
36
+ return;
37
+ }
38
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
39
+ await writeFile(path, `${salt}\n`, { mode: 0o600 });
40
+ // writeFile keeps the mode of a file that already exists.
41
+ await chmod(path, 0o600);
42
+ }
43
+ //# sourceMappingURL=account-salt.js.map
@@ -0,0 +1,34 @@
1
+ import type { Output } from "./review/progress.js";
2
+ /** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
3
+ export declare const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
4
+ export declare const LOGIN_USAGE: string;
5
+ export type Credentials = {
6
+ server: string;
7
+ login: string;
8
+ access_token: string;
9
+ refresh_token: string;
10
+ /** Epoch milliseconds when the access token expires. */
11
+ expires_at: number;
12
+ };
13
+ export type CloudDeps = {
14
+ env: Readonly<Record<string, string | undefined>>;
15
+ fetch: typeof fetch;
16
+ now: () => number;
17
+ sleep: (ms: number) => Promise<void>;
18
+ openBrowser: (url: string) => void;
19
+ credentialsPath: string;
20
+ clientName: string;
21
+ };
22
+ export declare function defaultCloudDeps(env?: CloudDeps["env"]): CloudDeps;
23
+ export declare function credentialsPath(env: CloudDeps["env"]): string;
24
+ /** ~/.config/ocra ($XDG_CONFIG_HOME/ocra), or %APPDATA%\ocra on Windows: this machine's own. */
25
+ export declare function ocraConfigDir(env: CloudDeps["env"]): string;
26
+ export declare function readCredentials(path: string): Promise<Credentials | undefined>;
27
+ /**
28
+ * The saved session with a live access token, refreshed (and saved) when
29
+ * it has expired or is about to; undefined when not signed in or the
30
+ * session was revoked.
31
+ */
32
+ export declare function cloudSession(deps: CloudDeps, minValidityMs?: number): Promise<Credentials | undefined>;
33
+ export declare function cloudCommand(command: "login" | "logout" | "whoami", argv: string[], out: Output, err: Output, deps: CloudDeps): Promise<number>;
34
+ //# sourceMappingURL=cloud.d.ts.map
package/dist/cloud.js ADDED
@@ -0,0 +1,272 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, hostname } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { errorMessage } from "@open-cr-agent/core/internal";
7
+ import { fetchAccountSalt, saveAccountSalt } from "./account-salt.js";
8
+ import { UsageError } from "./review/args.js";
9
+ import { forTerminal } from "./review/terminal.js";
10
+ import { VERSION } from "./version.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
+ /** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
16
+ export const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
17
+ export const LOGIN_USAGE = `Usage: ocra login [--no-browser]
18
+ ocra logout
19
+ ocra whoami
20
+
21
+ Sign in to ocra Cloud with GitHub, so reviews can use the model keys stored
22
+ there. ocra login shows a code, opens the browser to confirm it, and saves the
23
+ session to ${credentialsHint()} (readable only by you).
24
+
25
+ Environment:
26
+ OCRA_CLOUD=off Disable ocra Cloud: these commands refuse to run
27
+ OCRA_CLOUD_URL Another ocra Cloud server (default ${DEFAULT_CLOUD_URL})
28
+ `;
29
+ export function defaultCloudDeps(env = process.env) {
30
+ return {
31
+ env,
32
+ fetch: globalThis.fetch,
33
+ now: Date.now,
34
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
35
+ openBrowser,
36
+ credentialsPath: credentialsPath(env),
37
+ clientName: `${hostname()} (ocra ${VERSION})`,
38
+ };
39
+ }
40
+ function credentialsHint() {
41
+ return process.platform === "win32"
42
+ ? "%APPDATA%\\ocra\\credentials.json"
43
+ : "~/.config/ocra/credentials.json";
44
+ }
45
+ export function credentialsPath(env) {
46
+ return join(ocraConfigDir(env), "credentials.json");
47
+ }
48
+ /** ~/.config/ocra ($XDG_CONFIG_HOME/ocra), or %APPDATA%\ocra on Windows: this machine's own. */
49
+ export function ocraConfigDir(env) {
50
+ const base = process.platform === "win32"
51
+ ? (env.APPDATA ?? join(homedir(), "AppData", "Roaming"))
52
+ : (env.XDG_CONFIG_HOME ?? join(homedir(), ".config"));
53
+ return join(base, "ocra");
54
+ }
55
+ function cloudUrl(env) {
56
+ const named = env.OCRA_CLOUD_URL || DEFAULT_CLOUD_URL;
57
+ let url;
58
+ try {
59
+ url = new URL(named);
60
+ }
61
+ catch {
62
+ throw new UsageError(`OCRA_CLOUD_URL is not a URL: ${named}`);
63
+ }
64
+ if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
65
+ throw new UsageError(`OCRA_CLOUD_URL must use https: ${url}`);
66
+ }
67
+ return url.origin;
68
+ }
69
+ function openBrowser(url) {
70
+ const [cmd, args] = process.platform === "darwin"
71
+ ? ["open", [url]]
72
+ : process.platform === "win32"
73
+ ? ["cmd", ["/c", "start", "", url]]
74
+ : ["xdg-open", [url]];
75
+ try {
76
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
77
+ child.on("error", () => { });
78
+ child.unref();
79
+ }
80
+ catch {
81
+ // The URL is printed as well; a missing opener is not an error.
82
+ }
83
+ }
84
+ export async function readCredentials(path) {
85
+ let text;
86
+ try {
87
+ text = await readFile(path, "utf8");
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ const c = JSON.parse(text);
93
+ if (!c.server || !c.access_token || !c.refresh_token || typeof c.expires_at !== "number") {
94
+ return undefined;
95
+ }
96
+ return c;
97
+ }
98
+ async function writeCredentials(path, c) {
99
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
100
+ await writeFile(path, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 });
101
+ // writeFile keeps the mode of a file that already exists.
102
+ await chmod(path, 0o600);
103
+ }
104
+ async function postJson(deps, url, body, token) {
105
+ const res = await deps.fetch(url, {
106
+ method: "POST",
107
+ headers: {
108
+ "content-type": "application/json",
109
+ "user-agent": `ocra/${VERSION}`,
110
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
111
+ },
112
+ body: JSON.stringify(body),
113
+ signal: AbortSignal.timeout(30_000),
114
+ });
115
+ return {
116
+ status: res.status,
117
+ body: (await res.json().catch(() => ({}))),
118
+ };
119
+ }
120
+ async function fetchMe(deps, server, token) {
121
+ const res = await deps.fetch(`${server}/api/me`, {
122
+ headers: { authorization: `Bearer ${token}`, "user-agent": `ocra/${VERSION}` },
123
+ signal: AbortSignal.timeout(30_000),
124
+ });
125
+ if (res.status === 401)
126
+ return undefined;
127
+ if (!res.ok)
128
+ throw new Error(`ocra Cloud answered ${res.status}`);
129
+ return (await res.json());
130
+ }
131
+ /**
132
+ * The saved session with a live access token, refreshed (and saved) when
133
+ * it has expired or is about to; undefined when not signed in or the
134
+ * session was revoked.
135
+ */
136
+ export async function cloudSession(deps, minValidityMs = 60_000) {
137
+ const saved = await readCredentials(deps.credentialsPath);
138
+ if (!saved)
139
+ return undefined;
140
+ if (saved.expires_at - deps.now() > minValidityMs)
141
+ return saved;
142
+ const { status, body } = await postJson(deps, `${saved.server}/api/device/refresh`, {
143
+ refresh_token: saved.refresh_token,
144
+ });
145
+ const t = body;
146
+ if (status !== 200 || !t.access_token || !t.refresh_token || !t.expires_in)
147
+ return undefined;
148
+ const next = {
149
+ ...saved,
150
+ access_token: t.access_token,
151
+ refresh_token: t.refresh_token,
152
+ expires_at: deps.now() + t.expires_in * 1000,
153
+ };
154
+ await writeCredentials(deps.credentialsPath, next);
155
+ return next;
156
+ }
157
+ export async function cloudCommand(command, argv, out, err, deps) {
158
+ const { values } = parseArgs({
159
+ args: argv,
160
+ strict: true,
161
+ options: { "no-browser": { type: "boolean" }, help: { type: "boolean", short: "h" } },
162
+ });
163
+ if (values.help) {
164
+ out.write(LOGIN_USAGE);
165
+ return 0;
166
+ }
167
+ if (deps.env.OCRA_CLOUD === "off") {
168
+ err.write("ocra Cloud is off (OCRA_CLOUD=off).\n");
169
+ return 2;
170
+ }
171
+ if (command === "whoami")
172
+ return whoami(out, err, deps);
173
+ if (command === "logout")
174
+ return logout(out, deps);
175
+ return login(out, err, deps, !values["no-browser"]);
176
+ }
177
+ async function whoami(out, err, deps) {
178
+ const session = await cloudSession(deps);
179
+ if (!session) {
180
+ err.write("Not signed in. Run ocra login.\n");
181
+ return 1;
182
+ }
183
+ const me = await fetchMe(deps, session.server, session.access_token);
184
+ if (!me) {
185
+ err.write("The saved session was revoked. Run ocra login.\n");
186
+ return 1;
187
+ }
188
+ out.write(forTerminal(`${me.login} on ${session.server}\n`));
189
+ return 0;
190
+ }
191
+ async function logout(out, deps) {
192
+ const saved = await readCredentials(deps.credentialsPath);
193
+ if (saved) {
194
+ // Ends the session on the server too; the local file goes either way.
195
+ const session = await cloudSession(deps).catch(() => undefined);
196
+ if (session) {
197
+ await postJson(deps, `${session.server}/api/auth/logout`, {}, session.access_token).catch(() => { });
198
+ }
199
+ }
200
+ await rm(deps.credentialsPath, { force: true });
201
+ await saveAccountSalt(deps.credentialsPath, null);
202
+ out.write(saved ? "Signed out.\n" : "Not signed in.\n");
203
+ return 0;
204
+ }
205
+ async function login(out, err, deps, browser) {
206
+ const server = cloudUrl(deps.env);
207
+ const start = await postJson(deps, `${server}/api/device/code`, { client_name: deps.clientName });
208
+ const code = start.body;
209
+ if (start.status !== 200 ||
210
+ !code.device_code ||
211
+ !code.user_code ||
212
+ !code.verification_uri_complete) {
213
+ err.write(`ocra Cloud did not start a login (HTTP ${start.status}). Try again in a minute.\n`);
214
+ return 2;
215
+ }
216
+ out.write(forTerminal(`Confirm this code in your browser: ${code.user_code}\n` +
217
+ ` ${code.verification_uri_complete}\n` +
218
+ `(or open ${code.verification_uri} and type it)\n\nWaiting for approval...\n`));
219
+ if (browser)
220
+ deps.openBrowser(code.verification_uri_complete);
221
+ let interval = (code.interval ?? 5) * 1000;
222
+ const deadline = deps.now() + (code.expires_in ?? 600) * 1000;
223
+ while (deps.now() < deadline) {
224
+ await deps.sleep(interval);
225
+ let answer;
226
+ try {
227
+ answer = await postJson(deps, `${server}/api/device/token`, {
228
+ device_code: code.device_code,
229
+ });
230
+ }
231
+ catch (error) {
232
+ // A dropped connection is retried on the next tick.
233
+ err.write(forTerminal(`(${errorMessage(error)}; retrying)\n`));
234
+ continue;
235
+ }
236
+ const t = answer.body;
237
+ if (answer.status === 200 && t.access_token && t.refresh_token && t.expires_in) {
238
+ const me = await fetchMe(deps, server, t.access_token);
239
+ const credentials = {
240
+ server,
241
+ login: me?.login ?? "",
242
+ access_token: t.access_token,
243
+ refresh_token: t.refresh_token,
244
+ expires_at: deps.now() + t.expires_in * 1000,
245
+ };
246
+ await writeCredentials(deps.credentialsPath, credentials);
247
+ // Best effort: each signed-in review asks again.
248
+ await fetchAccountSalt(deps, server, t.access_token)
249
+ .then((salt) => saveAccountSalt(deps.credentialsPath, salt))
250
+ .catch(() => { });
251
+ out.write(forTerminal(`Signed in as ${credentials.login}.\n`));
252
+ return 0;
253
+ }
254
+ if (t.error === "authorization_pending")
255
+ continue;
256
+ if (t.error === "slow_down") {
257
+ interval = typeof t.interval === "number" ? t.interval * 1000 : interval + 5000;
258
+ continue;
259
+ }
260
+ if (t.error === "access_denied") {
261
+ err.write("Login denied in the browser.\n");
262
+ return 1;
263
+ }
264
+ if (t.error === "expired_token")
265
+ break;
266
+ err.write(forTerminal(`Login failed: ${t.error ?? `HTTP ${answer.status}`}\n`));
267
+ return 2;
268
+ }
269
+ err.write("The code expired before it was confirmed. Run ocra login again.\n");
270
+ return 1;
271
+ }
272
+ //# sourceMappingURL=cloud.js.map
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ export declare function isPackageName(name: string): boolean;
3
+ export declare function isExactVersion(version: string): boolean;
4
+ declare const allowedSchema: z.ZodObject<{
5
+ name: z.ZodString;
6
+ version: z.ZodString;
7
+ integrity: z.ZodString;
8
+ }, z.core.$strict>;
9
+ export type AllowedPlugin = z.infer<typeof allowedSchema>;
10
+ export declare function pluginsDir(env: Readonly<Record<string, string | undefined>>): string;
11
+ export declare function readAllowed(dir: string): Promise<AllowedPlugin[]>;
12
+ export declare function writeAllowed(dir: string, plugins: readonly AllowedPlugin[]): Promise<void>;
13
+ export declare function ensureDir(dir: string): Promise<void>;
14
+ /** What npm's lockfile in the directory says it installed for a package. */
15
+ export declare function installedEntry(dir: string, name: string): Promise<{
16
+ version?: string;
17
+ integrity?: string;
18
+ } | undefined>;
19
+ export {};
20
+ //# sourceMappingURL=plugin-store.d.ts.map
@@ -0,0 +1,84 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { ocraConfigDir } from "./cloud.js";
5
+ import { ConfigError } from "./review/config.js";
6
+ // The plugins this machine allows its ocra Cloud account to name (ADR-0027):
7
+ // installed by `ocra plugins allow` into a directory of the user's own, never
8
+ // the reviewed checkout, and recorded with the integrity npm installed.
9
+ // npm's package-name rules, less a leading "-" or "." that npm could read as
10
+ // an option or a path; at most 214 characters.
11
+ const PACKAGE_NAME = /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/;
12
+ // An exact version: no range, tag or URL.
13
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
14
+ export function isPackageName(name) {
15
+ return name.length <= 214 && PACKAGE_NAME.test(name);
16
+ }
17
+ export function isExactVersion(version) {
18
+ return version.length <= 256 && EXACT_VERSION.test(version);
19
+ }
20
+ const allowedSchema = z
21
+ .object({
22
+ name: z.string().refine(isPackageName),
23
+ version: z.string().refine(isExactVersion),
24
+ integrity: z.string().min(1),
25
+ })
26
+ .strict();
27
+ const fileSchema = z.object({ plugins: z.array(allowedSchema) }).strict();
28
+ export function pluginsDir(env) {
29
+ return join(ocraConfigDir(env), "plugins");
30
+ }
31
+ export async function readAllowed(dir) {
32
+ let text;
33
+ try {
34
+ text = await readFile(join(dir, "plugins.json"), "utf8");
35
+ }
36
+ catch (error) {
37
+ if (error.code === "ENOENT")
38
+ return [];
39
+ throw error;
40
+ }
41
+ let data;
42
+ try {
43
+ data = JSON.parse(text);
44
+ }
45
+ catch {
46
+ data = undefined;
47
+ }
48
+ const parsed = fileSchema.safeParse(data);
49
+ if (!parsed.success) {
50
+ throw new ConfigError(`${join(dir, "plugins.json")} is invalid; allow the plugins again or remove it`);
51
+ }
52
+ return parsed.data.plugins;
53
+ }
54
+ export async function writeAllowed(dir, plugins) {
55
+ await ensureDir(dir);
56
+ const path = join(dir, "plugins.json");
57
+ const sorted = [...plugins].sort((a, b) => (a.name < b.name ? -1 : 1));
58
+ await writeFile(path, `${JSON.stringify({ plugins: sorted }, null, 2)}\n`, { mode: 0o600 });
59
+ // writeFile keeps the mode of a file that already exists.
60
+ await chmod(path, 0o600);
61
+ }
62
+ export async function ensureDir(dir) {
63
+ await mkdir(dir, { recursive: true, mode: 0o700 });
64
+ await chmod(dir, 0o700);
65
+ }
66
+ /** What npm's lockfile in the directory says it installed for a package. */
67
+ export async function installedEntry(dir, name) {
68
+ let lock;
69
+ try {
70
+ lock = JSON.parse(await readFile(join(dir, "package-lock.json"), "utf8"));
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ const entry = lock.packages?.[`node_modules/${name}`];
76
+ if (typeof entry !== "object" || entry === null)
77
+ return undefined;
78
+ const { version, integrity } = entry;
79
+ return {
80
+ ...(typeof version === "string" ? { version } : {}),
81
+ ...(typeof integrity === "string" ? { integrity } : {}),
82
+ };
83
+ }
84
+ //# sourceMappingURL=plugin-store.js.map
@@ -0,0 +1,15 @@
1
+ import type { Output } from "./review/progress.js";
2
+ export declare const PLUGINS_USAGE = "Usage: ocra plugins allow <name>@<version>\n ocra plugins deny <name>\n ocra plugins list\n\nYour ocra Cloud settings may name plugins; a review loads one only if this\nmachine allows it. allow shows the package and who published it, installs that\nexact version with install scripts off into a directory of your own, and\nrecords its integrity; deny removes it.\n";
3
+ /** Runs npm with these arguments, never through a shell; resolves with its standard output. */
4
+ export type NpmRunner = (args: readonly string[]) => Promise<string>;
5
+ export interface PluginsDeps {
6
+ dir: string;
7
+ npm: NpmRunner;
8
+ }
9
+ export declare function pluginsCommand(argv: readonly string[], out: Output, deps: PluginsDeps): Promise<number>;
10
+ export declare function parseSpec(spec: string): {
11
+ name: string;
12
+ version: string;
13
+ };
14
+ export declare function defaultNpm(env?: NodeJS.ProcessEnv): NpmRunner;
15
+ //# sourceMappingURL=plugins-command.d.ts.map