@alphafox/cli 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/auth/browser-login.d.ts +30 -0
  2. package/dist/auth/browser-login.js +193 -0
  3. package/dist/auth/loopback-callback.d.ts +32 -0
  4. package/dist/auth/loopback-callback.js +175 -0
  5. package/dist/auth/open-browser.d.ts +15 -0
  6. package/dist/auth/open-browser.js +54 -0
  7. package/dist/auth/refresh.d.ts +27 -2
  8. package/dist/auth/refresh.js +52 -15
  9. package/dist/catalog/allowlist.d.ts +20 -4
  10. package/dist/catalog/allowlist.js +126 -25
  11. package/dist/catalog/command-tree.d.ts +34 -0
  12. package/dist/catalog/command-tree.js +117 -0
  13. package/dist/catalog/compatibility.d.ts +23 -0
  14. package/dist/catalog/compatibility.js +58 -0
  15. package/dist/catalog/generated/registry.json +6137 -0
  16. package/dist/catalog/generated/schemas.json +31036 -0
  17. package/dist/catalog/operations.d.ts +76 -3
  18. package/dist/catalog/operations.js +87 -213
  19. package/dist/commands/run.js +171 -153
  20. package/dist/config/profiles.js +3 -3
  21. package/dist/envelope.d.ts +3 -0
  22. package/dist/envelope.js +43 -3
  23. package/dist/http/client.js +35 -19
  24. package/dist/index.d.ts +12 -5
  25. package/dist/index.js +32 -1
  26. package/dist/keychain/linux-secret-service.d.ts +11 -0
  27. package/dist/keychain/linux-secret-service.js +93 -0
  28. package/dist/keychain/store.d.ts +20 -1
  29. package/dist/keychain/store.js +94 -6
  30. package/dist/keychain/windows-credential.d.ts +13 -0
  31. package/dist/keychain/windows-credential.js +176 -0
  32. package/dist/safety/confirmation.d.ts +10 -3
  33. package/dist/safety/confirmation.js +27 -4
  34. package/dist/version.d.ts +2 -2
  35. package/dist/version.js +3 -2
  36. package/docs/agents/domain.md +51 -0
  37. package/docs/agents/issue-tracker.md +156 -0
  38. package/docs/agents/triage-labels.md +18 -0
  39. package/docs/e2e-staging.md +76 -6
  40. package/docs/release-supply-chain.md +99 -26
  41. package/package.json +4 -2
  42. package/skills/account/SKILL.md +8 -6
  43. package/skills/admin/SKILL.md +9 -4
  44. package/skills/alphafox-shared/SKILL.md +23 -14
  45. package/skills/auth/SKILL.md +21 -6
  46. package/skills/exchange/SKILL.md +10 -3
  47. package/skills/market/SKILL.md +9 -4
  48. package/skills/notification/SKILL.md +8 -3
  49. package/skills/strategy/SKILL.md +15 -9
  50. package/skills/trading/SKILL.md +15 -5
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Interactive Authorization Code + PKCE with a loopback callback.
3
+ * The CLI listens on 127.0.0.1, opens the system browser, exchanges the
4
+ * code with the in-memory verifier, and stores tokens in the OS keychain.
5
+ */
6
+ import type { ProfileConfig } from "../config/profiles";
7
+ import { type OpenBrowserResult } from "./open-browser";
8
+ export declare const DEFAULT_BROWSER_LOGIN_TIMEOUT_MS = 300000;
9
+ export type BrowserLoginResult = {
10
+ readonly status: "authenticated";
11
+ readonly accessTokenFingerprint: string;
12
+ readonly expiresIn: number;
13
+ readonly requestId?: string;
14
+ } | {
15
+ readonly status: "failed";
16
+ readonly reason: "listen_failed" | "browser_open_failed" | "timeout" | "state_mismatch" | "oauth_error" | "missing_code" | "token_exchange_failed";
17
+ readonly message: string;
18
+ readonly authorizeUrl?: string;
19
+ readonly oauthError?: string;
20
+ };
21
+ export type OpenBrowserFn = (url: string) => OpenBrowserResult | Promise<OpenBrowserResult>;
22
+ export declare function browserLoginTimeoutMs(env?: NodeJS.ProcessEnv): number;
23
+ export declare function resolveOpenBrowser(env?: NodeJS.ProcessEnv): OpenBrowserFn;
24
+ export declare function runBrowserPkceLogin(input: {
25
+ readonly profile: ProfileConfig;
26
+ readonly env?: NodeJS.ProcessEnv;
27
+ readonly timeoutMs?: number;
28
+ readonly openBrowser?: OpenBrowserFn;
29
+ readonly fetchImpl?: typeof fetch;
30
+ }): Promise<BrowserLoginResult>;
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ /**
3
+ * Interactive Authorization Code + PKCE with a loopback callback.
4
+ * The CLI listens on 127.0.0.1, opens the system browser, exchanges the
5
+ * code with the in-memory verifier, and stores tokens in the OS keychain.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.DEFAULT_BROWSER_LOGIN_TIMEOUT_MS = void 0;
9
+ exports.browserLoginTimeoutMs = browserLoginTimeoutMs;
10
+ exports.resolveOpenBrowser = resolveOpenBrowser;
11
+ exports.runBrowserPkceLogin = runBrowserPkceLogin;
12
+ const node_crypto_1 = require("node:crypto");
13
+ const client_1 = require("../http/client");
14
+ const store_1 = require("../keychain/store");
15
+ const loopback_callback_1 = require("./loopback-callback");
16
+ const open_browser_1 = require("./open-browser");
17
+ const pkce_1 = require("./pkce");
18
+ exports.DEFAULT_BROWSER_LOGIN_TIMEOUT_MS = 300_000;
19
+ function browserLoginTimeoutMs(env = process.env) {
20
+ const raw = env.ALPHAFOX_BROWSER_LOGIN_TIMEOUT_MS?.trim();
21
+ if (!raw)
22
+ return exports.DEFAULT_BROWSER_LOGIN_TIMEOUT_MS;
23
+ const n = Number(raw);
24
+ return Number.isFinite(n) && n > 0 ? n : exports.DEFAULT_BROWSER_LOGIN_TIMEOUT_MS;
25
+ }
26
+ function resolveOpenBrowser(env = process.env) {
27
+ if (env.ALPHAFOX_TEST_BROWSER_OPEN === "fail") {
28
+ return () => ({ ok: false, reason: "test_browser_open_disabled" });
29
+ }
30
+ return open_browser_1.openSystemBrowser;
31
+ }
32
+ async function runBrowserPkceLogin(input) {
33
+ const env = input.env ?? process.env;
34
+ const openBrowser = input.openBrowser ?? resolveOpenBrowser(env);
35
+ const timeoutMs = input.timeoutMs ?? browserLoginTimeoutMs(env);
36
+ const state = (0, node_crypto_1.randomUUID)();
37
+ const pkce = (0, pkce_1.generatePkcePair)();
38
+ let loopback;
39
+ try {
40
+ loopback = await (0, loopback_callback_1.startLoopbackCallbackServer)({
41
+ expectedState: state,
42
+ timeoutMs,
43
+ });
44
+ }
45
+ catch (err) {
46
+ return {
47
+ status: "failed",
48
+ reason: "listen_failed",
49
+ message: err instanceof Error
50
+ ? `Could not bind loopback callback server: ${err.message}`
51
+ : "Could not bind loopback callback server.",
52
+ };
53
+ }
54
+ const authorizeUrl = (0, pkce_1.buildAuthorizeUrl)({
55
+ issuer: input.profile.issuer,
56
+ clientId: input.profile.clientId,
57
+ redirectUri: loopback.redirectUri,
58
+ codeChallenge: pkce.codeChallenge,
59
+ state,
60
+ });
61
+ try {
62
+ let opened;
63
+ try {
64
+ opened = await openBrowser(authorizeUrl);
65
+ }
66
+ catch (err) {
67
+ return {
68
+ status: "failed",
69
+ reason: "browser_open_failed",
70
+ message: err instanceof Error
71
+ ? `Could not open the system browser (${err.message}).`
72
+ : "Could not open the system browser.",
73
+ authorizeUrl,
74
+ };
75
+ }
76
+ if (!opened.ok) {
77
+ return {
78
+ status: "failed",
79
+ reason: "browser_open_failed",
80
+ message: `Could not open the system browser (${opened.reason}).`,
81
+ authorizeUrl,
82
+ };
83
+ }
84
+ const callback = await loopback.wait();
85
+ if (callback.status === "timeout") {
86
+ return {
87
+ status: "failed",
88
+ reason: "timeout",
89
+ message: "Browser login timed out waiting for the localhost callback.",
90
+ };
91
+ }
92
+ if (callback.status === "state_mismatch") {
93
+ return {
94
+ status: "failed",
95
+ reason: "state_mismatch",
96
+ message: "OAuth callback state did not match the login attempt.",
97
+ };
98
+ }
99
+ if (callback.status === "oauth_error") {
100
+ return {
101
+ status: "failed",
102
+ reason: "oauth_error",
103
+ message: `Authorization server returned ${callback.error}.`,
104
+ oauthError: callback.error,
105
+ };
106
+ }
107
+ if (callback.status === "missing_code") {
108
+ return {
109
+ status: "failed",
110
+ reason: "missing_code",
111
+ message: "OAuth callback did not include an authorization code.",
112
+ };
113
+ }
114
+ let res;
115
+ try {
116
+ res = await (0, client_1.apiRequest)({
117
+ method: "POST",
118
+ path: "/api/auth/oauth/token",
119
+ profile: input.profile,
120
+ skipAuth: true,
121
+ body: {
122
+ grant_type: "authorization_code",
123
+ code: callback.code,
124
+ redirect_uri: loopback.redirectUri,
125
+ client_id: input.profile.clientId,
126
+ code_verifier: pkce.codeVerifier,
127
+ },
128
+ }, env, input.fetchImpl ?? fetch);
129
+ }
130
+ catch (err) {
131
+ return {
132
+ status: "failed",
133
+ reason: "token_exchange_failed",
134
+ message: err instanceof Error
135
+ ? `Token exchange failed (${err.message}).`
136
+ : "Token exchange failed.",
137
+ };
138
+ }
139
+ if (res.status >= 400) {
140
+ return {
141
+ status: "failed",
142
+ reason: "token_exchange_failed",
143
+ message: `Token exchange failed (HTTP ${res.status}).`,
144
+ };
145
+ }
146
+ const tokens = parseTokenPair(res.json);
147
+ if (!tokens) {
148
+ return {
149
+ status: "failed",
150
+ reason: "token_exchange_failed",
151
+ message: "Token response missing access_token/refresh_token.",
152
+ };
153
+ }
154
+ (0, store_1.saveTokens)(input.profile.name, {
155
+ accessToken: tokens.access_token,
156
+ refreshToken: tokens.refresh_token,
157
+ expiresAt: Date.now() + (tokens.expires_in ?? 600) * 1000,
158
+ environment: input.profile.name,
159
+ issuer: input.profile.issuer,
160
+ audience: input.profile.audience,
161
+ clientId: input.profile.clientId,
162
+ scopes: (tokens.scope ?? "openid profile").split(/\s+/).filter(Boolean),
163
+ }, env);
164
+ return {
165
+ status: "authenticated",
166
+ accessTokenFingerprint: (0, store_1.tokenFingerprint)(tokens.access_token),
167
+ expiresIn: tokens.expires_in ?? 600,
168
+ requestId: res.requestId,
169
+ };
170
+ }
171
+ finally {
172
+ await loopback.close();
173
+ }
174
+ }
175
+ function parseTokenPair(json) {
176
+ if (!json || typeof json !== "object")
177
+ return null;
178
+ const o = json;
179
+ const access = o.access_token ?? o.accessToken;
180
+ const refresh = o.refresh_token ?? o.refreshToken;
181
+ if (typeof access !== "string" || typeof refresh !== "string")
182
+ return null;
183
+ return {
184
+ access_token: access,
185
+ refresh_token: refresh,
186
+ expires_in: typeof o.expires_in === "number"
187
+ ? o.expires_in
188
+ : typeof o.expiresIn === "number"
189
+ ? o.expiresIn
190
+ : undefined,
191
+ scope: typeof o.scope === "string" ? o.scope : undefined,
192
+ };
193
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * One-shot RFC 8252 loopback callback server for Authorization Code + PKCE.
3
+ * Binds 127.0.0.1 only. HTML responses never include code, token, or verifier.
4
+ */
5
+ export declare const LOOPBACK_CALLBACK_PATH = "/callback";
6
+ export type LoopbackCallbackResult = {
7
+ readonly status: "success";
8
+ readonly code: string;
9
+ readonly state: string;
10
+ } | {
11
+ readonly status: "oauth_error";
12
+ readonly error: string;
13
+ readonly errorDescription?: string;
14
+ } | {
15
+ readonly status: "state_mismatch";
16
+ } | {
17
+ readonly status: "missing_code";
18
+ } | {
19
+ readonly status: "timeout";
20
+ };
21
+ export interface LoopbackCallbackServer {
22
+ readonly redirectUri: string;
23
+ readonly port: number;
24
+ wait(): Promise<LoopbackCallbackResult>;
25
+ close(): Promise<void>;
26
+ }
27
+ export declare function startLoopbackCallbackServer(options: {
28
+ readonly expectedState: string;
29
+ readonly timeoutMs?: number;
30
+ readonly path?: string;
31
+ readonly port?: number;
32
+ }): Promise<LoopbackCallbackServer>;
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ /**
3
+ * One-shot RFC 8252 loopback callback server for Authorization Code + PKCE.
4
+ * Binds 127.0.0.1 only. HTML responses never include code, token, or verifier.
5
+ */
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.LOOPBACK_CALLBACK_PATH = void 0;
11
+ exports.startLoopbackCallbackServer = startLoopbackCallbackServer;
12
+ const node_crypto_1 = require("node:crypto");
13
+ const node_http_1 = __importDefault(require("node:http"));
14
+ exports.LOOPBACK_CALLBACK_PATH = "/callback";
15
+ const SUCCESS_HTML = `<!DOCTYPE html>
16
+ <html lang="en"><head><meta charset="utf-8"><title>Alphafox CLI</title></head>
17
+ <body><p>Signed in. You can close this window and return to the CLI.</p></body></html>
18
+ `;
19
+ const FAILURE_HTML = `<!DOCTYPE html>
20
+ <html lang="en"><head><meta charset="utf-8"><title>Alphafox CLI</title></head>
21
+ <body><p>Authorization failed. You can close this window and return to the CLI.</p></body></html>
22
+ `;
23
+ const DUPLICATE_HTML = `<!DOCTYPE html>
24
+ <html lang="en"><head><meta charset="utf-8"><title>Alphafox CLI</title></head>
25
+ <body><p>This login callback was already used. You can close this window.</p></body></html>
26
+ `;
27
+ async function startLoopbackCallbackServer(options) {
28
+ const callbackPath = options.path ?? exports.LOOPBACK_CALLBACK_PATH;
29
+ const timeoutMs = options.timeoutMs ?? 300_000;
30
+ const expectedState = options.expectedState;
31
+ let settled = false;
32
+ let timer;
33
+ let resolveDone;
34
+ const done = new Promise((resolve) => {
35
+ resolveDone = resolve;
36
+ });
37
+ const server = node_http_1.default.createServer((req, res) => {
38
+ handleLoopbackRequest(req, res, {
39
+ callbackPath,
40
+ expectedState,
41
+ isSettled: () => settled,
42
+ settle: (result, httpStatus) => {
43
+ if (settled) {
44
+ res.writeHead(409, htmlHeaders());
45
+ res.end(DUPLICATE_HTML);
46
+ return;
47
+ }
48
+ settled = true;
49
+ if (timer)
50
+ clearTimeout(timer);
51
+ res.writeHead(httpStatus, htmlHeaders());
52
+ res.end(httpStatus === 200 ? SUCCESS_HTML : FAILURE_HTML);
53
+ resolveDone(result);
54
+ },
55
+ });
56
+ });
57
+ await listenLoopback(server, options.port ?? 0);
58
+ const addr = server.address();
59
+ if (!addr || typeof addr === "string") {
60
+ server.close();
61
+ throw new Error("loopback server did not bind a TCP port");
62
+ }
63
+ const redirectUri = `http://127.0.0.1:${addr.port}${callbackPath}`;
64
+ timer = setTimeout(() => {
65
+ if (settled)
66
+ return;
67
+ settled = true;
68
+ resolveDone({ status: "timeout" });
69
+ }, timeoutMs);
70
+ timer.unref?.();
71
+ let closed = false;
72
+ return {
73
+ redirectUri,
74
+ port: addr.port,
75
+ wait: () => done,
76
+ close: async () => {
77
+ if (closed)
78
+ return;
79
+ closed = true;
80
+ clearTimeout(timer);
81
+ await closeServer(server);
82
+ },
83
+ };
84
+ }
85
+ function htmlHeaders() {
86
+ return {
87
+ "content-type": "text/html; charset=utf-8",
88
+ "cache-control": "no-store",
89
+ };
90
+ }
91
+ function handleLoopbackRequest(req, res, ctx) {
92
+ if (ctx.isSettled()) {
93
+ res.writeHead(409, htmlHeaders());
94
+ res.end(DUPLICATE_HTML);
95
+ return;
96
+ }
97
+ if (!isLoopbackHostHeader(req.headers.host)) {
98
+ res.writeHead(400, htmlHeaders());
99
+ res.end(FAILURE_HTML);
100
+ return;
101
+ }
102
+ if (req.method !== "GET") {
103
+ res.writeHead(405, htmlHeaders());
104
+ res.end(FAILURE_HTML);
105
+ return;
106
+ }
107
+ let parsed;
108
+ try {
109
+ parsed = new URL(req.url ?? "/", "http://127.0.0.1");
110
+ }
111
+ catch {
112
+ res.writeHead(400, htmlHeaders());
113
+ res.end(FAILURE_HTML);
114
+ return;
115
+ }
116
+ if (parsed.pathname !== ctx.callbackPath) {
117
+ res.writeHead(404, htmlHeaders());
118
+ res.end(FAILURE_HTML);
119
+ return;
120
+ }
121
+ const oauthError = parsed.searchParams.get("error");
122
+ if (oauthError) {
123
+ ctx.settle({
124
+ status: "oauth_error",
125
+ error: oauthError,
126
+ errorDescription: parsed.searchParams.get("error_description") ?? undefined,
127
+ }, 400);
128
+ return;
129
+ }
130
+ const state = parsed.searchParams.get("state") ?? "";
131
+ if (!statesEqual(state, ctx.expectedState)) {
132
+ ctx.settle({ status: "state_mismatch" }, 400);
133
+ return;
134
+ }
135
+ const code = parsed.searchParams.get("code")?.trim() ?? "";
136
+ if (!code) {
137
+ ctx.settle({ status: "missing_code" }, 400);
138
+ return;
139
+ }
140
+ ctx.settle({ status: "success", code, state }, 200);
141
+ }
142
+ function isLoopbackHostHeader(host) {
143
+ if (!host)
144
+ return false;
145
+ const hostname = host.split(":")[0]?.toLowerCase() ?? "";
146
+ return hostname === "127.0.0.1" || hostname === "localhost";
147
+ }
148
+ function statesEqual(actual, expected) {
149
+ const a = Buffer.from(actual);
150
+ const b = Buffer.from(expected);
151
+ if (a.length !== b.length)
152
+ return false;
153
+ return (0, node_crypto_1.timingSafeEqual)(a, b);
154
+ }
155
+ function listenLoopback(server, port) {
156
+ return new Promise((resolve, reject) => {
157
+ const onError = (err) => {
158
+ server.off("error", onError);
159
+ reject(err);
160
+ };
161
+ server.once("error", onError);
162
+ server.listen({ host: "127.0.0.1", port }, () => {
163
+ server.off("error", onError);
164
+ resolve();
165
+ });
166
+ });
167
+ }
168
+ function closeServer(server) {
169
+ return new Promise((resolve) => {
170
+ if (typeof server.closeAllConnections === "function") {
171
+ server.closeAllConnections();
172
+ }
173
+ server.close(() => resolve());
174
+ });
175
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Open a URL in the OS browser. Failure is explicit — callers must not
3
+ * continue as if the page was shown.
4
+ */
5
+ export type OpenBrowserResult = {
6
+ readonly ok: true;
7
+ } | {
8
+ readonly ok: false;
9
+ readonly reason: string;
10
+ };
11
+ export declare function systemBrowserCommand(url: string, platform?: NodeJS.Platform): {
12
+ readonly command: string;
13
+ readonly args: readonly string[];
14
+ };
15
+ export declare function openSystemBrowser(url: string): OpenBrowserResult;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ /**
3
+ * Open a URL in the OS browser. Failure is explicit — callers must not
4
+ * continue as if the page was shown.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.systemBrowserCommand = systemBrowserCommand;
8
+ exports.openSystemBrowser = openSystemBrowser;
9
+ const node_child_process_1 = require("node:child_process");
10
+ function systemBrowserCommand(url, platform = process.platform) {
11
+ if (platform === "darwin") {
12
+ return { command: "open", args: [url] };
13
+ }
14
+ if (platform === "win32") {
15
+ return { command: "cmd", args: ["/c", "start", "", url] };
16
+ }
17
+ return { command: "xdg-open", args: [url] };
18
+ }
19
+ function openSystemBrowser(url) {
20
+ let parsed;
21
+ try {
22
+ parsed = new URL(url);
23
+ }
24
+ catch {
25
+ return { ok: false, reason: "invalid_url" };
26
+ }
27
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
28
+ return { ok: false, reason: "unsupported_url_protocol" };
29
+ }
30
+ const { command, args } = systemBrowserCommand(url);
31
+ try {
32
+ const result = (0, node_child_process_1.spawnSync)(command, [...args], {
33
+ stdio: "ignore",
34
+ timeout: 15_000,
35
+ windowsHide: true,
36
+ });
37
+ if (result.error) {
38
+ return { ok: false, reason: result.error.message };
39
+ }
40
+ if (result.status !== 0 && result.status !== null) {
41
+ return { ok: false, reason: `exit_${result.status}` };
42
+ }
43
+ if (result.signal) {
44
+ return { ok: false, reason: `signal_${result.signal}` };
45
+ }
46
+ return { ok: true };
47
+ }
48
+ catch (err) {
49
+ return {
50
+ ok: false,
51
+ reason: err instanceof Error ? err.message : "spawn_failed",
52
+ };
53
+ }
54
+ }
@@ -1,19 +1,44 @@
1
1
  /**
2
- * Silent access-token renewal via refresh_token grant.
2
+ * Access-token renewal via refresh_token grant.
3
3
  * Access tokens are short-lived (~10m); refresh tokens last ~30d (web ADR).
4
+ *
5
+ * Outcomes are explicit: callers must not treat a failed refresh as a healthy session.
4
6
  */
5
7
  import type { ProfileConfig } from "../config/profiles";
6
8
  import { type StoredTokens } from "../keychain/store";
7
9
  /** Refresh when access token expires within this window. */
8
10
  export declare const ACCESS_TOKEN_REFRESH_SKEW_MS = 60000;
11
+ export type RefreshOutcome = {
12
+ readonly status: "refreshed";
13
+ readonly tokens: StoredTokens;
14
+ } | {
15
+ readonly status: "unchanged";
16
+ readonly tokens: StoredTokens;
17
+ } | {
18
+ readonly status: "failed";
19
+ readonly reason: string;
20
+ readonly tokens: StoredTokens | null;
21
+ } | {
22
+ readonly status: "no_session";
23
+ readonly reason: string;
24
+ readonly tokens: null;
25
+ };
9
26
  export declare function accessTokenNeedsRefresh(tokens: StoredTokens, now?: number): boolean;
10
27
  /**
11
28
  * Exchange refresh_token for a new AT/RT pair and persist to keychain.
12
- * Returns null if no tokens, no refresh token, or the AS rejects renewal.
29
+ * Returns a discriminated outcome never a bare success token on failure.
13
30
  */
14
31
  export declare function refreshStoredTokens(profile: ProfileConfig, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, options?: {
15
32
  readonly now?: number;
16
33
  readonly force?: boolean;
34
+ }): Promise<RefreshOutcome>;
35
+ /**
36
+ * Convenience for callers that only need tokens on successful refresh/unchanged.
37
+ * Returns null for no_session and failed — never pretends failure is success.
38
+ */
39
+ export declare function refreshStoredTokensOrNull(profile: ProfileConfig, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, options?: {
40
+ readonly now?: number;
41
+ readonly force?: boolean;
17
42
  }): Promise<StoredTokens | null>;
18
43
  /** Test helper: clear in-flight map between cases. */
19
44
  export declare function clearRefreshInflightForTests(): void;
@@ -1,13 +1,17 @@
1
1
  "use strict";
2
2
  /**
3
- * Silent access-token renewal via refresh_token grant.
3
+ * Access-token renewal via refresh_token grant.
4
4
  * Access tokens are short-lived (~10m); refresh tokens last ~30d (web ADR).
5
+ *
6
+ * Outcomes are explicit: callers must not treat a failed refresh as a healthy session.
5
7
  */
6
8
  Object.defineProperty(exports, "__esModule", { value: true });
7
9
  exports.ACCESS_TOKEN_REFRESH_SKEW_MS = void 0;
8
10
  exports.accessTokenNeedsRefresh = accessTokenNeedsRefresh;
9
11
  exports.refreshStoredTokens = refreshStoredTokens;
12
+ exports.refreshStoredTokensOrNull = refreshStoredTokensOrNull;
10
13
  exports.clearRefreshInflightForTests = clearRefreshInflightForTests;
14
+ const version_1 = require("../version");
11
15
  const store_1 = require("../keychain/store");
12
16
  /** Refresh when access token expires within this window. */
13
17
  exports.ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000;
@@ -21,16 +25,20 @@ function accessTokenNeedsRefresh(tokens, now = Date.now()) {
21
25
  }
22
26
  /**
23
27
  * Exchange refresh_token for a new AT/RT pair and persist to keychain.
24
- * Returns null if no tokens, no refresh token, or the AS rejects renewal.
28
+ * Returns a discriminated outcome never a bare success token on failure.
25
29
  */
26
30
  async function refreshStoredTokens(profile, env = process.env, fetchImpl = fetch, options = {}) {
27
31
  const existing = (0, store_1.loadTokens)(profile.name, env);
28
32
  if (!existing?.refreshToken?.trim()) {
29
- return null;
33
+ return {
34
+ status: "no_session",
35
+ reason: "no_refresh_token",
36
+ tokens: null,
37
+ };
30
38
  }
31
39
  if (!options.force &&
32
40
  !accessTokenNeedsRefresh(existing, options.now ?? Date.now())) {
33
- return existing;
41
+ return { status: "unchanged", tokens: existing };
34
42
  }
35
43
  const key = profile.name;
36
44
  const pending = inflightByProfile.get(key);
@@ -43,6 +51,17 @@ async function refreshStoredTokens(profile, env = process.env, fetchImpl = fetch
43
51
  inflightByProfile.set(key, work);
44
52
  return work;
45
53
  }
54
+ /**
55
+ * Convenience for callers that only need tokens on successful refresh/unchanged.
56
+ * Returns null for no_session and failed — never pretends failure is success.
57
+ */
58
+ async function refreshStoredTokensOrNull(profile, env = process.env, fetchImpl = fetch, options = {}) {
59
+ const outcome = await refreshStoredTokens(profile, env, fetchImpl, options);
60
+ if (outcome.status === "refreshed" || outcome.status === "unchanged") {
61
+ return outcome.tokens;
62
+ }
63
+ return null;
64
+ }
46
65
  async function performRefresh(profile, existing, env, fetchImpl) {
47
66
  const origin = profile.apiBaseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
48
67
  const url = `${origin}/api/auth/oauth/token`;
@@ -59,27 +78,43 @@ async function performRefresh(profile, existing, env, fetchImpl) {
59
78
  Accept: "application/json",
60
79
  "Content-Type": "application/json",
61
80
  "X-Alphafox-Client": "alphafox-cli",
62
- "X-Alphafox-Client-Version": env.ALPHAFOX_CLI_VERSION ?? "0.1.0",
81
+ "X-Alphafox-Client-Version": env.ALPHAFOX_CLI_VERSION ?? version_1.CLI_VERSION,
63
82
  },
64
83
  body: JSON.stringify(body),
65
84
  redirect: "manual",
66
85
  });
67
86
  }
