@wirecat/cli-core 0.412.0 → 0.413.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.413.0 — 25.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`startCli`, one entry-point call for a CLI that builds its own program** (from `@wirecat/cli-core/cli`). It does what drive, zm and memo wire by hand in `src/bin/`: the crash catch, cancellation on SIGINT and SIGTERM, a quiet exit 0 on a closed pipe, the SQLite swap, the first-run questions (`entry.atStart`), the consent prompt (`entry.ask`) and the browser (`entry.openBrowser`). The program and its failure report stay the CLI's own. Nothing changes for a CLI that keeps its own wiring.
15
+ - **Sign-in module `@wirecat/cli-core/oauth`** (available from cli-core 0.413.0). A CLI declares its provider (URLs, client, loopback or relay redirect) and gets authorization code with PKCE S256 (state check, timeout, cancel, paste fallback), refresh and revoke over plain `fetch`, and a `tokenKeeper` whose refresh takes a lock per keyring entry shared by every process, so two processes refreshing at once send one request. `credentialStorage` sits on cli-base's `Credentials`; `standardCodec` writes new entries as `oauth:<provider>:<profile>`, and `driveSessionCodec` and `zoomTokenCodec` read and write drive's and zm's existing entries unchanged, so nobody signs in again.
16
+ - **`gatherProjectEvidence` caps each linked chat per invocation.** A linked chat returns at most `max(20, floor(maxItems / (linked chats read + 1)))` new items per run (50 for one chat at the default 100 items, 20 from four chats up); the rest stays in the checkpoint for the next `--cursor` run, which gives it a fresh share. Every linked chat still gets its first page before any chat gets a second. Each `linkedChats` entry now has `items` (new items it returned in this run) and `capped` (it hit the limit and has more to read). Before, one busy chat could fill the whole run. Checkpoints stay version 1, and old ones still resume.
17
+
18
+ ### Fixed
19
+
20
+ - **A file lock no longer fails with "no such file" when another writer releases at the same moment** (available from cli-core 0.413.0). The releaser removes the empty marker folder; an acquirer that had just created it now creates it again instead of throwing `ENOENT`. `acquireLock`, the archive writer lease and the sign-in module's refresh lock all use it.
21
+ - **The 0.412.0 notes for the provider registry, unknown providers and the `events` kind say "available from cli-core 0.413.0"; they shipped in 0.412.0.** The release folded them in from `main`'s head. Nothing else in those entries changes.
8
22
 
9
23
  ## 0.412.0 — 24.10.2026
10
24
 
