@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.
- package/README.md +42 -4
- package/dist/auth/browser-login.d.ts +30 -0
- package/dist/auth/browser-login.js +193 -0
- package/dist/auth/loopback-callback.d.ts +32 -0
- package/dist/auth/loopback-callback.js +175 -0
- package/dist/auth/open-browser.d.ts +15 -0
- package/dist/auth/open-browser.js +54 -0
- package/dist/auth/refresh.d.ts +27 -2
- package/dist/auth/refresh.js +52 -15
- package/dist/catalog/allowlist.d.ts +20 -4
- package/dist/catalog/allowlist.js +126 -25
- package/dist/catalog/command-tree.d.ts +35 -0
- package/dist/catalog/command-tree.js +119 -0
- package/dist/catalog/compatibility.d.ts +23 -0
- package/dist/catalog/compatibility.js +58 -0
- package/dist/catalog/generated/registry.json +6137 -0
- package/dist/catalog/generated/schemas.json +31036 -0
- package/dist/catalog/operations.d.ts +76 -3
- package/dist/catalog/operations.js +87 -213
- package/dist/catalog/validate-body.d.ts +22 -0
- package/dist/catalog/validate-body.js +98 -0
- package/dist/commands/request-body.d.ts +21 -0
- package/dist/commands/request-body.js +92 -0
- package/dist/commands/run.js +283 -162
- package/dist/config/profiles.js +3 -3
- package/dist/engine-backtest/errors.d.ts +18 -0
- package/dist/engine-backtest/errors.js +26 -0
- package/dist/engine-backtest/fetch-runtime.d.ts +38 -0
- package/dist/engine-backtest/fetch-runtime.js +170 -0
- package/dist/engine-backtest/native-import.d.ts +1 -0
- package/dist/engine-backtest/native-import.js +10 -0
- package/dist/engine-backtest/parse-args.d.ts +6 -0
- package/dist/engine-backtest/parse-args.js +281 -0
- package/dist/engine-backtest/persist.d.ts +32 -0
- package/dist/engine-backtest/persist.js +94 -0
- package/dist/engine-backtest/replay-timeframe.d.ts +16 -0
- package/dist/engine-backtest/replay-timeframe.js +48 -0
- package/dist/engine-backtest/resolve-packages.d.ts +27 -0
- package/dist/engine-backtest/resolve-packages.js +288 -0
- package/dist/engine-backtest/run-command.d.ts +38 -0
- package/dist/engine-backtest/run-command.js +540 -0
- package/dist/engine-backtest/types.d.ts +249 -0
- package/dist/engine-backtest/types.js +2 -0
- package/dist/envelope.d.ts +3 -0
- package/dist/envelope.js +43 -3
- package/dist/http/client.js +18 -15
- package/dist/index.d.ts +16 -5
- package/dist/index.js +47 -1
- package/dist/install/exec.d.ts +13 -0
- package/dist/install/exec.js +73 -0
- package/dist/install/package-root.d.ts +4 -0
- package/dist/install/package-root.js +59 -0
- package/dist/install/types.d.ts +69 -0
- package/dist/install/types.js +26 -0
- package/dist/install/wizard.d.ts +21 -0
- package/dist/install/wizard.js +332 -0
- package/dist/keychain/linux-secret-service.d.ts +11 -0
- package/dist/keychain/linux-secret-service.js +93 -0
- package/dist/keychain/store.d.ts +20 -1
- package/dist/keychain/store.js +94 -6
- package/dist/keychain/windows-credential.d.ts +13 -0
- package/dist/keychain/windows-credential.js +176 -0
- package/dist/safety/confirmation.d.ts +10 -3
- package/dist/safety/confirmation.js +27 -4
- package/dist/version.d.ts +2 -2
- package/dist/version.js +3 -2
- package/docs/.nojekyll +0 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +156 -0
- package/docs/agents/triage-labels.md +18 -0
- package/docs/alphafox-cli-installation-guide.md +105 -0
- package/docs/e2e-staging.md +76 -6
- package/docs/favicon.svg +4 -0
- package/docs/index.html +748 -0
- package/docs/logo/alphafox-mark.svg +4 -0
- package/docs/logo/alphafox-wordmark-black-en.svg +17 -0
- package/docs/logo/alphafox-wordmark-white-en.svg +16 -0
- package/docs/release-supply-chain.md +107 -26
- package/package.json +10 -3
- package/skills/account/SKILL.md +8 -6
- package/skills/admin/SKILL.md +9 -4
- package/skills/alphafox-shared/SKILL.md +44 -14
- package/skills/auth/SKILL.md +21 -6
- package/skills/engine-backtest/SKILL.md +71 -0
- package/skills/exchange/SKILL.md +10 -3
- package/skills/market/SKILL.md +9 -4
- package/skills/notification/SKILL.md +8 -3
- package/skills/strategy/SKILL.md +23 -9
- package/skills/trading/SKILL.md +18 -5
- package/vendor/backtest-runner/NOTICE.md +1 -0
- package/vendor/backtest-runner/README.md +97 -0
- package/vendor/backtest-runner/index.d.ts +423 -0
- package/vendor/backtest-runner/index.mjs +59 -0
- package/vendor/backtest-runner/lib/abortable.mjs +23 -0
- package/vendor/backtest-runner/lib/cache.mjs +159 -0
- package/vendor/backtest-runner/lib/coverage.mjs +238 -0
- package/vendor/backtest-runner/lib/encode.mjs +28 -0
- package/vendor/backtest-runner/lib/exchanges.mjs +143 -0
- package/vendor/backtest-runner/lib/proxy.mjs +78 -0
- package/vendor/backtest-runner/lib/scenario.mjs +66 -0
- package/vendor/backtest-runner/lib/series.mjs +370 -0
- package/vendor/backtest-runner/lib/tape-loader.mjs +614 -0
- package/vendor/backtest-runner/lib/timeframes.mjs +55 -0
- 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
|
-
|
|
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/`.
|
|
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
|
+
}
|
package/dist/auth/refresh.d.ts
CHANGED
|
@@ -1,19 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
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;
|