68
- catch {
69
- return null;
87
+ catch (err) {
88
+ return {
89
+ status: "failed",
90
+ reason: err instanceof Error ? err.message : "network_error",
91
+ tokens: existing,
92
+ };
70
93
  }
71
94
  if (response.status >= 400) {
72
- return null;
95
+ return {
96
+ status: "failed",
97
+ reason: `http_${response.status}`,
98
+ tokens: existing,
99
+ };
73
100
  }
74
101
  let json;
75
102
  try {
76
103
  json = await response.json();
77
104
  }
78
105
  catch {
79
- return null;
106
+ return {
107
+ status: "failed",
108
+ reason: "invalid_json",
109
+ tokens: existing,
110
+ };
80
111
  }
81
112
  if (!json || typeof json !== "object") {
82
- return null;
113
+ return {
114
+ status: "failed",
115
+ reason: "invalid_body",
116
+ tokens: existing,
117
+ };
83
118
  }
84
119
  const o = json;
85
120
  const access = typeof o.access_token === "string"
@@ -93,16 +128,18 @@ async function performRefresh(profile, existing, env, fetchImpl) {
93
128
  ? o.refreshToken
94
129
  : null;
95
130
  if (!access || !refresh) {
96
- return null;
131
+ return {
132
+ status: "failed",
133
+ reason: "missing_tokens",
134
+ tokens: existing,
135
+ };
97
136
  }
98
137
  const expiresIn = typeof o.expires_in === "number"
99
138
  ? o.expires_in
100
139
  : typeof o.expiresIn === "number"
101
140
  ? o.expiresIn
102
141
  : 600;
103
- const scopeRaw = typeof o.scope === "string"
104
- ? o.scope
105
- : existing.scopes.join(" ");
142
+ const scopeRaw = typeof o.scope === "string" ? o.scope : existing.scopes.join(" ");
106
143
  const next = {
107
144
  accessToken: access,
108
145
  refreshToken: refresh,
@@ -114,7 +151,7 @@ async function performRefresh(profile, existing, env, fetchImpl) {
114
151
  scopes: scopeRaw.split(/\s+/).filter(Boolean),
115
152
  };
116
153
  (0, store_1.saveTokens)(profile.name, next, env);
117
- return next;
154
+ return { status: "refreshed", tokens: next };
118
155
  }
119
156
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
120
157
  async function fetchFollowingSameSiteRedirects(fetchImpl, startUrl, init, maxHops = 5) {