@alphafox/cli 0.1.5 → 0.3.2

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 (104) hide show
  1. package/README.md +42 -4
  2. package/dist/auth/browser-login.d.ts +30 -0
  3. package/dist/auth/browser-login.js +193 -0
  4. package/dist/auth/loopback-callback.d.ts +32 -0
  5. package/dist/auth/loopback-callback.js +175 -0
  6. package/dist/auth/open-browser.d.ts +15 -0
  7. package/dist/auth/open-browser.js +54 -0
  8. package/dist/auth/refresh.d.ts +27 -2
  9. package/dist/auth/refresh.js +52 -15
  10. package/dist/catalog/allowlist.d.ts +20 -4
  11. package/dist/catalog/allowlist.js +126 -25
  12. package/dist/catalog/command-tree.d.ts +35 -0
  13. package/dist/catalog/command-tree.js +119 -0
  14. package/dist/catalog/compatibility.d.ts +23 -0
  15. package/dist/catalog/compatibility.js +58 -0
  16. package/dist/catalog/generated/registry.json +6137 -0
  17. package/dist/catalog/generated/schemas.json +31036 -0
  18. package/dist/catalog/operations.d.ts +76 -3
  19. package/dist/catalog/operations.js +87 -213
  20. package/dist/catalog/validate-body.d.ts +22 -0
  21. package/dist/catalog/validate-body.js +98 -0
  22. package/dist/commands/request-body.d.ts +21 -0
  23. package/dist/commands/request-body.js +92 -0
  24. package/dist/commands/run.js +283 -162
  25. package/dist/config/profiles.js +3 -3
  26. package/dist/engine-backtest/errors.d.ts +18 -0
  27. package/dist/engine-backtest/errors.js +26 -0
  28. package/dist/engine-backtest/fetch-runtime.d.ts +38 -0
  29. package/dist/engine-backtest/fetch-runtime.js +170 -0
  30. package/dist/engine-backtest/native-import.d.ts +1 -0
  31. package/dist/engine-backtest/native-import.js +10 -0
  32. package/dist/engine-backtest/parse-args.d.ts +6 -0
  33. package/dist/engine-backtest/parse-args.js +281 -0
  34. package/dist/engine-backtest/persist.d.ts +32 -0
  35. package/dist/engine-backtest/persist.js +94 -0
  36. package/dist/engine-backtest/replay-timeframe.d.ts +16 -0
  37. package/dist/engine-backtest/replay-timeframe.js +48 -0
  38. package/dist/engine-backtest/resolve-packages.d.ts +27 -0
  39. package/dist/engine-backtest/resolve-packages.js +288 -0
  40. package/dist/engine-backtest/run-command.d.ts +38 -0
  41. package/dist/engine-backtest/run-command.js +540 -0
  42. package/dist/engine-backtest/types.d.ts +249 -0
  43. package/dist/engine-backtest/types.js +2 -0
  44. package/dist/envelope.d.ts +3 -0
  45. package/dist/envelope.js +43 -3
  46. package/dist/http/client.js +18 -15
  47. package/dist/index.d.ts +16 -5
  48. package/dist/index.js +47 -1
  49. package/dist/install/exec.d.ts +13 -0
  50. package/dist/install/exec.js +73 -0
  51. package/dist/install/package-root.d.ts +4 -0
  52. package/dist/install/package-root.js +59 -0
  53. package/dist/install/types.d.ts +69 -0
  54. package/dist/install/types.js +26 -0
  55. package/dist/install/wizard.d.ts +21 -0
  56. package/dist/install/wizard.js +332 -0
  57. package/dist/keychain/linux-secret-service.d.ts +11 -0
  58. package/dist/keychain/linux-secret-service.js +93 -0
  59. package/dist/keychain/store.d.ts +20 -1
  60. package/dist/keychain/store.js +94 -6
  61. package/dist/keychain/windows-credential.d.ts +13 -0
  62. package/dist/keychain/windows-credential.js +176 -0
  63. package/dist/safety/confirmation.d.ts +10 -3
  64. package/dist/safety/confirmation.js +27 -4
  65. package/dist/version.d.ts +2 -2
  66. package/dist/version.js +3 -2
  67. package/docs/.nojekyll +0 -0
  68. package/docs/agents/domain.md +51 -0
  69. package/docs/agents/issue-tracker.md +156 -0
  70. package/docs/agents/triage-labels.md +18 -0
  71. package/docs/alphafox-cli-installation-guide.md +105 -0
  72. package/docs/e2e-staging.md +76 -6
  73. package/docs/favicon.svg +4 -0
  74. package/docs/index.html +748 -0
  75. package/docs/logo/alphafox-mark.svg +4 -0
  76. package/docs/logo/alphafox-wordmark-black-en.svg +17 -0
  77. package/docs/logo/alphafox-wordmark-white-en.svg +16 -0
  78. package/docs/release-supply-chain.md +107 -26
  79. package/package.json +10 -3
  80. package/skills/account/SKILL.md +8 -6
  81. package/skills/admin/SKILL.md +9 -4
  82. package/skills/alphafox-shared/SKILL.md +44 -14
  83. package/skills/auth/SKILL.md +21 -6
  84. package/skills/engine-backtest/SKILL.md +71 -0
  85. package/skills/exchange/SKILL.md +10 -3
  86. package/skills/market/SKILL.md +9 -4
  87. package/skills/notification/SKILL.md +8 -3
  88. package/skills/strategy/SKILL.md +23 -9
  89. package/skills/trading/SKILL.md +18 -5
  90. package/vendor/backtest-runner/NOTICE.md +1 -0
  91. package/vendor/backtest-runner/README.md +97 -0
  92. package/vendor/backtest-runner/index.d.ts +423 -0
  93. package/vendor/backtest-runner/index.mjs +59 -0
  94. package/vendor/backtest-runner/lib/abortable.mjs +23 -0
  95. package/vendor/backtest-runner/lib/cache.mjs +159 -0
  96. package/vendor/backtest-runner/lib/coverage.mjs +238 -0
  97. package/vendor/backtest-runner/lib/encode.mjs +28 -0
  98. package/vendor/backtest-runner/lib/exchanges.mjs +143 -0
  99. package/vendor/backtest-runner/lib/proxy.mjs +78 -0
  100. package/vendor/backtest-runner/lib/scenario.mjs +66 -0
  101. package/vendor/backtest-runner/lib/series.mjs +370 -0
  102. package/vendor/backtest-runner/lib/tape-loader.mjs +614 -0
  103. package/vendor/backtest-runner/lib/timeframes.mjs +55 -0
  104. package/vendor/backtest-runner/package.json +13 -0
