@aisa-one/cli 0.5.2 → 0.6.1
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 +29 -1
- package/README.md +33 -8
- package/dist/api.d.ts +2 -2
- package/dist/api.js +7 -6
- package/dist/commands/account.js +4 -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 +2 -2
- package/dist/commands/oauth-login.js +16 -32
- 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 +4 -4
- package/dist/router.d.ts +3 -3
- package/dist/router.js +6 -6
- package/dist/types.d.ts +1 -0
- package/dist/utils/auth-http.d.ts +3 -0
- package/dist/utils/auth-http.js +18 -0
- package/package.json +3 -1
|
@@ -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.1";
|
|
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>\".";
|
package/dist/constants.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.6.1";
|
|
2
2
|
/** Root of the platform. Per-surface bases are derived in api.ts#resolveBases. */
|
|
3
3
|
export const BASE_URL = "https://api.aisa.one";
|
|
4
4
|
export const ENV_VAR_NAME = "AISA_API_KEY";
|
|
@@ -145,3 +145,6 @@ export const MODEL_PROVIDERS = [
|
|
|
145
145
|
"alibaba",
|
|
146
146
|
"bytedance",
|
|
147
147
|
];
|
|
148
|
+
export const AUTH_SERVER = "https://clerk.aisa.one";
|
|
149
|
+
export const AUTH_SETUP_GUIDANCE = `Run "aisa login" to sign in with OAuth. For CI, set ${ENV_VAR_NAME} or use "aisa login --key <key>".`;
|
|
150
|
+
export const MISSING_API_KEY_GUIDANCE = `Not authenticated. ${AUTH_SETUP_GUIDANCE}`;
|
package/dist/index.js
CHANGED
|
@@ -80,12 +80,12 @@ program
|
|
|
80
80
|
.action(wrap(loginAction));
|
|
81
81
|
program
|
|
82
82
|
.command("logout")
|
|
83
|
-
.description("
|
|
84
|
-
.action(logoutAction);
|
|
83
|
+
.description("Revoke OAuth refresh token and remove stored credentials")
|
|
84
|
+
.action(wrap(logoutAction));
|
|
85
85
|
program
|
|
86
86
|
.command("whoami")
|
|
87
87
|
.description("Show authentication status")
|
|
88
|
-
.action(whoamiAction);
|
|
88
|
+
.action(wrap(whoamiAction));
|
|
89
89
|
// ── Account ──
|
|
90
90
|
program
|
|
91
91
|
.command("topup [amount]")
|
|
@@ -245,7 +245,7 @@ mcp
|
|
|
245
245
|
.option("--agent <agent>", "Target agent: cursor, claude-desktop, all")
|
|
246
246
|
.option("--all", "Configure every live server, not just the default set")
|
|
247
247
|
.option("--yes", "Write the files. Without it, print what would change and stop")
|
|
248
|
-
.action(mcpSetupAction);
|
|
248
|
+
.action(wrap(mcpSetupAction));
|
|
249
249
|
mcp
|
|
250
250
|
.command("status")
|
|
251
251
|
.description("Check MCP configuration and ping each configured endpoint")
|
package/dist/router.d.ts
CHANGED
|
@@ -26,14 +26,14 @@ export interface RouterRequest {
|
|
|
26
26
|
/** Raw application JSON. Sent unchanged so numeric tokens survive. */
|
|
27
27
|
body: string;
|
|
28
28
|
/** Bearer credential without the "Bearer " prefix, if any. */
|
|
29
|
-
|
|
29
|
+
accessToken?: string;
|
|
30
30
|
}
|
|
31
31
|
export interface RouterHttpResult {
|
|
32
32
|
status: number;
|
|
33
33
|
raw: string;
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* POST one Router operation.
|
|
37
|
-
* replay
|
|
36
|
+
* POST one Router operation. Only a 401 permits one authentication retry; quote and use are not safe to
|
|
37
|
+
* replay after a transport or server failure.
|
|
38
38
|
*/
|
|
39
39
|
export declare function routerPost(request: RouterRequest): Promise<RouterHttpResult>;
|
package/dist/router.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getConfig } from "./config.js";
|
|
2
|
-
import {
|
|
2
|
+
import { authenticatedFetch } from "./utils/auth-http.js";
|
|
3
3
|
/**
|
|
4
4
|
* Deployed Tool Router origin (nginx / tools.aisa.one). Paths already include
|
|
5
5
|
* `/v1/tool-router/...`, so this is an origin, not the LLM `/v1` base.
|
|
@@ -35,8 +35,8 @@ function stripTrailingSlash(url) {
|
|
|
35
35
|
return url.replace(/\/+$/, "");
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
* POST one Router operation.
|
|
39
|
-
* replay
|
|
38
|
+
* POST one Router operation. Only a 401 permits one authentication retry; quote and use are not safe to
|
|
39
|
+
* replay after a transport or server failure.
|
|
40
40
|
*/
|
|
41
41
|
export async function routerPost(request) {
|
|
42
42
|
const url = `${resolveRouterBase()}${ROUTER_PATHS[request.operation]}`;
|
|
@@ -44,10 +44,10 @@ export async function routerPost(request) {
|
|
|
44
44
|
"Content-Type": "application/json",
|
|
45
45
|
"x-aisa-source": "cli",
|
|
46
46
|
};
|
|
47
|
-
if (request.
|
|
48
|
-
headers.Authorization = `Bearer ${request.
|
|
47
|
+
if (request.accessToken) {
|
|
48
|
+
headers.Authorization = `Bearer ${request.accessToken}`;
|
|
49
49
|
}
|
|
50
|
-
const res = await
|
|
50
|
+
const res = await authenticatedFetch(url, {
|
|
51
51
|
method: "POST",
|
|
52
52
|
headers,
|
|
53
53
|
body: request.body,
|
package/dist/types.d.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { refreshAccessToken } from "../config.js";
|
|
2
|
+
import { httpFetch } from "./http.js";
|
|
3
|
+
/** Retry only an explicit authentication rejection, at most once. */
|
|
4
|
+
export async function authenticatedFetch(url, options) {
|
|
5
|
+
const response = await httpFetch(url, options);
|
|
6
|
+
if (response.status !== 401)
|
|
7
|
+
return response;
|
|
8
|
+
const headers = new Headers(options.headers);
|
|
9
|
+
const authorization = headers.get("Authorization");
|
|
10
|
+
if (!authorization?.startsWith("Bearer "))
|
|
11
|
+
return response;
|
|
12
|
+
const token = await refreshAccessToken(authorization.slice(7));
|
|
13
|
+
if (!token)
|
|
14
|
+
return response;
|
|
15
|
+
await response.body?.cancel();
|
|
16
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
17
|
+
return httpFetch(url, { ...options, headers: Object.fromEntries(headers.entries()) });
|
|
18
|
+
}
|