@enrichlayer/el-linear 1.4.0 → 1.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.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * On-disk storage for OAuth tokens.
3
+ *
4
+ * Stored at `<active-profile-dir>/oauth.json` with mode 0600 — same security
5
+ * posture as the personal-token file. Schema is versioned (`v: 1`) so future
6
+ * changes can be migrated without silently corrupting older state.
7
+ *
8
+ * The personal-token file (`<profile-dir>/token`) and `oauth.json` are
9
+ * mutually-exclusive *by convention*, not by enforcement: if both exist the
10
+ * resolver prefers OAuth. We don't delete the personal token automatically
11
+ * during `init oauth` — operators sometimes keep both for fallback.
12
+ */
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { CONFIG_DIR, resolveActiveProfile } from "../config/paths.js";
16
+ import { atomicWrite } from "./oauth-fs.js";
17
+ export const OAUTH_STATE_VERSION = 1;
18
+ export const OAUTH_STATE_FILENAME = "oauth.json";
19
+ /**
20
+ * Resolve the path to the active profile's `oauth.json`. Mirrors
21
+ * `activePaths()` in `commands/init/shared.ts` so OAuth state lands in the
22
+ * same directory as the profile's `config.json` and `token`.
23
+ */
24
+ export function oauthStatePath() {
25
+ const active = resolveActiveProfile();
26
+ return path.join(path.dirname(active.configPath), OAUTH_STATE_FILENAME);
27
+ }
28
+ /**
29
+ * Read the active profile's OAuth state, or `null` if none has been written.
30
+ * Returns `null` (not throw) on JSON parse errors so callers can fall back
31
+ * to personal-token auth without spamming users with repair instructions —
32
+ * the `init oauth` command is responsible for repair.
33
+ */
34
+ export async function readOAuthState() {
35
+ try {
36
+ const raw = await fs.readFile(oauthStatePath(), "utf8");
37
+ const parsed = JSON.parse(raw);
38
+ if (parsed?.v !== OAUTH_STATE_VERSION)
39
+ return null;
40
+ if (typeof parsed.accessToken !== "string" || parsed.accessToken === "") {
41
+ return null;
42
+ }
43
+ return parsed;
44
+ }
45
+ catch (err) {
46
+ if (err.code === "ENOENT")
47
+ return null;
48
+ // Corrupt JSON or unreadable file — treat as "no state" so the
49
+ // resolver can fall through to personal-token auth.
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Write the active profile's OAuth state atomically with mode 0600.
55
+ *
56
+ * IMPORTANT: uses the same write-tmp + rename pattern as `writeToken` so a
57
+ * pre-existing 0644 file gets its mode reset. Tokens leaking via group/other
58
+ * read is the failure mode we want to make impossible.
59
+ */
60
+ export async function writeOAuthState(state) {
61
+ const target = oauthStatePath();
62
+ // Ensure both the legacy CONFIG_DIR (where active-profile + profiles/
63
+ // live) and the active profile's directory exist before writing.
64
+ await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
65
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
66
+ await atomicWrite(target, `${JSON.stringify(state, null, 2)}\n`, 0o600);
67
+ }
68
+ /**
69
+ * Delete the active profile's OAuth state. No-op if the file is already gone.
70
+ */
71
+ export async function clearOAuthState() {
72
+ try {
73
+ await fs.unlink(oauthStatePath());
74
+ }
75
+ catch (err) {
76
+ if (err.code !== "ENOENT")
77
+ throw err;
78
+ }
79
+ }
80
+ /**
81
+ * Return `true` when the access token is still valid for at least
82
+ * `skewMs` milliseconds. Default 60s skew protects against clock drift +
83
+ * network latency.
84
+ */
85
+ export function isAccessTokenFresh(state, skewMs = 60_000) {
86
+ return Date.now() + skewMs < state.expiresAt;
87
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Linear OAuth token endpoint calls: code exchange, refresh, and revoke.
3
+ *
4
+ * Linear's token endpoint accepts both `application/x-www-form-urlencoded`
5
+ * and JSON; we use form-encoded because that's what the docs show and what
6
+ * spec-compliant servers all support.
7
+ *
8
+ * We keep the dependency surface tiny — just `globalThis.fetch` (Node 22+
9
+ * has it native) plus our own tiny error envelope. The only thing the
10
+ * caller injects is the URL fetcher, so tests can mock without touching the
11
+ * network.
12
+ */
13
+ import { type OAuthScope } from "./oauth-client.js";
14
+ /**
15
+ * Minimal subset of `globalThis.fetch` we use. Typing as the structural
16
+ * shape (instead of `typeof fetch`) avoids dragging in DOM lib types.
17
+ */
18
+ export type FetchLike = (url: string, init: {
19
+ method: string;
20
+ headers: Record<string, string>;
21
+ body: string;
22
+ }) => Promise<{
23
+ ok: boolean;
24
+ status: number;
25
+ statusText: string;
26
+ text(): Promise<string>;
27
+ }>;
28
+ export interface ExchangeCodeInput {
29
+ clientId: string;
30
+ clientSecret?: string;
31
+ code: string;
32
+ redirectUri: string;
33
+ codeVerifier: string;
34
+ }
35
+ export interface RefreshTokensInput {
36
+ clientId: string;
37
+ clientSecret?: string;
38
+ refreshToken: string;
39
+ }
40
+ export interface RevokeTokenInput {
41
+ accessToken: string;
42
+ }
43
+ export interface ExchangeResult {
44
+ accessToken: string;
45
+ refreshToken?: string;
46
+ tokenType: string;
47
+ scopes: OAuthScope[];
48
+ /** Unix epoch milliseconds; computed from `expires_in`. */
49
+ expiresAt: number;
50
+ }
51
+ /**
52
+ * Exchange an authorization code for tokens. PKCE-aware: send the verifier;
53
+ * `client_secret` is optional (some Linear apps configured as native/public
54
+ * don't have one).
55
+ */
56
+ export declare function exchangeCodeForTokens(input: ExchangeCodeInput, fetchImpl?: FetchLike, now?: () => number): Promise<ExchangeResult>;
57
+ /**
58
+ * Use a refresh token to get a new access token. Linear may rotate the
59
+ * refresh token, so we plumb both fields through.
60
+ */
61
+ export declare function refreshTokens(input: RefreshTokensInput, fetchImpl?: FetchLike, now?: () => number): Promise<ExchangeResult>;
62
+ /**
63
+ * Revoke an access token. Best-effort — we don't throw on transport
64
+ * errors so callers can still clear local state.
65
+ */
66
+ export declare function revokeToken(input: RevokeTokenInput, fetchImpl?: FetchLike): Promise<{
67
+ ok: boolean;
68
+ status: number;
69
+ message?: string;
70
+ }>;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Linear OAuth token endpoint calls: code exchange, refresh, and revoke.
3
+ *
4
+ * Linear's token endpoint accepts both `application/x-www-form-urlencoded`
5
+ * and JSON; we use form-encoded because that's what the docs show and what
6
+ * spec-compliant servers all support.
7
+ *
8
+ * We keep the dependency surface tiny — just `globalThis.fetch` (Node 22+
9
+ * has it native) plus our own tiny error envelope. The only thing the
10
+ * caller injects is the URL fetcher, so tests can mock without touching the
11
+ * network.
12
+ */
13
+ import { LINEAR_REVOKE_URL, LINEAR_TOKEN_URL, } from "./oauth-client.js";
14
+ const defaultFetch = async (url, init) => {
15
+ const res = await globalThis.fetch(url, init);
16
+ return {
17
+ ok: res.ok,
18
+ status: res.status,
19
+ statusText: res.statusText,
20
+ text: () => res.text(),
21
+ };
22
+ };
23
+ /** Common form-encoded POST helper. Throws an error with sanitized body on non-2xx. */
24
+ async function postForm(url, params, fetchImpl) {
25
+ const body = new URLSearchParams(params).toString();
26
+ let res;
27
+ try {
28
+ res = await fetchImpl(url, {
29
+ method: "POST",
30
+ headers: {
31
+ "content-type": "application/x-www-form-urlencoded",
32
+ accept: "application/json",
33
+ },
34
+ body,
35
+ });
36
+ }
37
+ catch (err) {
38
+ const message = err instanceof Error ? err.message : String(err);
39
+ throw new Error(`Network error talking to ${url}: ${message}`);
40
+ }
41
+ const text = await res.text();
42
+ if (!res.ok) {
43
+ // Don't dump the request body — it contains client_secret /
44
+ // refresh_token / authorization code, all of which are secrets.
45
+ throw new Error(`OAuth endpoint ${url} responded ${res.status} ${res.statusText}: ${text || "(empty body)"}`);
46
+ }
47
+ try {
48
+ return JSON.parse(text);
49
+ }
50
+ catch {
51
+ throw new Error(`OAuth endpoint ${url} returned non-JSON response: ${text.slice(0, 200)}`);
52
+ }
53
+ }
54
+ function parseScopes(raw) {
55
+ if (!raw)
56
+ return [];
57
+ // Linear docs show scopes are returned comma-separated; some OAuth
58
+ // servers return space-separated. Accept either.
59
+ return raw
60
+ .split(/[,\s]+/)
61
+ .map((s) => s.trim())
62
+ .filter(Boolean);
63
+ }
64
+ function tokenResponseToResult(response, now) {
65
+ const expiresInMs = typeof response.expires_in === "number" && response.expires_in > 0
66
+ ? response.expires_in * 1000
67
+ : // Linear docs say tokens last 24h. If the field is missing, fall
68
+ // back to 23h to leave a safety margin before forcing a refresh.
69
+ 23 * 60 * 60 * 1000;
70
+ return {
71
+ accessToken: response.access_token,
72
+ refreshToken: response.refresh_token,
73
+ tokenType: response.token_type ?? "Bearer",
74
+ scopes: parseScopes(response.scope),
75
+ expiresAt: now + expiresInMs,
76
+ };
77
+ }
78
+ /**
79
+ * Exchange an authorization code for tokens. PKCE-aware: send the verifier;
80
+ * `client_secret` is optional (some Linear apps configured as native/public
81
+ * don't have one).
82
+ */
83
+ export async function exchangeCodeForTokens(input, fetchImpl = defaultFetch, now = Date.now) {
84
+ const params = {
85
+ grant_type: "authorization_code",
86
+ code: input.code,
87
+ redirect_uri: input.redirectUri,
88
+ client_id: input.clientId,
89
+ code_verifier: input.codeVerifier,
90
+ };
91
+ if (input.clientSecret)
92
+ params.client_secret = input.clientSecret;
93
+ const res = await postForm(LINEAR_TOKEN_URL, params, fetchImpl);
94
+ if (typeof res.access_token !== "string" || res.access_token === "") {
95
+ throw new Error("OAuth response missing `access_token`.");
96
+ }
97
+ return tokenResponseToResult(res, now());
98
+ }
99
+ /**
100
+ * Use a refresh token to get a new access token. Linear may rotate the
101
+ * refresh token, so we plumb both fields through.
102
+ */
103
+ export async function refreshTokens(input, fetchImpl = defaultFetch, now = Date.now) {
104
+ const params = {
105
+ grant_type: "refresh_token",
106
+ refresh_token: input.refreshToken,
107
+ client_id: input.clientId,
108
+ };
109
+ if (input.clientSecret)
110
+ params.client_secret = input.clientSecret;
111
+ const res = await postForm(LINEAR_TOKEN_URL, params, fetchImpl);
112
+ if (typeof res.access_token !== "string" || res.access_token === "") {
113
+ throw new Error("OAuth refresh response missing `access_token`.");
114
+ }
115
+ return tokenResponseToResult(res, now());
116
+ }
117
+ /**
118
+ * Revoke an access token. Best-effort — we don't throw on transport
119
+ * errors so callers can still clear local state.
120
+ */
121
+ export async function revokeToken(input, fetchImpl = defaultFetch) {
122
+ try {
123
+ const res = await fetchImpl(LINEAR_REVOKE_URL, {
124
+ method: "POST",
125
+ headers: {
126
+ authorization: `Bearer ${input.accessToken}`,
127
+ "content-type": "application/x-www-form-urlencoded",
128
+ },
129
+ body: "",
130
+ });
131
+ return {
132
+ ok: res.ok,
133
+ status: res.status,
134
+ message: res.ok ? undefined : await res.text(),
135
+ };
136
+ }
137
+ catch (err) {
138
+ const message = err instanceof Error ? err.message : String(err);
139
+ return { ok: false, status: 0, message };
140
+ }
141
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Unified resolver: returns the right credential for a CLI invocation,
3
+ * preferring OAuth state when present and auto-refreshing expired tokens.
4
+ *
5
+ * Resolution order (highest priority first):
6
+ * 1. `--api-token <token>` flag (always personal-style — sent without
7
+ * `Bearer `).
8
+ * 2. `LINEAR_API_TOKEN` env var (personal-style).
9
+ * 3. Profile OAuth state (`<profile-dir>/oauth.json`) — auto-refreshes
10
+ * when the access token has < 60s of validity left.
11
+ * 4. Profile personal token (`<profile-dir>/token`) and legacy fallbacks.
12
+ *
13
+ * The kind discriminant (`personal` vs `oauth`) tells `GraphQLService` which
14
+ * `Authorization` header shape to use:
15
+ * - personal: `Authorization: <token>` (no Bearer prefix)
16
+ * - oauth: `Authorization: Bearer <token>`
17
+ */
18
+ import { type OAuthState } from "./oauth-storage.js";
19
+ import { type FetchLike } from "./oauth-token.js";
20
+ export interface ActiveAuth {
21
+ kind: "personal" | "oauth";
22
+ token: string;
23
+ /** Original OAuth state, when `kind === "oauth"`. */
24
+ oauth?: OAuthState;
25
+ }
26
+ export interface GetActiveAuthOptions {
27
+ apiToken?: string;
28
+ /** Test seam: override the network fetcher used by refresh. */
29
+ fetchImpl?: FetchLike;
30
+ /** Test seam: override the wall-clock for refresh expiry checks. */
31
+ now?: () => number;
32
+ }
33
+ /**
34
+ * Resolve the credential for this invocation.
35
+ *
36
+ * Synchronous personal-token paths still work via `getApiToken` for
37
+ * backwards compatibility — code that hasn't been migrated to OAuth-aware
38
+ * call sites keeps using `getApiToken` directly.
39
+ */
40
+ export declare function getActiveAuth(options?: GetActiveAuthOptions): Promise<ActiveAuth>;
41
+ /**
42
+ * If the token is fresh, return state unchanged. Otherwise call
43
+ * `refreshTokens`, write the new state to disk, and return it.
44
+ *
45
+ * On refresh failure, throws an actionable error pointing at
46
+ * `el-linear init oauth`.
47
+ */
48
+ export declare function ensureFreshAccessToken(state: OAuthState, options?: GetActiveAuthOptions): Promise<OAuthState>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Unified resolver: returns the right credential for a CLI invocation,
3
+ * preferring OAuth state when present and auto-refreshing expired tokens.
4
+ *
5
+ * Resolution order (highest priority first):
6
+ * 1. `--api-token <token>` flag (always personal-style — sent without
7
+ * `Bearer `).
8
+ * 2. `LINEAR_API_TOKEN` env var (personal-style).
9
+ * 3. Profile OAuth state (`<profile-dir>/oauth.json`) — auto-refreshes
10
+ * when the access token has < 60s of validity left.
11
+ * 4. Profile personal token (`<profile-dir>/token`) and legacy fallbacks.
12
+ *
13
+ * The kind discriminant (`personal` vs `oauth`) tells `GraphQLService` which
14
+ * `Authorization` header shape to use:
15
+ * - personal: `Authorization: <token>` (no Bearer prefix)
16
+ * - oauth: `Authorization: Bearer <token>`
17
+ */
18
+ import { getApiToken } from "../utils/auth.js";
19
+ import { isAccessTokenFresh, readOAuthState, writeOAuthState, } from "./oauth-storage.js";
20
+ import { refreshTokens, } from "./oauth-token.js";
21
+ /**
22
+ * Resolve the credential for this invocation.
23
+ *
24
+ * Synchronous personal-token paths still work via `getApiToken` for
25
+ * backwards compatibility — code that hasn't been migrated to OAuth-aware
26
+ * call sites keeps using `getApiToken` directly.
27
+ */
28
+ export async function getActiveAuth(options = {}) {
29
+ // 1) Explicit override: `--api-token` always wins. Personal-style.
30
+ if (options.apiToken) {
31
+ return { kind: "personal", token: options.apiToken };
32
+ }
33
+ // 2) Env var: same precedence as the legacy resolver. Personal-style.
34
+ if (process.env.LINEAR_API_TOKEN) {
35
+ return { kind: "personal", token: process.env.LINEAR_API_TOKEN };
36
+ }
37
+ // 3) OAuth state for the active profile, auto-refreshed if needed.
38
+ const oauth = await readOAuthState();
39
+ if (oauth) {
40
+ const fresh = await ensureFreshAccessToken(oauth, options);
41
+ return { kind: "oauth", token: fresh.accessToken, oauth: fresh };
42
+ }
43
+ // 4) Fall back to the existing personal-token resolver (handles
44
+ // profile-aware token files, legacy paths, etc.). If no token can
45
+ // be found, this throws — and the message already mentions the
46
+ // profile, so we don't have to re-wrap.
47
+ return { kind: "personal", token: getApiToken({}) };
48
+ }
49
+ /**
50
+ * If the token is fresh, return state unchanged. Otherwise call
51
+ * `refreshTokens`, write the new state to disk, and return it.
52
+ *
53
+ * On refresh failure, throws an actionable error pointing at
54
+ * `el-linear init oauth`.
55
+ */
56
+ export async function ensureFreshAccessToken(state, options = {}) {
57
+ const now = options.now ?? Date.now;
58
+ if (isAccessTokenFresh(state, /* skewMs */ 60_000)) {
59
+ // `isAccessTokenFresh` reads `Date.now()` internally; for the
60
+ // purpose of the test seam we re-check against the injected clock.
61
+ if (now() + 60_000 < state.expiresAt) {
62
+ return state;
63
+ }
64
+ }
65
+ if (!state.refreshToken) {
66
+ throw new Error("OAuth access token expired and no refresh token is stored. Re-run `el-linear init oauth`.");
67
+ }
68
+ let refreshed;
69
+ try {
70
+ refreshed = await refreshTokens({
71
+ clientId: state.clientId,
72
+ clientSecret: state.clientSecret,
73
+ refreshToken: state.refreshToken,
74
+ }, options.fetchImpl, now);
75
+ }
76
+ catch (err) {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ throw new Error(`OAuth refresh failed: ${message}. Re-run \`el-linear init oauth\` to re-authorize.`);
79
+ }
80
+ const next = {
81
+ ...state,
82
+ accessToken: refreshed.accessToken,
83
+ // Preserve the previous refresh token if the server didn't rotate
84
+ // (some OAuth servers only return a new refresh_token periodically).
85
+ refreshToken: refreshed.refreshToken ?? state.refreshToken,
86
+ tokenType: refreshed.tokenType,
87
+ // Use the freshly-returned scopes only if non-empty; otherwise keep
88
+ // what we had, since some token endpoints omit `scope` on refresh.
89
+ scopes: refreshed.scopes.length > 0 ? refreshed.scopes : state.scopes,
90
+ expiresAt: refreshed.expiresAt,
91
+ obtainedAt: now(),
92
+ };
93
+ await writeOAuthState(next);
94
+ return next;
95
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { mergeAliasesIntoConfig, runAliasesImport, runAliasesStep, } from "./aliases.js";
18
18
  import { runDefaultsStep } from "./defaults.js";
19
+ import { runOAuthRevoke, runOAuthStep } from "./oauth.js";
19
20
  import { assignDefined, printStep, readConfig, writeConfig, } from "./shared.js";
20
21
  import { runTokenStep } from "./token.js";
21
22
  import { runWorkspaceStep } from "./workspace.js";
@@ -56,6 +57,27 @@ export function setupInitCommands(program) {
56
57
  printStep("token", "Linear API token");
57
58
  await runTokenStep({ force: options.force ?? false });
58
59
  }));
60
+ init
61
+ .command("oauth")
62
+ .description("Authorize via OAuth 2.0 (PKCE) — alternative to a personal API token")
63
+ .option("--force", "ignore existing tokens; re-authorize unconditionally")
64
+ .option("--revoke", "revoke and remove the stored OAuth tokens")
65
+ .option("--no-browser", "skip the browser-open + localhost listener; paste the code manually")
66
+ .option("--port <port>", "localhost callback port (default 8765)", (value) => Number.parseInt(value, 10))
67
+ .action(withCleanExit(async (options) => {
68
+ printStep("oauth", "Linear OAuth (PKCE)");
69
+ if (options.revoke) {
70
+ const result = await runOAuthRevoke();
71
+ console.log(` ${result.message}`);
72
+ return;
73
+ }
74
+ await runOAuthStep({
75
+ force: options.force ?? false,
76
+ // commander's `--no-browser` produces `browser: false`.
77
+ noBrowser: options.browser === false,
78
+ port: options.port,
79
+ });
80
+ }));
59
81
  init
60
82
  .command("workspace")
61
83
  .description("Set the default team and refresh team UUID cache")
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Wizard step for OAuth 2.0 (PKCE flow) authorization.
3
+ *
4
+ * Flow:
5
+ * 1. Present a "what is this?" intro pointing the user at Linear's OAuth
6
+ * app registration page. (Until we ship a shared OAuth client_id,
7
+ * every user registers their own app.)
8
+ * 2. Prompt for `client_id`, optional `client_secret`, port, scopes.
9
+ * 3. Generate PKCE verifier + state, build the authorize URL.
10
+ * 4. Try to open the system browser; fall back to printing the URL.
11
+ * 5. Spin a localhost listener (or fall back to pasted-code prompt) to
12
+ * receive the redirect.
13
+ * 6. Exchange code for tokens.
14
+ * 7. Validate by calling `viewer { ... }` with the new bearer token.
15
+ * 8. Persist `oauth.json` to the active profile.
16
+ *
17
+ * Idempotent: if a fresh `oauth.json` already exists, offer keep / re-auth /
18
+ * revoke before doing anything else.
19
+ */
20
+ import { runLocalhostCallback } from "../../auth/oauth-callback.js";
21
+ import { type OAuthState } from "../../auth/oauth-storage.js";
22
+ import { type FetchLike } from "../../auth/oauth-token.js";
23
+ interface ViewerResponse {
24
+ viewer: {
25
+ id: string;
26
+ name: string;
27
+ email: string;
28
+ displayName: string;
29
+ organization: {
30
+ urlKey: string;
31
+ name: string;
32
+ };
33
+ };
34
+ }
35
+ export interface OAuthStepOptions {
36
+ /** Force re-authorization even if existing state is valid. */
37
+ force?: boolean;
38
+ /** Skip the localhost listener; use the headless code-paste prompt. */
39
+ noBrowser?: boolean;
40
+ /** Override the localhost port. Default 8765. */
41
+ port?: number;
42
+ /** Test seam for the OAuth token endpoint. */
43
+ fetchImpl?: FetchLike;
44
+ /**
45
+ * Test seam for the localhost listener. Default uses the real one.
46
+ */
47
+ runLocalhostCallbackImpl?: typeof runLocalhostCallback;
48
+ /** Test seam for the system-browser opener. Default uses spawn. */
49
+ openBrowser?: (url: string) => Promise<void>;
50
+ /** Test seam for `viewer` validation against the new bearer token. */
51
+ validateViewer?: (oauthToken: string) => Promise<ViewerResponse["viewer"]>;
52
+ }
53
+ export interface OAuthStepResult {
54
+ state: OAuthState;
55
+ viewer: ViewerResponse["viewer"];
56
+ }
57
+ /**
58
+ * Open `url` in the system browser. Forks per-platform:
59
+ * - darwin → `open <url>`
60
+ * - win32 → `start "" "<url>"` (cmd builtin)
61
+ * - other → `xdg-open <url>` (Linux / BSD with desktop env)
62
+ *
63
+ * Returns once the spawn call succeeds — does NOT wait for the browser to
64
+ * actually load. Throws on spawn failure (e.g. xdg-open not installed in
65
+ * a barebones container).
66
+ */
67
+ export declare function openSystemBrowser(url: string): Promise<void>;
68
+ /**
69
+ * Run the OAuth step. Returns the final stored state + viewer info.
70
+ *
71
+ * If the user opts to keep their existing tokens, returns the existing
72
+ * state unchanged (no network calls).
73
+ */
74
+ export declare function runOAuthStep(options?: OAuthStepOptions): Promise<OAuthStepResult>;
75
+ /**
76
+ * `init oauth --revoke`: revoke the active profile's tokens and remove
77
+ * `oauth.json`. Best-effort on the network call.
78
+ */
79
+ export declare function runOAuthRevoke(options?: {
80
+ fetchImpl?: FetchLike;
81
+ }): Promise<{
82
+ revoked: boolean;
83
+ message: string;
84
+ }>;
85
+ export {};