package/README.md CHANGED
@@ -2,16 +2,43 @@
2
2
 
3
3
  Alphafox CLI (`alphafox`) — Agent and human entry for the versioned Public Application API on alphafox-web.
4
4
 
5
+ Homepage: https://alphafoxai.github.io/alphafox-cli/
6
+
5
7
  ## Install
6
8
 
9
+ Pick **one** of the following. Both install the CLI **and** register Agent
10
+ Skills. `npm install -g @alphafox/cli` alone does **not** install Skills.
11
+
12
+ ### Method 1 — Manual wizard
13
+
7
14
  ```bash
8
- npm install -g @alphafox/cli
9
- # or
10
- npx alphafox-cli version
15
+ npx @alphafox/cli@latest install
11
16
  ```
12
17
 
18
+ This installs `@alphafox/cli` globally, runs `npx skills add` into detected
19
+ agents (Cursor, Claude Code, Codex, …), and may prompt for
20
+ `alphafox auth login --browser`. **Restart the AI tool** afterwards so Skills
21
+ load.
22
+
23
+ ### Method 2 — Install via Agent
24
+
25
+ Copy and send this to Cursor / Claude Code / Codex / Trae:
26
+
27
+ ```
28
+ 帮我安装 Alphafox CLI:https://github.com/alphafoxai/alphafox-cli/blob/main/docs/alphafox-cli-installation-guide.md
29
+ ```
30
+
31
+ ```
32
+ Help me install Alphafox CLI: https://github.com/alphafoxai/alphafox-cli/blob/main/docs/alphafox-cli-installation-guide.md
33
+ ```
34
+
35
+ The Agent follows that guide (`npm install -g`, `npx skills add`, login,
36
+ `doctor`). **Restart the AI tool** when it finishes.
37
+
13
38
  ## Quick start
14
39
 
40
+ After Method 1 or 2:
41
+
15
42
  ```bash
16
43
  alphafox version
17
44
  alphafox doctor
@@ -28,14 +55,25 @@ alphafox api GET /api/v1/me
28
55
  - Profiles: `production` (default), `staging`, `local` — isolated issuer/audience/client (ADR 0003).
29
56
  - High-risk writes require `--yes`. Automation tokens are **deferred** (ADR 0004).
30
57
  - Raw `api` only hits allowlisted `/api/v1/*` facade paths.
58
+ - Writes validate `--body` / `--config @file` against the catalog schema
59
+ before HTTP. Agents must `alphafox schema <operationId>` first and must
60
+ not invent fields. Large objects use `--config @file`, not argv.
31
61
 
32
62
  ## Skills
33
63
 
34
- Co-versioned Agent Skills live under `skills/`. They route intent to public `operationId`s only.
64
+ Co-versioned Agent Skills live under `skills/`. API-oriented skills route public
65
+ `operationId`s; an explicit local-execution skill may call its co-versioned
66
+ built-in command.
67
+
68
+ `alphafox install` (and the [Agent install guide](docs/alphafox-cli-installation-guide.md))
69
+ run `npx skills add` so those files land in Agent skill directories
70
+ (`.cursor/skills`, `.claude/skills`, `~/.agents/skills`, …).
35
71
 
36
72
  ## Docs
37
73
 
74
+ - [Homepage](https://alphafoxai.github.io/alphafox-cli/)
38
75
  - [Release / supply chain](docs/release-supply-chain.md)
76
+ - [Agent install guide](docs/alphafox-cli-installation-guide.md)
39
77
  - [Staging E2E checklist](docs/e2e-staging.md)
40
78
  - Related ADRs and parity matrix live in the alphafox-web repository
41
79
 
@@ -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;