@aisa-one/cli 0.5.1 → 0.6.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.
- package/CHANGELOG.md +41 -1
- package/README.md +33 -8
- package/dist/api.d.ts +2 -2
- package/dist/api.js +7 -6
- package/dist/commands/account.js +3 -3
- package/dist/commands/auth.d.ts +2 -2
- package/dist/commands/auth.js +14 -21
- package/dist/commands/chat.js +2 -2
- package/dist/commands/configCmd.js +2 -0
- package/dist/commands/connect.js +14 -14
- package/dist/commands/flow.js +4 -4
- package/dist/commands/mcp.js +2 -2
- package/dist/commands/models.js +3 -3
- package/dist/commands/oauth-login.d.ts +4 -2
- package/dist/commands/oauth-login.js +29 -31
- package/dist/commands/signin-page.js +5 -6
- package/dist/commands/tools.js +6 -6
- package/dist/config.d.ts +20 -7
- package/dist/config.js +312 -49
- package/dist/constants.d.ts +4 -1
- package/dist/constants.js +4 -1
- package/dist/index.js +6 -4
- package/dist/router.d.ts +3 -3
- package/dist/router.js +6 -6
- package/dist/utils/auth-http.d.ts +3 -0
- package/dist/utils/auth-http.js +18 -0
- package/package.json +3 -1
|
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { error, hint, info, success } from "../utils/display.js";
|
|
7
|
-
import {
|
|
7
|
+
import { replaceTokens, tokenExpiresAt } from "../config.js";
|
|
8
8
|
import { maskKey } from "../config.js";
|
|
9
9
|
import { httpFetch } from "../utils/http.js";
|
|
10
10
|
import { canOpenBrowser } from "../utils/browser.js";
|
|
@@ -13,16 +13,14 @@ import { renderSignInPage } from "./signin-page.js";
|
|
|
13
13
|
import { handOverSignInPage, SIGNIN_PAGE_TTL_MS } from "./serve-signin.js";
|
|
14
14
|
/**
|
|
15
15
|
* `aisa login` without a key: sign in once in a browser, come back with the
|
|
16
|
-
* CLI's
|
|
16
|
+
* CLI's access and refresh tokens.
|
|
17
17
|
*
|
|
18
18
|
* The flow is the standard one every CLI converges on (gh, flyctl, claude):
|
|
19
19
|
*
|
|
20
20
|
* 1. register a public OAuth client (Clerk supports dynamic registration)
|
|
21
21
|
* 2. authorization-code + PKCE, redirecting to a loopback port
|
|
22
22
|
* 3. exchange the code for an access token
|
|
23
|
-
* 4.
|
|
24
|
-
* and store the key — the token itself is then dropped. One secret on
|
|
25
|
-
* disk, and it is the one that does not expire in a day.
|
|
23
|
+
* 4. store access/refresh tokens and the registered client ID for silent refresh.
|
|
26
24
|
*
|
|
27
25
|
* A machine with no browser of its own takes the paste-back variant, and it
|
|
28
26
|
* is chosen for the user rather than asked for: the URL is printed, the user
|
|
@@ -32,7 +30,7 @@ import { handOverSignInPage, SIGNIN_PAGE_TTL_MS } from "./serve-signin.js";
|
|
|
32
30
|
* the verifier never left this machine. What travels through the clipboard
|
|
33
31
|
* is a one-time code, not a key.
|
|
34
32
|
*/
|
|
35
|
-
|
|
33
|
+
import { AUTH_SERVER } from "../constants.js";
|
|
36
34
|
/**
|
|
37
35
|
* Where the sign-in lands when the browser is on a different machine.
|
|
38
36
|
*
|
|
@@ -46,7 +44,6 @@ const AUTH_SERVER = "https://clerk.aisa.one";
|
|
|
46
44
|
* have to be added alongside.
|
|
47
45
|
*/
|
|
48
46
|
const HOSTED_REDIRECT = "https://aisa.one/cli/auth";
|
|
49
|
-
const MINT_URL = "https://api.aisa.one/v1/keys/mint";
|
|
50
47
|
const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
51
48
|
async function registerClient(redirectUri) {
|
|
52
49
|
const res = await httpFetch(`${AUTH_SERVER}/oauth/register`, {
|
|
@@ -55,10 +52,10 @@ async function registerClient(redirectUri) {
|
|
|
55
52
|
body: JSON.stringify({
|
|
56
53
|
client_name: "AIsa CLI",
|
|
57
54
|
redirect_uris: [redirectUri],
|
|
58
|
-
grant_types: ["authorization_code"],
|
|
55
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
59
56
|
response_types: ["code"],
|
|
60
57
|
token_endpoint_auth_method: "none",
|
|
61
|
-
scope: "openid profile email",
|
|
58
|
+
scope: "openid profile email offline_access",
|
|
62
59
|
}),
|
|
63
60
|
signal: AbortSignal.timeout(15_000),
|
|
64
61
|
});
|
|
@@ -227,7 +224,7 @@ function openBrowser(url) {
|
|
|
227
224
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
228
225
|
void run(cmd, [url], { timeout: 30_000 }).catch(() => { });
|
|
229
226
|
}
|
|
230
|
-
export async function
|
|
227
|
+
export async function signInAndStoreTokens(options = {}) {
|
|
231
228
|
const lang = options.lang ?? "en";
|
|
232
229
|
// Unset means "work it out": a server has no browser to open and waiting
|
|
233
230
|
// for a click on a machine with no screen is the worst way to find out.
|
|
@@ -253,7 +250,7 @@ export async function mintCliKey(options = {}) {
|
|
|
253
250
|
response_type: "code",
|
|
254
251
|
client_id: clientId,
|
|
255
252
|
redirect_uri: redirectUri,
|
|
256
|
-
scope: "openid profile email",
|
|
253
|
+
scope: "openid profile email offline_access",
|
|
257
254
|
state,
|
|
258
255
|
code_challenge: challenge,
|
|
259
256
|
code_challenge_method: "S256",
|
|
@@ -297,27 +294,28 @@ export async function mintCliKey(options = {}) {
|
|
|
297
294
|
signal: AbortSignal.timeout(20_000),
|
|
298
295
|
});
|
|
299
296
|
const tokens = (await tokenRes.json());
|
|
300
|
-
if (!tokens.access_token) {
|
|
297
|
+
if (!tokenRes.ok || !tokens.access_token) {
|
|
301
298
|
throw new Error(`token exchange failed: ${tokens.error_description ?? tokens.error ?? tokenRes.status}`);
|
|
302
299
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const mintRes = await httpFetch(MINT_URL, {
|
|
306
|
-
method: "POST",
|
|
307
|
-
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
308
|
-
signal: AbortSignal.timeout(20_000),
|
|
309
|
-
});
|
|
310
|
-
if (mintRes.status === 404) {
|
|
311
|
-
// A deployment without the mint endpoint. The sign-in still proved the
|
|
312
|
-
// account works; the key just has to travel by hand once.
|
|
313
|
-
throw new Error("this deployment cannot issue CLI keys — copy one from https://console.aisa.one/api-keys and run: aisa login --key <key>");
|
|
300
|
+
if (typeof tokens.refresh_token !== "string" || !tokens.refresh_token.trim()) {
|
|
301
|
+
throw new Error("token exchange did not return a refresh token. Existing credentials were kept; retry sign-in with offline access enabled.");
|
|
314
302
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
return
|
|
303
|
+
await replaceTokens(tokens.access_token, tokens.refresh_token, tokenExpiresAt(tokens.expires_in), clientId);
|
|
304
|
+
return tokens.access_token;
|
|
305
|
+
}
|
|
306
|
+
/** Commander after-help for `aisa login`. Mechanics only; runtime is unchanged. */
|
|
307
|
+
export function loginHelpAfter() {
|
|
308
|
+
return `
|
|
309
|
+
Default: open a browser this person can actually use (SSH/CI/no display skip the local open). The live authorize URL is printed at the same time — relay that exact URL immediately. Do not invent, shorten, or delay it.
|
|
310
|
+
|
|
311
|
+
--no-browser needs a persistent interactive TTY. Agents request only the one-time redirect URL or code — never an API key, access token, or refresh token. Paste that result into this same living process once. Reuse a running login; do not start another. "Done" is not that input. If they cannot type here, relay the paste through chat into this PTY. An empty line cancels.
|
|
312
|
+
|
|
313
|
+
If this process already timed out, or the URL/code is from an older run, start a fresh aisa login and use the new URL and its result. Do not reuse a stale paste.
|
|
314
|
+
|
|
315
|
+
No browser and no TTY: this CLI cannot complete OAuth here. Normal setup is native MCP OAuth at https://tools.aisa.one/mcp.
|
|
316
|
+
|
|
317
|
+
Do not report connected unless login stored credentials and the following balance check succeeded. aisa whoami and stored local credentials are not proof. Scripts/CI: AISA_API_KEY or --key.
|
|
318
|
+
`;
|
|
321
319
|
}
|
|
322
320
|
export async function oauthLogin(options = {}) {
|
|
323
321
|
if (options.open === false && !process.stdin.isTTY) {
|
|
@@ -326,8 +324,8 @@ export async function oauthLogin(options = {}) {
|
|
|
326
324
|
process.exitCode = 1;
|
|
327
325
|
return;
|
|
328
326
|
}
|
|
329
|
-
const key = await
|
|
327
|
+
const key = await signInAndStoreTokens(options);
|
|
330
328
|
// The balance follows from the caller, so no "try aisa balance" here: being
|
|
331
329
|
// told to go and check is worse than being shown.
|
|
332
|
-
success(`Signed in —
|
|
330
|
+
success(`Signed in — access token ${maskKey(key)} stored`);
|
|
333
331
|
}
|
|
@@ -53,14 +53,14 @@ const ENDING_MARKUP = [
|
|
|
53
53
|
" function tick() {",
|
|
54
54
|
" var left = Math.ceil((until - Date.now()) / 1000);",
|
|
55
55
|
" if (left <= 0) {",
|
|
56
|
-
' show("This page has closed", "Nothing here is needed any more \u2014 your
|
|
56
|
+
' show("This page has closed", "Nothing here is needed any more \u2014 return to your terminal to check the sign-in result.");',
|
|
57
57
|
" return;",
|
|
58
58
|
" }",
|
|
59
59
|
// Silent until the last minute, as before: a countdown running for five
|
|
60
60
|
// minutes is furniture. What changed is that it ends in a dialog rather
|
|
61
61
|
// than in a line nobody was looking at.
|
|
62
62
|
" if (left > 60) { setTimeout(tick, 1000); return; }",
|
|
63
|
-
' show("This page closes in " + left + " seconds", "You can close it now \u2014 your
|
|
63
|
+
' show("This page closes in " + left + " seconds", "You can close it now \u2014 check your terminal for the sign-in result.");',
|
|
64
64
|
" setTimeout(tick, 1000);",
|
|
65
65
|
" }",
|
|
66
66
|
" tick();",
|
|
@@ -69,9 +69,8 @@ const ENDING_MARKUP = [
|
|
|
69
69
|
].join("\n");
|
|
70
70
|
const COPY = {
|
|
71
71
|
ok: {
|
|
72
|
-
kicker: "
|
|
73
|
-
title: "
|
|
74
|
-
body: "Your key was created on the machine you started from — it never travelled through this browser. You can close this tab.",
|
|
72
|
+
kicker: "AUTHORIZED",
|
|
73
|
+
title: "Return to your terminal",
|
|
75
74
|
/**
|
|
76
75
|
* The one moment a mention of the console is welcome rather than in the
|
|
77
76
|
* way — and a sentence, not a button.
|
|
@@ -171,7 +170,7 @@ export function renderSignInPage(outcome, closesAt) {
|
|
|
171
170
|
<div class="wrap">
|
|
172
171
|
<div class="tick"><span>${good ? "✓" : "!"}</span><b>${c.kicker}</b></div>
|
|
173
172
|
<h1>${c.title}</h1>
|
|
174
|
-
|
|
173
|
+
${c.body ? `<p>${c.body}</p>` : ""}
|
|
175
174
|
${c.cta
|
|
176
175
|
? `<p class="cta">${c.cta.before}<a href="${c.cta.href}" rel="noopener">${c.cta.domain}</a>${c.cta.after}</p>`
|
|
177
176
|
: ""}
|
package/dist/commands/tools.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import ora from "ora";
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
-
import {
|
|
3
|
+
import { getAccessToken, MISSING_API_KEY_GUIDANCE } from "../config.js";
|
|
4
4
|
import { CliError, EXIT_PARTIAL, EXIT_TRANSPORT, transportError } from "../cli-error.js";
|
|
5
5
|
import { routerPost } from "../router.js";
|
|
6
6
|
import { error as printError } from "../utils/display.js";
|
|
@@ -22,14 +22,14 @@ export async function callAction(options) {
|
|
|
22
22
|
}
|
|
23
23
|
async function runToolCommand(kind, operands, options, auth) {
|
|
24
24
|
const prepared = await prepareRouterRequest(kind, operands, options);
|
|
25
|
-
const
|
|
25
|
+
const accessToken = auth.auth === "required" ? await requireRouterKey(kind) : await getAccessToken();
|
|
26
26
|
const spinner = options.json ? undefined : ora(spinnerText(kind)).start();
|
|
27
27
|
let result;
|
|
28
28
|
try {
|
|
29
29
|
result = await routerPost({
|
|
30
30
|
operation: kind,
|
|
31
31
|
body: prepared.body,
|
|
32
|
-
|
|
32
|
+
accessToken,
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
catch (err) {
|
|
@@ -57,11 +57,11 @@ async function runToolCommand(kind, operands, options, auth) {
|
|
|
57
57
|
process.exitCode = EXIT_PARTIAL;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
function requireRouterKey(kind) {
|
|
61
|
-
const key =
|
|
60
|
+
async function requireRouterKey(kind) {
|
|
61
|
+
const key = await getAccessToken();
|
|
62
62
|
if (!key) {
|
|
63
63
|
throw new CliError(`${MISSING_API_KEY_GUIDANCE} ` +
|
|
64
|
-
`search and schema may be anonymous; ${kind} will not run without
|
|
64
|
+
`search and schema may be anonymous; ${kind} will not run without authentication. ` +
|
|
65
65
|
`Do not invent a business result.`, EXIT_TRANSPORT);
|
|
66
66
|
}
|
|
67
67
|
return key;
|
package/dist/config.d.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
export interface StoredTokens {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
refreshToken?: string;
|
|
4
|
+
/** Unix timestamp in milliseconds. */
|
|
5
|
+
expiresAt?: number;
|
|
6
|
+
clientId?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Next step when no local credential is present. */
|
|
9
|
+
export { AUTH_SETUP_GUIDANCE, MISSING_API_KEY_GUIDANCE } from "./constants.js";
|
|
10
|
+
export declare function getAccessToken(): Promise<string | undefined>;
|
|
11
|
+
export declare function requireAccessToken(): Promise<string>;
|
|
12
|
+
/** Low-level storage; interactive login must use replaceTokens to retire the previous grant. */
|
|
13
|
+
export declare function storeTokens(accessToken: string, refreshToken?: string, expiresAt?: number, clientId?: string): Promise<void>;
|
|
14
|
+
/** All reads, rotation and persistence are serialized across CLI processes. */
|
|
15
|
+
export declare function refreshAccessToken(accessToken?: string): Promise<string | undefined>;
|
|
16
|
+
export declare function tokenExpiresAt(expiresIn?: number): number | undefined;
|
|
17
|
+
/** Replace a login only after revoking the previous OAuth grant. */
|
|
18
|
+
export declare function replaceTokens(accessToken: string, refreshToken?: string, expiresAt?: number, clientId?: string): Promise<void>;
|
|
19
|
+
export declare function revokeAndClearTokens(): Promise<boolean>;
|
|
20
|
+
export declare function clearTokens(): Promise<void>;
|
|
8
21
|
export declare function getKeySource(): "env" | "config" | "none";
|
|
9
22
|
export declare function getConfig(key: string): unknown;
|
|
10
23
|
export declare function setConfig(key: string, value: string): void;
|
package/dist/config.js
CHANGED
|
@@ -1,27 +1,102 @@
|
|
|
1
1
|
import Conf from "conf";
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
//
|
|
5
|
+
import lockfile from "proper-lockfile";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { httpFetch } from "./utils/http.js";
|
|
8
|
+
import { AUTH_SERVER, MISSING_API_KEY_GUIDANCE, ENV_VAR_NAME } from "./constants.js";
|
|
9
|
+
// tokens.json is authoritative; conf is a write-only compatibility mirror.
|
|
10
10
|
const aisaDir = () => join(homedir(), ".aisa");
|
|
11
11
|
const keyFile = () => join(aisaDir(), "key");
|
|
12
|
-
|
|
12
|
+
const tokenFile = () => join(aisaDir(), "tokens.json");
|
|
13
|
+
// Only call under withTokenLock, except the read-only source indicator.
|
|
14
|
+
function readTokenFile() {
|
|
13
15
|
try {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
+
const tokens = JSON.parse(readFileSync(tokenFile(), "utf-8"));
|
|
17
|
+
if (typeof tokens?.accessToken !== "string" || !tokens.accessToken)
|
|
18
|
+
throw new Error("Invalid token file. Restore it before changing credentials.");
|
|
19
|
+
return tokens;
|
|
16
20
|
}
|
|
17
|
-
catch {
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return undefined;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function readLegacyKey() {
|
|
28
|
+
try {
|
|
29
|
+
return readFileSync(keyFile(), "utf-8").trim() || undefined;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code === "ENOENT")
|
|
33
|
+
return undefined;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function readTokens() {
|
|
38
|
+
const tokens = readTokenFile();
|
|
39
|
+
if (tokens)
|
|
40
|
+
return tokens;
|
|
41
|
+
const accessToken = readLegacyKey();
|
|
42
|
+
if (!accessToken)
|
|
18
43
|
return undefined;
|
|
44
|
+
const migrated = { accessToken };
|
|
45
|
+
writeTokens(migrated);
|
|
46
|
+
return migrated;
|
|
47
|
+
}
|
|
48
|
+
async function withTokenLock(work) {
|
|
49
|
+
mkdirSync(aisaDir(), { recursive: true });
|
|
50
|
+
let compromised;
|
|
51
|
+
const release = await lockfile.lock(tokenFile(), {
|
|
52
|
+
realpath: false,
|
|
53
|
+
stale: 30_000,
|
|
54
|
+
update: 1_000,
|
|
55
|
+
retries: { retries: 400, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
|
56
|
+
onCompromised: (error) => { compromised = error; },
|
|
57
|
+
});
|
|
58
|
+
// Fail closed before any writes if a suspended process loses its lease.
|
|
59
|
+
const previousGuard = assertTokenLock;
|
|
60
|
+
assertTokenLock = () => { if (compromised)
|
|
61
|
+
throw compromised; };
|
|
62
|
+
try {
|
|
63
|
+
return await work();
|
|
19
64
|
}
|
|
65
|
+
finally {
|
|
66
|
+
assertTokenLock = previousGuard;
|
|
67
|
+
await release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let assertTokenLock = () => { };
|
|
71
|
+
const rotationFile = () => join(aisaDir(), ".token-rotation.json");
|
|
72
|
+
const pendingFile = () => join(aisaDir(), ".pending-tokens.json");
|
|
73
|
+
const tokenHash = (token) => createHash("sha256").update(token).digest("hex");
|
|
74
|
+
function writeJson(path, value) {
|
|
75
|
+
assertTokenLock();
|
|
76
|
+
writeIndependentJson(path, value);
|
|
20
77
|
}
|
|
21
|
-
|
|
78
|
+
// Unique recovery files do not share a name and can be created without the main lock.
|
|
79
|
+
function writeIndependentJson(path, value) {
|
|
22
80
|
mkdirSync(aisaDir(), { recursive: true });
|
|
23
|
-
|
|
24
|
-
|
|
81
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
82
|
+
try {
|
|
83
|
+
writeFileSync(temporary, JSON.stringify(value) + "\n", { mode: 0o600 });
|
|
84
|
+
renameSync(temporary, path);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
if (existsSync(temporary))
|
|
88
|
+
unlinkSync(temporary);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function removeFile(path) {
|
|
92
|
+
assertTokenLock();
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(path);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error.code !== "ENOENT")
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
25
100
|
}
|
|
26
101
|
/**
|
|
27
102
|
* 0600, because this file holds credentials.
|
|
@@ -41,6 +116,7 @@ const config = new Conf({
|
|
|
41
116
|
projectName: "aisa-cli",
|
|
42
117
|
configFileMode: CONFIG_FILE_MODE,
|
|
43
118
|
schema: {
|
|
119
|
+
tokens: { type: "object", default: {} },
|
|
44
120
|
apiKey: { type: "string", default: "" },
|
|
45
121
|
defaultModel: { type: "string", default: "gpt-4.1-mini" },
|
|
46
122
|
baseUrl: { type: "string", default: "https://api.aisa.one/v1" },
|
|
@@ -63,58 +139,245 @@ try {
|
|
|
63
139
|
catch {
|
|
64
140
|
/* not ours to chmod, or gone — neither is worth failing a command over */
|
|
65
141
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
writeKeyFile(stored);
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
/* migration is best-effort; the key itself is still returned */
|
|
142
|
+
/** Next step when no local credential is present. */
|
|
143
|
+
export { AUTH_SETUP_GUIDANCE, MISSING_API_KEY_GUIDANCE } from "./constants.js";
|
|
144
|
+
export async function getAccessToken() {
|
|
145
|
+
if (process.env[ENV_VAR_NAME])
|
|
146
|
+
return process.env[ENV_VAR_NAME];
|
|
147
|
+
return withTokenLock(async () => {
|
|
148
|
+
const tokens = readTokens();
|
|
149
|
+
if (!tokens)
|
|
150
|
+
return undefined;
|
|
151
|
+
if (canRefresh(tokens) && typeof tokens.expiresAt === "number" && tokens.expiresAt <= Date.now() + 60_000) {
|
|
152
|
+
return (await refreshLocked(tokens)) ?? tokens.accessToken;
|
|
82
153
|
}
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
return undefined;
|
|
154
|
+
return tokens.accessToken;
|
|
155
|
+
});
|
|
86
156
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
export function requireApiKey() {
|
|
91
|
-
const key = getApiKey();
|
|
92
|
-
if (!key) {
|
|
157
|
+
export async function requireAccessToken() {
|
|
158
|
+
const token = await getAccessToken();
|
|
159
|
+
if (!token) {
|
|
93
160
|
console.error(MISSING_API_KEY_GUIDANCE);
|
|
94
161
|
process.exit(1);
|
|
95
162
|
}
|
|
96
|
-
return
|
|
163
|
+
return token;
|
|
97
164
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
config.set("apiKey", key);
|
|
165
|
+
function makeTokens(accessToken, refreshToken, expiresAt, clientId) {
|
|
166
|
+
return accessToken.startsWith("sk-") ? { accessToken } : { accessToken, refreshToken, expiresAt, clientId };
|
|
101
167
|
}
|
|
102
|
-
|
|
168
|
+
/** Low-level storage; interactive login must use replaceTokens to retire the previous grant. */
|
|
169
|
+
export async function storeTokens(accessToken, refreshToken, expiresAt, clientId) {
|
|
170
|
+
await withTokenLock(() => writeTokens(makeTokens(accessToken, refreshToken, expiresAt, clientId)));
|
|
171
|
+
}
|
|
172
|
+
function writeTokens(tokens, previousAccessToken) {
|
|
173
|
+
assertTokenLock();
|
|
174
|
+
// The rotated credential is authoritative. Auxiliary metadata must never
|
|
175
|
+
// prevent its persistence or turn a successful refresh into an old-token fallback.
|
|
176
|
+
writeJson(tokenFile(), tokens);
|
|
177
|
+
try {
|
|
178
|
+
if (previousAccessToken) {
|
|
179
|
+
writeJson(rotationFile(), { previous: tokenHash(previousAccessToken), current: tokenHash(tokens.accessToken) });
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
removeFile(rotationFile());
|
|
183
|
+
}
|
|
184
|
+
catch { /* rotation hints are optional; their hashes are checked before reuse */ }
|
|
103
185
|
try {
|
|
104
|
-
|
|
105
|
-
|
|
186
|
+
config.set("tokens", tokens);
|
|
187
|
+
config.set("apiKey", tokens.accessToken);
|
|
188
|
+
writeFileSync(keyFile(), tokens.accessToken + "\n", { mode: 0o600 });
|
|
189
|
+
chmodSync(keyFile(), 0o600);
|
|
106
190
|
}
|
|
107
191
|
catch {
|
|
108
|
-
|
|
192
|
+
console.error("Credentials saved, but legacy credential mirrors could not be updated.");
|
|
109
193
|
}
|
|
194
|
+
}
|
|
195
|
+
function canRefresh(tokens) {
|
|
196
|
+
return !tokens.accessToken.startsWith("sk-") &&
|
|
197
|
+
typeof tokens.refreshToken === "string" && !!tokens.refreshToken &&
|
|
198
|
+
typeof tokens.clientId === "string" && !!tokens.clientId;
|
|
199
|
+
}
|
|
200
|
+
/** All reads, rotation and persistence are serialized across CLI processes. */
|
|
201
|
+
export async function refreshAccessToken(accessToken) {
|
|
202
|
+
if (process.env[ENV_VAR_NAME])
|
|
203
|
+
return undefined;
|
|
204
|
+
return withTokenLock(async () => {
|
|
205
|
+
const tokens = readTokens();
|
|
206
|
+
if (!tokens || !canRefresh(tokens))
|
|
207
|
+
return undefined;
|
|
208
|
+
if (accessToken && accessToken !== tokens.accessToken) {
|
|
209
|
+
try {
|
|
210
|
+
const rotation = JSON.parse(readFileSync(rotationFile(), "utf-8"));
|
|
211
|
+
return rotation.previous === tokenHash(accessToken) && rotation.current === tokenHash(tokens.accessToken)
|
|
212
|
+
? tokens.accessToken : undefined;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return refreshLocked(tokens);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
async function refreshLocked(tokens) {
|
|
222
|
+
let next;
|
|
223
|
+
try {
|
|
224
|
+
const response = await httpFetch(`${AUTH_SERVER}/oauth/token`, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
227
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokens.refreshToken, client_id: tokens.clientId }),
|
|
228
|
+
timeoutMs: 20_000, idempotent: false, redirect: "error",
|
|
229
|
+
});
|
|
230
|
+
if (!response.ok)
|
|
231
|
+
return undefined;
|
|
232
|
+
next = await response.json();
|
|
233
|
+
if (typeof next.access_token !== "string" || !next.access_token)
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
writeTokens(makeTokens(next.access_token, typeof next.refresh_token === "string" && next.refresh_token ? next.refresh_token : tokens.refreshToken, tokenExpiresAt(next.expires_in), tokens.clientId), tokens.accessToken);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
throw new Error("Clerk refreshed the session, but the new credentials could not be saved. Restore credential-directory write access and sign in again.");
|
|
244
|
+
}
|
|
245
|
+
return next.access_token;
|
|
246
|
+
}
|
|
247
|
+
export function tokenExpiresAt(expiresIn) {
|
|
248
|
+
return typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn >= 0
|
|
249
|
+
? Date.now() + expiresIn * 1000 : undefined;
|
|
250
|
+
}
|
|
251
|
+
async function revokeTokens(tokens) {
|
|
252
|
+
if (!tokens?.refreshToken || tokens.accessToken.startsWith("sk-"))
|
|
253
|
+
return false;
|
|
254
|
+
if (!tokens.clientId)
|
|
255
|
+
throw new Error("Stored OAuth client ID is missing. Credentials retained; server revocation could not be completed.");
|
|
256
|
+
let response;
|
|
257
|
+
try {
|
|
258
|
+
response = await httpFetch(`${AUTH_SERVER}/oauth/token/revoke`, {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
261
|
+
body: new URLSearchParams({ token: tokens.refreshToken, token_type_hint: "refresh_token", client_id: tokens.clientId }),
|
|
262
|
+
timeoutMs: 20_000, idempotent: false, redirect: "error",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new Error("Could not reach Clerk to revoke the OAuth session. Credentials retained; retry the command.");
|
|
267
|
+
}
|
|
268
|
+
if (!response.ok)
|
|
269
|
+
throw new Error(`OAuth revocation failed (HTTP ${response.status}). Credentials retained; retry the command.`);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
// A replacement is journaled before revoking the old grant. Failed cleanup
|
|
273
|
+
// never discards the only copy of a newly issued, non-expiring refresh token.
|
|
274
|
+
function readPending() {
|
|
275
|
+
let pending;
|
|
276
|
+
try {
|
|
277
|
+
pending = JSON.parse(readFileSync(pendingFile(), "utf-8"));
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (error.code !== "ENOENT")
|
|
281
|
+
throw error;
|
|
282
|
+
pending = [];
|
|
283
|
+
}
|
|
284
|
+
// Import lock-acquisition failures atomically into the ordinary cleanup queue.
|
|
285
|
+
for (const name of readdirSync(aisaDir())) {
|
|
286
|
+
if (!/^\.pending-login-[0-9a-f-]+\.json$/.test(name))
|
|
287
|
+
continue;
|
|
288
|
+
const path = join(aisaDir(), name);
|
|
289
|
+
const recovered = JSON.parse(readFileSync(path, "utf-8"));
|
|
290
|
+
if (!pending.some((entry) => sameGrant(entry, recovered)))
|
|
291
|
+
pending.push(recovered);
|
|
292
|
+
writeJson(pendingFile(), pending);
|
|
293
|
+
removeFile(path);
|
|
294
|
+
}
|
|
295
|
+
return pending;
|
|
296
|
+
}
|
|
297
|
+
function sameGrant(a, b) {
|
|
298
|
+
return !!a && a.clientId === b.clientId && a.refreshToken === b.refreshToken &&
|
|
299
|
+
(b.refreshToken ? true : a.accessToken === b.accessToken);
|
|
300
|
+
}
|
|
301
|
+
async function cleanupPending(keep) {
|
|
302
|
+
const current = readTokenFile();
|
|
303
|
+
const pending = readPending();
|
|
304
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
305
|
+
if (keep && sameGrant(keep, pending[i]))
|
|
306
|
+
continue;
|
|
307
|
+
if (!sameGrant(current, pending[i]))
|
|
308
|
+
await revokeTokens(pending[i]);
|
|
309
|
+
pending.splice(i, 1);
|
|
310
|
+
writeJson(pendingFile(), pending);
|
|
311
|
+
}
|
|
312
|
+
if (!pending.length)
|
|
313
|
+
removeFile(pendingFile());
|
|
314
|
+
}
|
|
315
|
+
/** Replace a login only after revoking the previous OAuth grant. */
|
|
316
|
+
export async function replaceTokens(accessToken, refreshToken, expiresAt, clientId) {
|
|
317
|
+
const next = makeTokens(accessToken, refreshToken, expiresAt, clientId);
|
|
318
|
+
let journaled = false;
|
|
319
|
+
try {
|
|
320
|
+
await withTokenLock(async () => {
|
|
321
|
+
const pending = readPending();
|
|
322
|
+
if (!pending.some((entry) => sameGrant(entry, next)))
|
|
323
|
+
pending.push(next);
|
|
324
|
+
writeJson(pendingFile(), pending);
|
|
325
|
+
journaled = true;
|
|
326
|
+
await cleanupPending(next);
|
|
327
|
+
const previous = readTokens();
|
|
328
|
+
if (previous?.refreshToken && previous.refreshToken === next.refreshToken && previous.clientId === next.clientId) {
|
|
329
|
+
writeTokens(next);
|
|
330
|
+
removeFile(pendingFile());
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
await revokeTokens(previous);
|
|
334
|
+
writeTokens(next);
|
|
335
|
+
removeFile(pendingFile());
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (!journaled && next.refreshToken) {
|
|
340
|
+
// Clerk already issued this grant before we attempted to acquire the lock.
|
|
341
|
+
// Preserve it independently so later login/logout can revoke it.
|
|
342
|
+
try {
|
|
343
|
+
writeIndependentJson(join(aisaDir(), `.pending-login-${randomUUID()}.json`), next);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
try {
|
|
347
|
+
await revokeTokens(next);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
throw new Error("Login failed; the newly issued OAuth grant could neither be saved for cleanup nor revoked. Check Clerk authorization management.");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
export async function revokeAndClearTokens() {
|
|
358
|
+
return withTokenLock(async () => {
|
|
359
|
+
await cleanupPending();
|
|
360
|
+
const revoked = await revokeTokens(readTokens());
|
|
361
|
+
clearTokensLocked();
|
|
362
|
+
return revoked;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
export async function clearTokens() {
|
|
366
|
+
await withTokenLock(async () => {
|
|
367
|
+
await cleanupPending();
|
|
368
|
+
clearTokensLocked();
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function clearTokensLocked() {
|
|
372
|
+
for (const path of [tokenFile(), keyFile(), rotationFile()])
|
|
373
|
+
removeFile(path);
|
|
374
|
+
config.delete("tokens");
|
|
110
375
|
config.delete("apiKey");
|
|
111
376
|
}
|
|
112
377
|
export function getKeySource() {
|
|
113
378
|
if (process.env[ENV_VAR_NAME])
|
|
114
379
|
return "env";
|
|
115
|
-
|
|
116
|
-
return "config";
|
|
117
|
-
return "none";
|
|
380
|
+
return readTokenFile() || readLegacyKey() ? "config" : "none";
|
|
118
381
|
}
|
|
119
382
|
export function getConfig(key) {
|
|
120
383
|
return config.get(key);
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.6.0";
|
|
2
2
|
/** Root of the platform. Per-surface bases are derived in api.ts#resolveBases. */
|
|
3
3
|
export declare const BASE_URL = "https://api.aisa.one";
|
|
4
4
|
export declare const ENV_VAR_NAME = "AISA_API_KEY";
|
|
@@ -103,3 +103,6 @@ export declare function categoryOf(providerId: string): string;
|
|
|
103
103
|
export declare const API_CATEGORIES: readonly [...string[], "other"];
|
|
104
104
|
export declare const MODEL_PROVIDERS: readonly ["openai", "anthropic", "google", "deepseek", "xai", "moonshot", "alibaba", "bytedance"];
|
|
105
105
|
export type ModelProvider = (typeof MODEL_PROVIDERS)[number];
|
|
106
|
+
export declare const AUTH_SERVER = "https://clerk.aisa.one";
|
|
107
|
+
export declare const AUTH_SETUP_GUIDANCE = "Run \"aisa login\" to sign in with OAuth. For CI, set AISA_API_KEY or use \"aisa login --key <key>\".";
|
|
108
|
+
export declare const MISSING_API_KEY_GUIDANCE = "Not authenticated. Run \"aisa login\" to sign in with OAuth. For CI, set AISA_API_KEY or use \"aisa login --key <key>\".";
|