@wirecat/cli-core 0.412.0 → 0.414.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.
@@ -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
+ };
@@ -14,6 +14,11 @@ export interface AppIdentity {
14
14
  issues?: string;
15
15
  /** The language of the transcript a person reads: its day headings and the word for "you". `ru-RU` when unset. */
16
16
  locale?: string;
17
+ /**
18
+ * ISO 639-1 codes of the languages its content is mostly in (`["ru"]`). The first run pre-ticks the ones search
19
+ * can stem in place of English and the system's language; the person still confirms, since the choice is shared.
20
+ */
21
+ contentLanguages?: readonly string[];
17
22
  /** Error reasons and detail keys of its own — max's `max_code`, tg's `migrated_chat_id` — kept in every error it prints. */
18
23
  errors?: AppErrors;
19
24
  }
@@ -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;