@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.
@@ -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 { setApiKey } from "../config.js";
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 long-lived key.
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. trade that token for the durable "aisa cli" key at /v1/keys/mint,
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
- const AUTH_SERVER = "https://clerk.aisa.one";
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 mintCliKey(options = {}) {
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
- // The token is a day-long credential; the key is the durable one. Trade up
304
- // and keep only the key.
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
- const minted = (await mintRes.json());
316
- if (!mintRes.ok || !minted.key) {
317
- throw new Error(`could not issue a key: ${minted.error ?? `HTTP ${mintRes.status}`}`);
318
- }
319
- setApiKey(minted.key);
320
- return minted.key;
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 mintCliKey(options);
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 — CLI key ${maskKey(key)} stored`);
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 key was saved on the machine you signed in from.");',
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 key is already saved.");',
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: "SIGNED IN",
73
- title: "You're all set",
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
- <p>${c.body}</p>
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
  : ""}
@@ -1,6 +1,6 @@
1
1
  import ora from "ora";
2
2
  import chalk from "chalk";
3
- import { getApiKey, MISSING_API_KEY_GUIDANCE } from "../config.js";
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 apiKey = auth.auth === "required" ? requireRouterKey(kind) : getApiKey();
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
- apiKey,
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 = getApiKey();
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 a key. ` +
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 declare function getApiKey(): string | undefined;
2
- /** Next step when no local credential is present. Browser login first; env/--key are CI. */
3
- export declare const AUTH_SETUP_GUIDANCE = "Run \"aisa login\". For CI, set AISA_API_KEY or use \"aisa login --key <key>\".";
4
- export declare const MISSING_API_KEY_GUIDANCE = "No API key found. Run \"aisa login\". For CI, set AISA_API_KEY or use \"aisa login --key <key>\".";
5
- export declare function requireApiKey(): string;
6
- export declare function setApiKey(key: string): void;
7
- export declare function clearApiKey(): void;
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 { ENV_VAR_NAME } from "./constants.js";
6
- // ~/.aisa is the credential home: one visible, portable, platform-stable
7
- // place for the key, next door to ~/.claude and ~/.codex. The conf store
8
- // (platform-specific path, invisible to other tools) remains a legacy read
9
- // source and a sync target so older CLI versions keep working.
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
- function readKeyFile() {
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 k = readFileSync(keyFile(), "utf-8").trim();
15
- return k || undefined;
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
- function writeKeyFile(key) {
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
- writeFileSync(keyFile(), key + "\n", { mode: 0o600 });
24
- chmodSync(keyFile(), 0o600);
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
- export function getApiKey() {
67
- const envKey = process.env[ENV_VAR_NAME];
68
- if (envKey)
69
- return envKey;
70
- const fileKey = readKeyFile();
71
- if (fileKey)
72
- return fileKey;
73
- const stored = config.get("apiKey");
74
- if (stored) {
75
- // Legacy location — migrate on first read so every other tool (wrappers,
76
- // scripts) finds the key at the one agreed place from now on.
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 stored;
84
- }
85
- return undefined;
154
+ return tokens.accessToken;
155
+ });
86
156
  }
87
- /** Next step when no local credential is present. Browser login first; env/--key are CI. */
88
- export const AUTH_SETUP_GUIDANCE = `Run "aisa login". For CI, set ${ENV_VAR_NAME} or use "aisa login --key <key>".`;
89
- export const MISSING_API_KEY_GUIDANCE = `No API key found. ${AUTH_SETUP_GUIDANCE}`;
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 key;
163
+ return token;
97
164
  }
98
- export function setApiKey(key) {
99
- writeKeyFile(key);
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
- export function clearApiKey() {
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
- if (existsSync(keyFile()))
105
- unlinkSync(keyFile());
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
- /* the conf delete below still applies */
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
- if (readKeyFile() || config.get("apiKey"))
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);
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.5.1";
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>\".";