@@ -0,0 +1,59 @@
1
+ import { type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ import type { ErrorReason } from "@wirecat/cli-base/standards";
3
+ import type { Command } from "commander";
4
+ import { type AppIdentity } from "../ports/app.js";
5
+ /** What `startCli` hands the CLI's own program: the process, wired once. */
6
+ export interface EntryContext {
7
+ /** `process.argv` without the runtime and the script. */
8
+ args: string[];
9
+ env: NodeJS.ProcessEnv;
10
+ /** Whether stdout is a terminal. */
11
+ tty: boolean;
12
+ /** Aborted on SIGINT, SIGTERM and a closed or failing stdout. */
13
+ signal: AbortSignal;
14
+ /** The first-run questions, before the command acts: call it from a `preAction` hook with the action command. */
15
+ atStart: (command: Command) => Promise<void>;
16
+ /** A yes/no question on stderr; false without asking when there is no terminal, under CI, `--json`, `--jsonl` or `--no-input`. */
17
+ ask: (question: string, command?: Command) => Promise<boolean>;
18
+ /** Opens the URL in the system browser, or in `browser` when given; never waits for the browser to close. */
19
+ openBrowser: (url: string, browser?: string) => Promise<void>;
20
+ /** True once a reader closed the output (`| head`): nothing more reaches the terminal. */
21
+ outputClosed: () => boolean;
22
+ }
23
+ /** The parts of the process `startCli` touches, replaced in tests. */
24
+ export interface EntryIo {
25
+ argv: readonly string[];
26
+ env: NodeJS.ProcessEnv;
27
+ platform: NodeJS.Platform;
28
+ stdin: NodeJS.ReadableStream & {
29
+ isTTY?: boolean;
30
+ };
31
+ stdout: NodeJS.WritableStream & {
32
+ isTTY?: boolean;
33
+ };
34
+ stderr: NodeJS.WritableStream & {
35
+ isTTY?: boolean;
36
+ };
37
+ signals: Pick<NodeJS.Process, "on" | "off">;
38
+ setExitCode: (code: number) => void;
39
+ spawn: (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
40
+ }
41
+ export interface CliEntry {
42
+ app: AppIdentity;
43
+ /** Builds and runs the CLI's program; a returned number is the exit code, nothing keeps the one already set. */
44
+ run: (entry: EntryContext) => Promise<number | undefined>;
45
+ /** Says a failure the program threw, once, and gives its exit code. */
46
+ fail: (error: unknown, entry: EntryContext) => number;
47
+ /** Whether a crash may print the human form; stdout being a terminal when left out. */
48
+ interactive?: (args: readonly string[], tty: boolean) => boolean;
49
+ reasons?: readonly ErrorReason[];
50
+ /** Swap a system SQLite that cannot hold the store before anything runs (`ensureSqlite`). */
51
+ sqlite?: boolean;
52
+ io?: Partial<EntryIo>;
53
+ }
54
+ /**
55
+ * The entry point of a CLI that builds its own commander program: crash catch, cancellation, a quiet end on a closed
56
+ * pipe, the first-run questions, the consent prompt and the browser. It sets `process.exitCode` and never exits, so
57
+ * output still flushes.
58
+ */
59
+ export declare const startCli: (entry: CliEntry) => Promise<void>;
@@ -0,0 +1,136 @@
1
+ import { spawn } from "node:child_process";
2
+ import { CliError, catchCrashes } from "@wirecat/cli-base";
3
+ import { readOnlyLockOf } from "../ports/app.js";
4
+ import { ensureSqlite } from "../sqlite-runtime.js";
5
+ import { readSecret } from "../terminal/prompt.js";
6
+ import { commandPathOf } from "./command-contract.js";
7
+ import { setupAtStart } from "./first-run.js";
8
+ const processIo = () => ({
9
+ argv: process.argv,
10
+ env: process.env,
11
+ platform: process.platform,
12
+ stdin: process.stdin,
13
+ stdout: process.stdout,
14
+ stderr: process.stderr,
15
+ signals: process,
16
+ setExitCode: (code) => {
17
+ process.exitCode = code;
18
+ },
19
+ spawn,
20
+ });
21
+ /**
22
+ * The entry point of a CLI that builds its own commander program: crash catch, cancellation, a quiet end on a closed
23
+ * pipe, the first-run questions, the consent prompt and the browser. It sets `process.exitCode` and never exits, so
24
+ * output still flushes.
25
+ */
26
+ export const startCli = async (entry) => {
27
+ const io = { ...processIo(), ...entry.io };
28
+ const args = io.argv.slice(2);
29
+ const tty = io.stdout.isTTY === true;
30
+ const release = catchCrashes({
31
+ cli: entry.app.command,
32
+ interactive: entry.interactive?.(args, tty) ?? tty,
33
+ ...(entry.reasons ? { reasons: entry.reasons } : {}),
34
+ target: io.signals,
35
+ });
36
+ const controller = new AbortController();
37
+ let closed = false;
38
+ let unwritable = false;
39
+ const interrupted = (signal) => () => controller.abort(new CliError("cancelled", `command interrupted by ${signal}`, { reason: signal, retryable: false }));
40
+ const sigint = interrupted("SIGINT");
41
+ const sigterm = interrupted("SIGTERM");
42
+ const outputFailed = (error) => {
43
+ if (error.code === "EPIPE") {
44
+ closed = true;
45
+ controller.abort(new CliError("cancelled", "output pipe closed", { retryable: false }));
46
+ }
47
+ else {
48
+ unwritable = true;
49
+ controller.abort(new CliError("configuration_error", "could not write command output"));
50
+ }
51
+ };
52
+ const headless = (command) => {
53
+ const flags = command?.optsWithGlobals() ?? {};
54
+ return Boolean(flags.json || flags.jsonl || flags.input === false || io.env.CI) || !tty;
55
+ };
56
+ const context = {
57
+ args,
58
+ env: io.env,
59
+ tty,
60
+ signal: controller.signal,
61
+ atStart: (command) => setupAtStart({
62
+ command: entry.app.command,
63
+ env: io.env,
64
+ path: commandPathOf(command),
65
+ headless: headless(command),
66
+ readOnlyLock: readOnlyLockOf(entry.app, io.env),
67
+ input: io.stdin,
68
+ }),
69
+ ask: async (question, command) => {
70
+ if (headless(command) || io.stdin.isTTY !== true)
71
+ return false;
72
+ const answer = await readSecret(`${question} [y/N] `, {
73
+ input: io.stdin,
74
+ output: io.stderr,
75
+ echo: true,
76
+ signal: controller.signal,
77
+ });
78
+ return /^(y|yes)$/i.test(answer.trim());
79
+ },
80
+ openBrowser: (url, browser) => openBrowser(io, url, browser),
81
+ outputClosed: () => closed,
82
+ };
83
+ let code;
84
+ try {
85
+ if (entry.sqlite)
86
+ await ensureSqlite();
87
+ io.signals.on("SIGINT", sigint).on("SIGTERM", sigterm);
88
+ io.stdout.on("error", outputFailed);
89
+ io.stderr.on("error", outputFailed);
90
+ const returned = await entry.run(context);
91
+ if (typeof returned === "number")
92
+ code = returned;
93
+ }
94
+ catch (error) {
95
+ code = commanderExit(error) ?? entry.fail(error, context);
96
+ }
97
+ finally {
98
+ io.signals.off("SIGINT", sigint).off("SIGTERM", sigterm);
99
+ release();
100
+ }
101
+ // The listeners stay: output still flushing after the command can meet the closed pipe too.
102
+ if (closed)
103
+ io.setExitCode(0);
104
+ else if (unwritable)
105
+ io.setExitCode(3);
106
+ else if (code !== undefined)
107
+ io.setExitCode(code);
108
+ };
109
+ /** Help and the version were printed already; help for a missing command is a usage mistake, as in `run()`. */
110
+ const commanderExit = (error) => {
111
+ if (!(error instanceof Error))
112
+ return undefined;
113
+ const { code, exitCode } = error;
114
+ if (typeof code !== "string" || !code.startsWith("commander.") || typeof exitCode !== "number")
115
+ return undefined;
116
+ if (exitCode === 0)
117
+ return 0;
118
+ return code === "commander.help" ? 2 : undefined;
119
+ };
120
+ const openBrowser = (io, url, browser) => {
121
+ const [command, prefix] = browser !== undefined
122
+ ? [browser, []]
123
+ : io.platform === "darwin"
124
+ ? ["open", []]
125
+ : io.platform === "win32"
126
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler"]]
127
+ : ["xdg-open", []];
128
+ return new Promise((resolve, reject) => {
129
+ const child = io.spawn(command, [...prefix, url], { detached: true, stdio: "ignore", windowsHide: true });
130
+ child.once("error", () => reject(new CliError("configuration_error", `could not start ${command} — open the printed URL in a browser`)));
131
+ child.once("spawn", () => {
132
+ child.unref();
133
+ resolve();
134
+ });
135
+ });
136
+ };
@@ -53,7 +53,7 @@ export declare const askSetupQuestions: (run: FirstRun) => Promise<Question[]>;
53
53
  /**
54
54
  * The first-run step a tool runs before each command: asks when `asksAtStart` says so, and lets the command run
55
55
  * whatever happens to the questions. `setup` asks all three again, since a refusal sends the owner there.
56
- * A tool that builds its own program (zm, memo, drive) calls this with its command path.
56
+ * A tool that builds its own program gets it from `startCli` as `entry.atStart`.
57
57
  */
58
58
  export declare const setupAtStart: (start: {
59
59
  command: string;
@@ -203,7 +203,7 @@ export const askSetupQuestions = async (run) => {
203
203
  /**
204
204
  * The first-run step a tool runs before each command: asks when `asksAtStart` says so, and lets the command run
205
205
  * whatever happens to the questions. `setup` asks all three again, since a refusal sends the owner there.
206
- * A tool that builds its own program (zm, memo, drive) calls this with its command path.
206
+ * A tool that builds its own program gets it from `startCli` as `entry.atStart`.
207
207
  */
208
208
  export const setupAtStart = async (start) => {
209
209
  const input = start.input ?? process.stdin;
@@ -59,6 +59,7 @@ export { type Closeable, withDeadline } from "./deadline.js";
59
59
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
60
60
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
61
61
  export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, } from "./effects.js";
62
+ export { type CliEntry, type EntryContext, type EntryIo, startCli } from "./entry.js";
62
63
  export { type AnswerOutput, answerSchema, type Body, type ItemBody, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, type ListBody, type ListInput, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
63
64
  export { withAbort } from "./execution.js";
64
65
  export { askSetupQuestions, asksAtStart, type FirstRun, type FirstRunIo, LANGUAGES, setupAtStart, systemLanguage, } from "./first-run.js";
package/dist/cli/index.js CHANGED
@@ -52,6 +52,7 @@ export { withDeadline } from "./deadline.js";
52
52
  export { DEFAULT_CONFIG, ensureDefaultConfig } from "./default-config.js";
53
53
  export { DISCOVERY_DESCRIPTIONS } from "./discovery-descriptions.js";
54
54
  export { changesAuthority, changesAuthorityAt, changesAuthorityNow, destroysNothing, FETCH, grantsAuthority, isLogin, isOtherServiceCall, isStoreChange, judgedEffects, judgedTargets, LOCAL_READ, LOCAL_WRITE, logsInOrOut, NO_EFFECTS, ownerOnlyOption, REMOTE_READ, REMOTE_WRITE, reachesOtherService, refuseUnderReadOnly, } from "./effects.js";
55
+ export { startCli } from "./entry.js";
55
56
  export { answerSchema, isBody, itemAnswer, itemOf, jsonlRecords, judgedAnswer, listOf, metaNow, printItem, renderAnswer, sealed, snakeKeys, snakeKeysKeeping, } from "./envelope.js";
56
57
  export { withAbort } from "./execution.js";
57
58
  export { askSetupQuestions, asksAtStart, LANGUAGES, setupAtStart, systemLanguage, } from "./first-run.js";
@@ -15,6 +15,21 @@ const QUERY = [
15
15
  ["query.invalid-topic", "a topic in the query is not valid"],
16
16
  ["query.topic-scope", "a topic needs its chat in the query"],
17
17
  ].map(([reason, message]) => validation(reason, message));
18
+ const SIGN_IN = [
19
+ ["oauth.declined", "authentication_error", "the provider's consent was declined"],
20
+ ["oauth.timeout", "timeout", "the sign-in was not finished in time"],
21
+ ["oauth.mismatch", "authentication_error", "the pasted sign-in result belongs to another sign-in"],
22
+ ["oauth.missing", "authentication_error", "this profile is not signed in"],
23
+ ["oauth.locked", "provider_unavailable", "another process holds this sign-in's lock"],
24
+ ["oauth.no-response", "outcome_unknown", "the token request did not finish"],
25
+ ["oauth.unavailable", "provider_unavailable", "the provider's token endpoint is unavailable"],
26
+ ["oauth.expired", "authentication_error", "the provider refused the saved sign-in; sign in again"],
27
+ ["oauth.invalid-response", "invalid_response", "the provider's token response could not be used"],
28
+ ["oauth.failed", "authentication_error", "the provider returned no offline refresh token"],
29
+ ["oauth.revoke-failed", "outcome_unknown", "the provider did not confirm the revoke"],
30
+ ["oauth.invalid", "authentication_error", "the saved sign-in cannot be read"],
31
+ ["oauth.not-saved", "configuration_error", "the sign-in cannot be saved or read where it is kept"],
32
+ ];
18
33
  const OTHERS = [
19
34
  ["model.offline", "permission_error", "downloading a model needs the network; --offline was requested"],
20
35
  [
@@ -105,7 +120,7 @@ export const INPUT_FIELD_REASONS = defineReasons([
105
120
  export const REASONS = defineReasons([
106
121
  ...QUERY_REASONS,
107
122
  ...INPUT_FIELD_REASONS,
108
- ...[...QUERY, ...OTHERS].map(([reason, code, message]) => ({
123
+ ...[...QUERY, ...SIGN_IN, ...OTHERS].map(([reason, code, message]) => ({
109
124
  reason,
110
125
  code,
111
126
  message,
@@ -22,15 +22,26 @@ export const nativeWriterLeaseStore = (layout) => {
22
22
  const root = await realpath(destination);
23
23
  signal?.throwIfAborted();
24
24
  const folder = join(root, markerDirectory);
25
- await mkdir(folder, { recursive: true });
26
- if ((await lstat(folder)).isSymbolicLink())
27
- throw new CliError("validation_error", "archive lock directory must not be a symlink");
28
- await inspection.recover(root, { apply: true, signal });
29
- signal?.throwIfAborted();
30
25
  const owner = { version: 1, pid: process.pid, host: hostname(), nonce: randomUUID() };
31
26
  const name = `${owner.pid}-${owner.nonce}.json`;
32
27
  const path = join(folder, name);
33
- const file = await open(path, "wx", 0o600);
28
+ // Another writer removes the folder once it is empty, so it can vanish before our marker is in it.
29
+ const marker = async (attempt = 1) => {
30
+ await mkdir(folder, { recursive: true });
31
+ try {
32
+ if ((await lstat(folder)).isSymbolicLink())
33
+ throw new CliError("validation_error", "archive lock directory must not be a symlink");
34
+ await inspection.recover(root, { apply: true, signal });
35
+ signal?.throwIfAborted();
36
+ return await open(path, "wx", 0o600);
37
+ }
38
+ catch (error) {
39
+ if (attempt < 5 && missingLeaseFile(error))
40
+ return marker(attempt + 1);
41
+ throw error;
42
+ }
43
+ };
44
+ const file = await marker();
34
45
  let retained = false;
35
46
  let identity;
36
47
  try {
@@ -0,0 +1,21 @@
1
+ import type { OAuthProvider } from "./provider.js";
2
+ export interface AuthorizeOptions {
3
+ provider: OAuthProvider;
4
+ scopes: readonly string[];
5
+ openBrowser: (url: string) => Promise<void>;
6
+ showUrl?: (url: string) => void;
7
+ /** The redirected URL, or its query, pasted when the browser cannot reach this machine's loopback. */
8
+ readPaste?: (signal: AbortSignal) => Promise<string>;
9
+ timeoutMs?: number;
10
+ signal?: AbortSignal;
11
+ }
12
+ export interface Authorization {
13
+ code: string;
14
+ redirectUri: string;
15
+ codeVerifier: string;
16
+ }
17
+ /**
18
+ * Authorization code with PKCE S256 for an installed app: a loopback listener on 127.0.0.1, a `state` check, a
19
+ * timeout, cancel and a paste fallback. Answers the code without exchanging it.
20
+ */
21
+ export declare const authorize: (options: AuthorizeOptions) => Promise<Authorization>;
@@ -0,0 +1,130 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { CliError, isCliError } from "@wirecat/cli-base";
4
+ const DEFAULT_TIMEOUT_MS = 300_000;
5
+ const pkce = () => {
6
+ const codeVerifier = randomBytes(48).toString("base64url");
7
+ return { codeVerifier, codeChallenge: createHash("sha256").update(codeVerifier).digest("base64url") };
8
+ };
9
+ const queryOf = (text) => {
10
+ const trimmed = text.trim();
11
+ const start = trimmed.indexOf("?");
12
+ return new URLSearchParams(start >= 0 ? trimmed.slice(start + 1).split("#")[0] : trimmed);
13
+ };
14
+ /**
15
+ * Authorization code with PKCE S256 for an installed app: a loopback listener on 127.0.0.1, a `state` check, a
16
+ * timeout, cancel and a paste fallback. Answers the code without exchanging it.
17
+ */
18
+ export const authorize = async (options) => {
19
+ const { provider } = options;
20
+ const cancelled = () => isCliError(options.signal?.reason)
21
+ ? options.signal.reason
22
+ : new CliError("cancelled", `${provider.name} sign-in was cancelled`);
23
+ if (options.signal?.aborted)
24
+ throw cancelled();
25
+ const { codeVerifier, codeChallenge } = pkce();
26
+ const paste = new AbortController();
27
+ let state = "";
28
+ let settled = false;
29
+ let accept = () => { };
30
+ let fail = () => { };
31
+ const code = new Promise((resolve, reject) => {
32
+ accept = (value) => {
33
+ settled = true;
34
+ resolve(value);
35
+ };
36
+ fail = (error) => {
37
+ settled = true;
38
+ reject(error);
39
+ };
40
+ });
41
+ void code.catch(() => { });
42
+ const settle = (query) => {
43
+ const value = query.get("code");
44
+ if (value)
45
+ accept(value);
46
+ else
47
+ fail(new CliError("authentication_error", `${provider.name} sign-in was declined`, { reason: "oauth.declined" }));
48
+ };
49
+ const server = createServer((request, response) => {
50
+ let url;
51
+ try {
52
+ url = new URL(request.url ?? "/", "http://127.0.0.1");
53
+ }
54
+ catch {
55
+ response.writeHead(400).end();
56
+ return;
57
+ }
58
+ if (request.method !== "GET" || url.pathname !== "/callback") {
59
+ response.writeHead(404).end();
60
+ return;
61
+ }
62
+ if (settled || !state || url.searchParams.get("state") !== state) {
63
+ response.writeHead(400).end("This sign-in link does not belong to the waiting sign-in");
64
+ return;
65
+ }
66
+ if (!url.searchParams.get("code") && !url.searchParams.get("error")) {
67
+ response.writeHead(400).end("Missing sign-in result");
68
+ return;
69
+ }
70
+ response.end("Sign-in received; you can close this tab");
71
+ settle(url.searchParams);
72
+ });
73
+ const onAbort = () => fail(cancelled());
74
+ options.signal?.addEventListener("abort", onAbort, { once: true });
75
+ const timer = setTimeout(() => fail(new CliError("timeout", `${provider.name} sign-in timed out`, { reason: "oauth.timeout" })), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
76
+ try {
77
+ await new Promise((resolve, reject) => {
78
+ server.once("error", reject);
79
+ server.listen(0, "127.0.0.1", resolve);
80
+ });
81
+ const address = server.address();
82
+ if (!address || typeof address === "string")
83
+ throw new CliError("configuration_error", "could not start the local sign-in listener");
84
+ const random = randomBytes(32).toString("base64url");
85
+ const relay = provider.redirect.kind === "relay";
86
+ state = relay ? `${address.port}.${random}` : random;
87
+ const redirectUri = provider.redirect.kind === "relay" ? provider.redirect.url : `http://127.0.0.1:${address.port}/callback`;
88
+ const url = new URL(provider.authorizeUrl);
89
+ for (const [key, value] of Object.entries({
90
+ ...provider.authorizeParams,
91
+ response_type: "code",
92
+ client_id: provider.client.clientId,
93
+ redirect_uri: redirectUri,
94
+ ...(options.scopes.length ? { scope: options.scopes.join(" ") } : {}),
95
+ state,
96
+ code_challenge: codeChallenge,
97
+ code_challenge_method: "S256",
98
+ }))
99
+ url.searchParams.set(key, value);
100
+ options.showUrl?.(url.toString());
101
+ options
102
+ .readPaste?.(paste.signal)
103
+ .then((text) => {
104
+ if (settled)
105
+ return;
106
+ const query = queryOf(text);
107
+ if (query.get("state") !== state || (!query.get("code") && !query.get("error")))
108
+ fail(new CliError("authentication_error", "the pasted sign-in result does not match this sign-in", {
109
+ reason: "oauth.mismatch",
110
+ }));
111
+ else
112
+ settle(query);
113
+ })
114
+ .catch(() => { });
115
+ void Promise.resolve()
116
+ .then(() => options.openBrowser(url.toString()))
117
+ .catch(() => {
118
+ if (!options.readPaste && !options.showUrl)
119
+ fail(new CliError("configuration_error", "could not open the browser, and no sign-in link can be shown"));
120
+ });
121
+ return { code: await code, redirectUri, codeVerifier };
122
+ }
123
+ finally {
124
+ clearTimeout(timer);
125
+ options.signal?.removeEventListener("abort", onAbort);
126
+ paste.abort();
127
+ server.closeAllConnections();
128
+ await new Promise((resolve) => server.close(() => resolve()));
129
+ }
130
+ };
@@ -0,0 +1,5 @@
1
+ export { type Authorization, type AuthorizeOptions, authorize } from "./authorize.js";
2
+ export { type SignInOptions, type TokenKeeper, type TokenKeeperOptions, tokenKeeper } from "./keeper.js";
3
+ export type { ExchangeContext, OAuthClient, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
4
+ export { requestTokens, revokeToken, TOKEN_LOG_ALLOW, TOKEN_LOG_EVENT } from "./requests.js";
5
+ export { type CredentialStorageOptions, credentialStorage, driveSessionCodec, type EncodeInput, oauthAccount, type StoredTokens, standardCodec, type TokenCodec, type TokenStorage, zoomTokenCodec, } from "./storage.js";
@@ -0,0 +1,4 @@
1
+ export { authorize } from "./authorize.js";
2
+ export { tokenKeeper } from "./keeper.js";
3
+ export { requestTokens, revokeToken, TOKEN_LOG_ALLOW, TOKEN_LOG_EVENT } from "./requests.js";
4
+ export { credentialStorage, driveSessionCodec, oauthAccount, standardCodec, zoomTokenCodec, } from "./storage.js";
@@ -0,0 +1,41 @@
1
+ import { type CredentialSource, type Logger } from "@wirecat/cli-base";
2
+ import { type AuthorizeOptions } from "./authorize.js";
3
+ import type { OAuthProvider, TokenSet } from "./provider.js";
4
+ import type { TokenStorage } from "./storage.js";
5
+ export interface TokenKeeperOptions {
6
+ provider: OAuthProvider;
7
+ storage: TokenStorage;
8
+ /** The CLI's config directory: the keyring entry varies with it, so the lock must too. */
9
+ lockDir: string;
10
+ fetch: typeof fetch;
11
+ /** `drive personal session start`: named in every failure that needs a new sign-in. */
12
+ signInCommand: string;
13
+ now?: () => number;
14
+ signal?: AbortSignal;
15
+ log?: Logger;
16
+ /** Longer than one token request's 30 s limit, so a live refresher finishes first. Real time, not `now`. */
17
+ lockWaitMs?: number;
18
+ lockRetryMs?: number;
19
+ /** An access token expiring later than this from now is used as is. */
20
+ freshForMs?: number;
21
+ }
22
+ export type SignInOptions = Omit<AuthorizeOptions, "provider" | "signal"> & {
23
+ /** Runs before anything is saved; it may refuse the account or add fields to the entry (drive's `principal`). */
24
+ accept?: (tokens: TokenSet) => Promise<Record<string, unknown> | undefined> | Record<string, unknown> | undefined;
25
+ };
26
+ export interface TokenKeeper {
27
+ accessToken(): Promise<string>;
28
+ tokens(): Promise<TokenSet>;
29
+ signIn(options: SignInOptions): Promise<{
30
+ scopes: string[];
31
+ }>;
32
+ /** Saves tokens a CLI got by a flow of its own, under the same lock. */
33
+ save(tokens: TokenSet, extra?: Record<string, unknown>): Promise<void>;
34
+ signOut(options?: {
35
+ revoke?: boolean;
36
+ }): Promise<{
37
+ revoked: boolean;
38
+ removed: CredentialSource[];
39
+ }>;
40
+ }
41
+ export declare const tokenKeeper: (options: TokenKeeperOptions) => TokenKeeper;
@@ -0,0 +1,136 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { CliError, isCliError } from "@wirecat/cli-base";
5
+ import { acquireLock } from "../files/index.js";
6
+ import { authorize } from "./authorize.js";
7
+ import { requestTokens, revokeToken, TOKEN_LOG_EVENT } from "./requests.js";
8
+ const inFlight = new Map();
9
+ export const tokenKeeper = (options) => {
10
+ const { provider, storage, signal, log } = options;
11
+ const now = options.now ?? Date.now;
12
+ const fresh = (tokens) => Boolean(tokens.accessToken) &&
13
+ tokens.expiresAt !== undefined &&
14
+ tokens.expiresAt > now() + (options.freshForMs ?? 60_000);
15
+ const lockPath = join(options.lockDir, `oauth-${createHash("sha256").update(storage.key).digest("hex").slice(0, 16)}.lock`);
16
+ const clientOf = (stored) => {
17
+ const saved = stored?.client;
18
+ if (!saved || saved.clientSecret || saved.clientId !== provider.client.clientId)
19
+ return saved ?? provider.client;
20
+ return provider.client;
21
+ };
22
+ const context = (client, previous) => ({
23
+ fetch: options.fetch,
24
+ client,
25
+ now,
26
+ signInCommand: options.signInCommand,
27
+ ...(signal ? { signal } : {}),
28
+ ...(log ? { log } : {}),
29
+ ...(previous ? { previous } : {}),
30
+ });
31
+ const exchange = (params, ctx) => provider.exchange ? provider.exchange(params, ctx) : requestTokens(provider, params, ctx);
32
+ const notSignedIn = () => new CliError("authentication_error", `not signed in to ${provider.name}; run ${options.signInCommand}`, {
33
+ reason: "oauth.missing",
34
+ });
35
+ const busy = (error) => isCliError(error) && error.details.reason === "file.locked";
36
+ const acquire = async () => {
37
+ const started = performance.now();
38
+ let waiting = false;
39
+ for (;;) {
40
+ try {
41
+ return await acquireLock(lockPath, signal ? { signal } : {});
42
+ }
43
+ catch (error) {
44
+ if (!busy(error))
45
+ throw error;
46
+ if (!waiting) {
47
+ waiting = true;
48
+ log?.info({
49
+ event: TOKEN_LOG_EVENT,
50
+ provider: provider.id,
51
+ operation: "lock",
52
+ outcome: "waiting",
53
+ pid: process.pid,
54
+ });
55
+ }
56
+ if (performance.now() - started > (options.lockWaitMs ?? 45_000))
57
+ throw new CliError("provider_unavailable", `another process holds this ${provider.name} sign-in; retry`, {
58
+ reason: "oauth.locked",
59
+ retryable: true,
60
+ });
61
+ await delay(options.lockRetryMs ?? 50, undefined, signal ? { signal } : {}).catch(() => {
62
+ throw new CliError("cancelled", `${provider.name} sign-in was cancelled`);
63
+ });
64
+ }
65
+ }
66
+ };
67
+ const locked = async (work) => {
68
+ const lock = await acquire();
69
+ try {
70
+ return await work();
71
+ }
72
+ finally {
73
+ await lock.release().catch(() => { });
74
+ }
75
+ };
76
+ const shared = (work) => {
77
+ const running = inFlight.get(lockPath);
78
+ if (running)
79
+ return running;
80
+ const started = locked(work).finally(() => inFlight.delete(lockPath));
81
+ inFlight.set(lockPath, started);
82
+ return started;
83
+ };
84
+ const tokens = async () => {
85
+ const seen = storage.read();
86
+ if (!seen)
87
+ throw notSignedIn();
88
+ if (fresh(seen.tokens))
89
+ return seen.tokens;
90
+ return shared(async () => {
91
+ const current = storage.read();
92
+ if (!current)
93
+ throw notSignedIn();
94
+ // Another process refreshed while this one waited for the lock: its result is the one to use.
95
+ if (fresh(current.tokens) || current.tokens.refreshToken !== seen.tokens.refreshToken)
96
+ return current.tokens;
97
+ const client = clientOf(current);
98
+ const refreshed = await exchange({ grant_type: "refresh_token", refresh_token: current.tokens.refreshToken }, context(client, current.tokens));
99
+ storage.write({ tokens: refreshed, client });
100
+ return refreshed;
101
+ });
102
+ };
103
+ return {
104
+ tokens,
105
+ accessToken: async () => {
106
+ const current = await tokens();
107
+ if (!current.accessToken)
108
+ throw notSignedIn();
109
+ return current.accessToken;
110
+ },
111
+ signIn: async ({ accept, ...authorizeOptions }) => {
112
+ storage.probe();
113
+ const authorization = await authorize({ ...authorizeOptions, provider, ...(signal ? { signal } : {}) });
114
+ const client = provider.client;
115
+ const received = await exchange({
116
+ grant_type: "authorization_code",
117
+ code: authorization.code,
118
+ redirect_uri: authorization.redirectUri,
119
+ code_verifier: authorization.codeVerifier,
120
+ }, context(client));
121
+ const extra = (await accept?.(received)) ?? undefined;
122
+ await locked(async () => storage.write({ tokens: received, client, ...(extra ? { extra } : {}) }));
123
+ return { scopes: received.scopes };
124
+ },
125
+ save: (saved, extra) => locked(async () => storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) })),
126
+ signOut: ({ revoke = false } = {}) => locked(async () => {
127
+ const current = storage.read();
128
+ let revoked = false;
129
+ if (revoke && current) {
130
+ await revokeToken(provider, current.tokens, context(clientOf(current)));
131
+ revoked = true;
132
+ }
133
+ return { revoked, removed: storage.remove() };
134
+ }),
135
+ };
136
+ };
@@ -0,0 +1,54 @@
1
+ import type { Logger } from "@wirecat/cli-base";
2
+ /** The app identity a provider issued; an installed app's secret is public, not account access. */
3
+ export interface OAuthClient {
4
+ clientId: string;
5
+ clientSecret?: string;
6
+ }
7
+ export interface TokenSet {
8
+ accessToken?: string;
9
+ refreshToken: string;
10
+ /** UTC milliseconds; unknown means "refresh before use". */
11
+ expiresAt?: number;
12
+ scopes: string[];
13
+ tokenType?: string;
14
+ }
15
+ export interface RequestContext {
16
+ fetch: typeof fetch;
17
+ client: OAuthClient;
18
+ now: () => number;
19
+ signal?: AbortSignal;
20
+ log?: Logger;
21
+ /** The command that signs in again, named in every failure that needs it. */
22
+ signInCommand: string;
23
+ }
24
+ export interface ExchangeContext extends RequestContext {
25
+ /** The tokens being refreshed; absent for the first exchange. */
26
+ previous?: TokenSet;
27
+ }
28
+ /**
29
+ * A provider as a CLI declares it. cli-core holds no provider's URLs or ids; every step may be replaced, and the
30
+ * keeper still holds the lock and the storage around it.
31
+ */
32
+ export interface OAuthProvider {
33
+ /** A short word, part of new keyring account names: `google`, `zoom`. */
34
+ id: string;
35
+ /** The name people know, used in messages: `Google`. */
36
+ name: string;
37
+ authorizeUrl: string;
38
+ tokenUrl: string;
39
+ revokeUrl?: string;
40
+ client: OAuthClient;
41
+ /**
42
+ * `loopback` sends the browser to `http://127.0.0.1:<port>/callback`. `relay` sends it to a fixed page that
43
+ * forwards to the loopback port, which it reads from `state` (`<port>.<random>`).
44
+ */
45
+ redirect: {
46
+ kind: "loopback";
47
+ } | {
48
+ kind: "relay";
49
+ url: string;
50
+ };
51
+ authorizeParams?: Record<string, string>;
52
+ exchange?: (params: Record<string, string>, context: ExchangeContext) => Promise<TokenSet>;
53
+ revoke?: (tokens: TokenSet, context: RequestContext) => Promise<void>;
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { ExchangeContext, OAuthProvider, RequestContext, TokenSet } from "./provider.js";
2
+ export declare const TOKEN_LOG_EVENT = "oauth.token";
3
+ /** The keys the module's log records carry, for a run log's `allow`; names only, never a token. */
4
+ export declare const TOKEN_LOG_ALLOW: readonly ["provider", "operation", "outcome", "status", "rotated", "pid"];
5
+ /**
6
+ * The token endpoint, form-encoded, for both grants. Nothing from the request or the response reaches a message or
7
+ * a log record: both carry codes and tokens.
8
+ */
9
+ export declare const requestTokens: (provider: OAuthProvider, params: Record<string, string>, context: ExchangeContext) => Promise<TokenSet>;
10
+ /** Revokes the refresh token at `revokeUrl`. A provider without one, or with another method, declares `revoke`. */
11
+ export declare const revokeToken: (provider: OAuthProvider, tokens: TokenSet, context: RequestContext) => Promise<void>;
@@ -0,0 +1,129 @@
1
+ import { CliError, isCliError } from "@wirecat/cli-base";
2
+ const REQUEST_LIMIT_MS = 30_000;
3
+ export const TOKEN_LOG_EVENT = "oauth.token";
4
+ /** The keys the module's log records carry, for a run log's `allow`; names only, never a token. */
5
+ export const TOKEN_LOG_ALLOW = ["provider", "operation", "outcome", "status", "rotated", "pid"];
6
+ const object = (value) => {
7
+ if (typeof value !== "object" || value === null || Array.isArray(value))
8
+ throw new Error("not an object");
9
+ return value;
10
+ };
11
+ const requestSignal = (signal) => signal ? AbortSignal.any([signal, AbortSignal.timeout(REQUEST_LIMIT_MS)]) : AbortSignal.timeout(REQUEST_LIMIT_MS);
12
+ const cancelledBy = (signal) => {
13
+ if (!signal?.aborted)
14
+ return;
15
+ throw isCliError(signal.reason) ? signal.reason : new CliError("cancelled", "sign-in was cancelled");
16
+ };
17
+ const post = async (url, body, context) => {
18
+ cancelledBy(context.signal);
19
+ try {
20
+ return await context.fetch(url, {
21
+ method: "POST",
22
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
23
+ body,
24
+ signal: requestSignal(context.signal),
25
+ redirect: "error",
26
+ });
27
+ }
28
+ catch {
29
+ cancelledBy(context.signal);
30
+ return undefined;
31
+ }
32
+ };
33
+ const clientFields = ({ client }) => ({
34
+ client_id: client.clientId,
35
+ ...(client.clientSecret ? { client_secret: client.clientSecret } : {}),
36
+ });
37
+ /**
38
+ * The token endpoint, form-encoded, for both grants. Nothing from the request or the response reaches a message or
39
+ * a log record: both carry codes and tokens.
40
+ */
41
+ export const requestTokens = async (provider, params, context) => {
42
+ const operation = params.grant_type ?? "unknown";
43
+ const record = (level, fields) => context.log?.[level]({ event: TOKEN_LOG_EVENT, provider: provider.id, operation, pid: process.pid, ...fields });
44
+ const again = `run ${context.signInCommand} again`;
45
+ const response = await post(provider.tokenUrl, new URLSearchParams({ ...params, ...clientFields(context) }), context);
46
+ if (!response) {
47
+ record("warn", { outcome: "no_response" });
48
+ throw new CliError("outcome_unknown", `${provider.name} token request did not finish; ${again} if it keeps failing`, {
49
+ reason: "oauth.no-response",
50
+ });
51
+ }
52
+ if (!response.ok) {
53
+ record("warn", { outcome: "rejected", status: response.status });
54
+ if (response.status === 429 || response.status >= 500)
55
+ throw new CliError("provider_unavailable", `${provider.name} token request failed (HTTP ${response.status})`, {
56
+ status: response.status,
57
+ retryable: true,
58
+ reason: "oauth.unavailable",
59
+ });
60
+ throw new CliError("authentication_error", `${provider.name} sign-in was refused (HTTP ${response.status}); ${again}`, {
61
+ status: response.status,
62
+ reason: "oauth.expired",
63
+ });
64
+ }
65
+ let body;
66
+ try {
67
+ body = object(await response.json());
68
+ }
69
+ catch {
70
+ record("error", { outcome: "unreadable_response", status: response.status });
71
+ throw new CliError("invalid_response", `${provider.name} returned an unreadable token response; ${again}`, {
72
+ reason: "oauth.invalid-response",
73
+ });
74
+ }
75
+ const previous = context.previous;
76
+ const refreshToken = typeof body.refresh_token === "string" && body.refresh_token ? body.refresh_token : previous?.refreshToken;
77
+ const expiresIn = body.expires_in;
78
+ if (typeof body.access_token !== "string" ||
79
+ !body.access_token ||
80
+ (expiresIn !== undefined && (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0)) ||
81
+ (body.scope !== undefined && typeof body.scope !== "string") ||
82
+ (body.token_type !== undefined && typeof body.token_type !== "string")) {
83
+ record("error", { outcome: "invalid_response", status: response.status });
84
+ throw new CliError("invalid_response", `${provider.name} returned an invalid token response; ${again}`, {
85
+ reason: "oauth.invalid-response",
86
+ });
87
+ }
88
+ if (!refreshToken) {
89
+ record("error", { outcome: "no_refresh_token", status: response.status });
90
+ throw new CliError("authentication_error", `${provider.name} returned no offline refresh token; ${again}`, {
91
+ reason: "oauth.failed",
92
+ });
93
+ }
94
+ const scopes = typeof body.scope === "string" ? body.scope.split(/\s+/).filter(Boolean) : (previous?.scopes ?? []);
95
+ const tokenType = typeof body.token_type === "string" ? body.token_type : previous?.tokenType;
96
+ record("info", {
97
+ outcome: "received",
98
+ status: response.status,
99
+ ...(previous ? { rotated: refreshToken !== previous.refreshToken } : {}),
100
+ });
101
+ return {
102
+ accessToken: body.access_token,
103
+ refreshToken,
104
+ ...(typeof expiresIn === "number" ? { expiresAt: context.now() + expiresIn * 1000 } : {}),
105
+ scopes,
106
+ ...(tokenType ? { tokenType } : {}),
107
+ };
108
+ };
109
+ /** Revokes the refresh token at `revokeUrl`. A provider without one, or with another method, declares `revoke`. */
110
+ export const revokeToken = async (provider, tokens, context) => {
111
+ if (provider.revoke)
112
+ return provider.revoke(tokens, context);
113
+ if (!provider.revokeUrl)
114
+ throw new CliError("validation_error", `${provider.name} declares no way to revoke a sign-in`);
115
+ const response = await post(provider.revokeUrl, new URLSearchParams({ token: tokens.refreshToken, ...clientFields(context) }), context);
116
+ context.log?.[response?.ok ? "info" : "warn"]({
117
+ event: TOKEN_LOG_EVENT,
118
+ provider: provider.id,
119
+ operation: "revoke",
120
+ pid: process.pid,
121
+ outcome: response?.ok ? "revoked" : response ? "rejected" : "no_response",
122
+ ...(response ? { status: response.status } : {}),
123
+ });
124
+ if (!response?.ok)
125
+ throw new CliError("outcome_unknown", `${provider.name} did not confirm the revoke`, {
126
+ reason: "oauth.revoke-failed",
127
+ ...(response ? { status: response.status } : {}),
128
+ });
129
+ };
@@ -0,0 +1,48 @@
1
+ import { type CredentialSource, type CredentialsOptions } from "@wirecat/cli-base";
2
+ import type { OAuthClient, TokenSet } from "./provider.js";
3
+ export interface StoredTokens {
4
+ tokens: TokenSet;
5
+ /** The app the entry was signed in with, when the entry records it; refresh must use that one. */
6
+ client?: OAuthClient;
7
+ }
8
+ export interface EncodeInput {
9
+ tokens: TokenSet;
10
+ client: OAuthClient;
11
+ /** The entry as stored before, so fields the codec does not own survive a write. */
12
+ previous?: string;
13
+ /** Fields a CLI adds at sign-in, such as drive's `principal`. */
14
+ extra?: Record<string, unknown>;
15
+ }
16
+ /** Every failure throws a fixed message: a parser's message would quote the secret. */
17
+ export interface TokenCodec {
18
+ decode(raw: string): StoredTokens;
19
+ encode(input: EncodeInput): string;
20
+ }
21
+ export interface TokenStorage {
22
+ /** Names the entry across processes; the lock is keyed by it. */
23
+ readonly key: string;
24
+ /** Throws when the entry cannot be saved, so a sign-in fails before the browser, not after consent. */
25
+ probe(): void;
26
+ read(): StoredTokens | undefined;
27
+ write(input: Omit<EncodeInput, "previous">): void;
28
+ remove(): CredentialSource[];
29
+ }
30
+ export declare const oauthAccount: (providerId: string, profile: string) => string;
31
+ /** The entry every new sign-in writes: account `oauth:<provider>:<profile>` under the CLI's own service. */
32
+ export declare const standardCodec: (providerId: string) => TokenCodec;
33
+ /**
34
+ * drive's entry as drive-cli writes it today: `{principal, clientId, clientSecret?, tokens: {access_token,
35
+ * refresh_token, expiry_date, scope, token_type, …}}`. A write keeps every field it does not own.
36
+ */
37
+ export declare const driveSessionCodec: TokenCodec;
38
+ /** zm's entry as zoom-cli writes it today: `{accessToken, refreshToken, expiresAt, scopes}`, all four required. */
39
+ export declare const zoomTokenCodec: TokenCodec;
40
+ export interface CredentialStorageOptions {
41
+ /** What the CLI passes `Credentials` today; the same options address the same entry. */
42
+ credentials: CredentialsOptions;
43
+ account: string;
44
+ /** Older accounts read when `account` is empty; a hit is copied to `account` and left in place. */
45
+ legacyAccounts?: readonly string[];
46
+ codec: TokenCodec;
47
+ }
48
+ export declare const credentialStorage: (options: CredentialStorageOptions) => TokenStorage;
@@ -0,0 +1,197 @@
1
+ import { CliError, Credentials, keyringService, } from "@wirecat/cli-base";
2
+ const invalid = () => new CliError("authentication_error", "the saved sign-in cannot be read; sign in again", { reason: "oauth.invalid" });
3
+ const parse = (raw) => {
4
+ let value;
5
+ try {
6
+ value = JSON.parse(raw);
7
+ }
8
+ catch {
9
+ throw invalid();
10
+ }
11
+ if (typeof value !== "object" || value === null || Array.isArray(value))
12
+ throw invalid();
13
+ return value;
14
+ };
15
+ const text = (value) => (typeof value === "string" && value ? value : undefined);
16
+ const finite = (value) => typeof value === "number" && Number.isFinite(value) ? value : undefined;
17
+ const previousOf = (raw) => {
18
+ if (raw === undefined)
19
+ return {};
20
+ try {
21
+ return parse(raw);
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ };
27
+ const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined));
28
+ export const oauthAccount = (providerId, profile) => {
29
+ if (!profile.trim())
30
+ throw new CliError("validation_error", "name a profile: a sign-in always belongs to one");
31
+ return `oauth:${providerId}:${profile}`;
32
+ };
33
+ /** The entry every new sign-in writes: account `oauth:<provider>:<profile>` under the CLI's own service. */
34
+ export const standardCodec = (providerId) => ({
35
+ decode: (raw) => {
36
+ const value = parse(raw);
37
+ const refreshToken = text(value.refreshToken);
38
+ const clientId = text(value.clientId);
39
+ if (value.version !== 1 || value.provider !== providerId || !refreshToken || !clientId)
40
+ throw invalid();
41
+ if (!Array.isArray(value.scopes) || !value.scopes.every((scope) => typeof scope === "string"))
42
+ throw invalid();
43
+ return {
44
+ client: { clientId },
45
+ tokens: defined({
46
+ accessToken: text(value.accessToken),
47
+ refreshToken,
48
+ expiresAt: finite(value.expiresAt),
49
+ scopes: value.scopes,
50
+ tokenType: text(value.tokenType),
51
+ }),
52
+ };
53
+ },
54
+ encode: ({ tokens, client, previous, extra }) => JSON.stringify(defined({
55
+ ...previousOf(previous),
56
+ ...extra,
57
+ version: 1,
58
+ provider: providerId,
59
+ clientId: client.clientId,
60
+ accessToken: tokens.accessToken,
61
+ refreshToken: tokens.refreshToken,
62
+ expiresAt: tokens.expiresAt,
63
+ scopes: tokens.scopes,
64
+ tokenType: tokens.tokenType,
65
+ })),
66
+ });
67
+ /**
68
+ * drive's entry as drive-cli writes it today: `{principal, clientId, clientSecret?, tokens: {access_token,
69
+ * refresh_token, expiry_date, scope, token_type, …}}`. A write keeps every field it does not own.
70
+ */
71
+ export const driveSessionCodec = {
72
+ decode: (raw) => {
73
+ const value = parse(raw);
74
+ const tokens = value.tokens;
75
+ if (typeof tokens !== "object" || tokens === null || Array.isArray(tokens))
76
+ throw invalid();
77
+ const fields = tokens;
78
+ const refreshToken = text(fields.refresh_token);
79
+ const clientId = text(value.clientId);
80
+ if (!refreshToken || !clientId || typeof value.principal !== "string")
81
+ throw invalid();
82
+ return {
83
+ client: defined({ clientId, clientSecret: text(value.clientSecret) }),
84
+ tokens: defined({
85
+ accessToken: text(fields.access_token),
86
+ refreshToken,
87
+ expiresAt: finite(fields.expiry_date),
88
+ scopes: typeof fields.scope === "string" ? fields.scope.split(/\s+/).filter(Boolean) : [],
89
+ tokenType: text(fields.token_type),
90
+ }),
91
+ };
92
+ },
93
+ encode: ({ tokens, client, previous, extra }) => {
94
+ const before = previousOf(previous);
95
+ const oldTokens = typeof before.tokens === "object" && before.tokens !== null ? before.tokens : {};
96
+ const session = {
97
+ ...before,
98
+ ...extra,
99
+ clientId: client.clientId,
100
+ ...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
101
+ tokens: defined({
102
+ ...oldTokens,
103
+ access_token: tokens.accessToken,
104
+ refresh_token: tokens.refreshToken,
105
+ expiry_date: tokens.expiresAt,
106
+ ...(tokens.scopes.length ? { scope: tokens.scopes.join(" ") } : {}),
107
+ token_type: tokens.tokenType,
108
+ }),
109
+ };
110
+ if (typeof session.principal !== "string")
111
+ throw new CliError("configuration_error", "drive's sign-in entry needs the account's principal");
112
+ return JSON.stringify(session);
113
+ },
114
+ };
115
+ /** zm's entry as zoom-cli writes it today: `{accessToken, refreshToken, expiresAt, scopes}`, all four required. */
116
+ export const zoomTokenCodec = {
117
+ decode: (raw) => {
118
+ const value = parse(raw);
119
+ const accessToken = text(value.accessToken);
120
+ const refreshToken = text(value.refreshToken);
121
+ const expiresAt = finite(value.expiresAt);
122
+ if (!accessToken || !refreshToken || expiresAt === undefined)
123
+ throw invalid();
124
+ if (!Array.isArray(value.scopes) || !value.scopes.every((scope) => typeof scope === "string"))
125
+ throw invalid();
126
+ return { tokens: { accessToken, refreshToken, expiresAt, scopes: value.scopes } };
127
+ },
128
+ encode: ({ tokens }) => {
129
+ if (!tokens.accessToken)
130
+ throw new CliError("invalid_response", "zm's sign-in entry needs an access token");
131
+ return JSON.stringify({
132
+ accessToken: tokens.accessToken,
133
+ refreshToken: tokens.refreshToken,
134
+ expiresAt: tokens.expiresAt ?? 0,
135
+ scopes: tokens.scopes,
136
+ });
137
+ },
138
+ };
139
+ const keyringFailure = () => new CliError("configuration_error", "could not reach the saved sign-in; use an available keyring or file storage", {
140
+ reason: "oauth.not-saved",
141
+ });
142
+ export const credentialStorage = (options) => {
143
+ if (!options.credentials.keyring)
144
+ throw new CliError("configuration_error", "a sign-in store needs its keyring passed in, never a default");
145
+ const credentials = new Credentials(options.credentials);
146
+ const { account, codec } = options;
147
+ const service = keyringService(options.credentials.service, options.credentials.configDir, options.credentials.isolated);
148
+ const raw = (name) => {
149
+ try {
150
+ return credentials.read(name)?.secret;
151
+ }
152
+ catch {
153
+ throw keyringFailure();
154
+ }
155
+ };
156
+ const save = (value) => {
157
+ try {
158
+ credentials.write(account, value);
159
+ }
160
+ catch {
161
+ throw keyringFailure();
162
+ }
163
+ };
164
+ const current = () => {
165
+ const own = raw(account);
166
+ if (own !== undefined)
167
+ return own;
168
+ for (const legacy of options.legacyAccounts ?? []) {
169
+ const found = raw(legacy);
170
+ if (found === undefined)
171
+ continue;
172
+ codec.decode(found);
173
+ save(found);
174
+ return found;
175
+ }
176
+ return undefined;
177
+ };
178
+ return {
179
+ key: `${service}\u0000${account}`,
180
+ probe: () => {
181
+ raw(account);
182
+ },
183
+ read: () => {
184
+ const value = current();
185
+ return value === undefined ? undefined : codec.decode(value);
186
+ },
187
+ write: (input) => save(codec.encode({ ...input, previous: raw(account) })),
188
+ remove: () => {
189
+ try {
190
+ return credentials.remove(account);
191
+ }
192
+ catch {
193
+ throw keyringFailure();
194
+ }
195
+ },
196
+ };
197
+ };
@@ -77,6 +77,8 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
77
77
  source: number | null;
78
78
  lane: string | null;
79
79
  state: "in-scope" | "out-of-scope" | "unavailable";
80
+ items: number;
81
+ capped: boolean;
80
82
  }[];
81
83
  fetch: {
82
84
  state: ProjectFetchState;
@@ -5,6 +5,7 @@ import { excerpt } from "./excerpt.js";
5
5
  import { attachNeighbours, documentMatches, } from "./project-gathering-around.js";
6
6
  import { decodeCheckpoint, encodeCheckpoint, projectGatheringRequest, projectUnits, seenKey, } from "./project-gathering-request.js";
7
7
  const terminal = new Set(["done", "unavailable", "failed"]);
8
+ const PAGE = 20;
8
9
  const CONTEXTS = ["person-only", "sender", "linked-chat", "project"];
9
10
  const contextOf = (lane) => lane.kinds.includes("linked-chat")
10
11
  ? "linked-chat"
@@ -84,6 +85,12 @@ export const gatherProjectEvidence = async (ports, request) => {
84
85
  const oversized = new Map();
85
86
  let stopped = null;
86
87
  const open = (u) => !terminal.has(u.state) && u.state !== "read-limited";
88
+ const linkedLanes = new Set(plan.linkedChats.flatMap((c) => (c.lane === null ? [] : [c.lane])));
89
+ // One even share per linked chat plus one for the word and sender lanes, so one busy chat cannot fill a run; never
90
+ // below a page, so every chat still gets its whole first page.
91
+ const linkedLimit = Math.max(PAGE, Math.floor(plan.budgets.maxItems / (linkedLanes.size + 1)));
92
+ const linkedTaken = new Map();
93
+ const capped = new Set();
87
94
  /** Records another lane finding a reference this run already returned; true when it had. */
88
95
  const again = (ref, lane) => {
89
96
  const known = byRef.get(ref);
@@ -133,11 +140,13 @@ export const gatherProjectEvidence = async (ports, request) => {
133
140
  byRef.set(item.ref, entry);
134
141
  items.push(entry);
135
142
  usage.items++;
143
+ if (linkedLanes.has(lane.id))
144
+ linkedTaken.set(lane.id, (linkedTaken.get(lane.id) ?? 0) + 1);
136
145
  };
137
146
  // A resumed run starts at its shallowest open unit: below it no unit is due, and an empty round would end the run.
138
147
  const shallowest = progress.reduce((low, u) => (open(u) ? Math.min(low, u.pages) : low), Number.POSITIVE_INFINITY);
139
148
  for (let round = shallowest; !stopped; round++) {
140
- const due = progress.flatMap((u, index) => (open(u) && u.pages <= round ? [index] : []));
149
+ const due = progress.flatMap((u, index) => open(u) && u.pages <= round && !capped.has(units[index]?.lane.id ?? "") ? [index] : []);
141
150
  if (!due.length)
142
151
  break;
143
152
  for (const index of due) {
@@ -145,6 +154,11 @@ export const gatherProjectEvidence = async (ports, request) => {
145
154
  if (!unit || !state)
146
155
  continue;
147
156
  signal?.throwIfAborted();
157
+ const room = linkedLanes.has(unit.lane.id) ? linkedLimit - (linkedTaken.get(unit.lane.id) ?? 0) : PAGE;
158
+ if (room <= 0) {
159
+ capped.add(unit.lane.id);
160
+ continue;
161
+ }
148
162
  stopped =
149
163
  usage.reads >= plan.budgets.maxReads
150
164
  ? "reads"
@@ -187,7 +201,7 @@ export const gatherProjectEvidence = async (ports, request) => {
187
201
  }
188
202
  };
189
203
  try {
190
- let page = await readPage(Math.min(plan.budgets.maxPageBytes, plan.budgets.maxReadBytes - usage.readBytes), 20);
204
+ let page = await readPage(Math.min(plan.budgets.maxPageBytes, plan.budgets.maxReadBytes - usage.readBytes), Math.min(PAGE, room));
191
205
  // A large record another lane already returned is not read again; its page already moves past it.
192
206
  const big = page.oversized && !again(page.oversized.ref, unit.lane) ? page.oversized : undefined;
193
207
  if (big && big.bytes <= CONTEXT_MAX_READ_BYTES) {
@@ -288,6 +302,8 @@ export const gatherProjectEvidence = async (ports, request) => {
288
302
  : units.every((unit, index) => unit.lane.id !== lane || progress[index]?.state === "unavailable")
289
303
  ? "unavailable"
290
304
  : "in-scope",
305
+ items: lane === null ? 0 : (linkedTaken.get(lane) ?? 0),
306
+ capped: lane !== null && capped.has(lane),
291
307
  })),
292
308
  fetch: fetched.map((f, index) => ({ source: index, ...f })),
293
309
  index: indexed,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.412.0",
3
+ "version": "0.413.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -128,6 +128,10 @@
128
128
  "./update": {
129
129
  "types": "./dist/update/index.d.ts",
130
130
  "default": "./dist/update/index.js"
131
+ },
132
+ "./oauth": {
133
+ "types": "./dist/oauth/index.d.ts",
134
+ "default": "./dist/oauth/index.js"
131
135
  }
132
136
  },
133
137
  "scripts": {