@aisa-one/cli 0.3.0 → 0.5.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 +165 -1
- package/LICENSE +21 -0
- package/README.md +164 -170
- package/dist/api.d.ts +10 -0
- package/dist/api.js +14 -3
- package/dist/catalog.d.ts +1 -18
- package/dist/catalog.js +1 -24
- package/dist/cli-error.d.ts +17 -0
- package/dist/cli-error.js +25 -0
- package/dist/cli-guidance.d.ts +14 -0
- package/dist/cli-guidance.js +55 -0
- package/dist/commands/account.d.ts +17 -0
- package/dist/commands/account.js +45 -1
- package/dist/commands/api.d.ts +0 -11
- package/dist/commands/api.js +6 -175
- package/dist/commands/auth.d.ts +2 -1
- package/dist/commands/auth.js +45 -11
- package/dist/commands/brand-logos.d.ts +6 -0
- package/dist/commands/brand-logos.js +21 -0
- package/dist/commands/configCmd.js +8 -3
- package/dist/commands/connect-shared.d.ts +136 -0
- package/dist/commands/connect-shared.js +143 -0
- package/dist/commands/connect-t2.d.ts +18 -0
- package/dist/commands/connect-t2.js +1599 -0
- package/dist/commands/connect-terminal.d.ts +65 -0
- package/dist/commands/connect-terminal.js +822 -0
- package/dist/commands/connect.d.ts +31 -0
- package/dist/commands/connect.js +3181 -0
- package/dist/commands/flow.d.ts +685 -0
- package/dist/commands/flow.js +697 -0
- package/dist/commands/install.d.ts +91 -0
- package/dist/commands/install.js +258 -0
- package/dist/commands/llm-config.d.ts +103 -0
- package/dist/commands/llm-config.js +452 -0
- package/dist/commands/manifest.d.ts +68 -0
- package/dist/commands/manifest.js +90 -0
- package/dist/commands/mcp.d.ts +59 -0
- package/dist/commands/mcp.js +122 -44
- package/dist/commands/oauth-login.d.ts +33 -0
- package/dist/commands/oauth-login.js +333 -0
- package/dist/commands/prompt.d.ts +96 -0
- package/dist/commands/prompt.js +397 -0
- package/dist/commands/serve-results.d.ts +61 -0
- package/dist/commands/serve-results.js +245 -0
- package/dist/commands/serve-signin.d.ts +28 -0
- package/dist/commands/serve-signin.js +90 -0
- package/dist/commands/signin-page.d.ts +49 -0
- package/dist/commands/signin-page.js +250 -0
- package/dist/commands/skills.js +87 -50
- package/dist/commands/tool-help.d.ts +69 -0
- package/dist/commands/tool-help.js +189 -0
- package/dist/commands/tool-input.d.ts +22 -0
- package/dist/commands/tool-input.js +220 -0
- package/dist/commands/tools.d.ts +12 -0
- package/dist/commands/tools.js +306 -0
- package/dist/commands/update.d.ts +11 -0
- package/dist/commands/update.js +92 -0
- package/dist/commands/vscode.d.ts +65 -0
- package/dist/commands/vscode.js +173 -0
- package/dist/commands/wrappers.d.ts +23 -0
- package/dist/commands/wrappers.js +102 -0
- package/dist/completion.js +3 -80
- package/dist/config.d.ts +3 -0
- package/dist/config.js +80 -3
- package/dist/constants.d.ts +51 -38
- package/dist/constants.js +53 -109
- package/dist/index.js +152 -355
- package/dist/json-text.d.ts +12 -0
- package/dist/json-text.js +60 -0
- package/dist/router.d.ts +39 -0
- package/dist/router.js +59 -0
- package/dist/skills-registry.js +12 -3
- package/dist/utils/banner.d.ts +9 -0
- package/dist/utils/banner.js +41 -0
- package/dist/utils/browser.d.ts +19 -0
- package/dist/utils/browser.js +31 -0
- package/dist/utils/exec.d.ts +43 -0
- package/dist/utils/exec.js +61 -0
- package/dist/utils/http.d.ts +53 -0
- package/dist/utils/http.js +124 -0
- package/dist/utils/install-method.d.ts +36 -0
- package/dist/utils/install-method.js +48 -0
- package/dist/utils/journal.d.ts +57 -0
- package/dist/utils/journal.js +188 -0
- package/dist/utils/key-check.d.ts +20 -0
- package/dist/utils/key-check.js +24 -0
- package/dist/utils/update-check.d.ts +23 -0
- package/dist/utils/update-check.js +116 -0
- package/dist/utils/width.d.ts +32 -0
- package/dist/utils/width.js +73 -0
- package/package.json +8 -4
- package/vscode-extension/README.md +6 -0
- package/vscode-extension/aisa-vscode.vsix +0 -0
- package/dist/commands/finance.d.ts +0 -17
- package/dist/commands/finance.js +0 -342
- package/dist/commands/run.d.ts +0 -28
- package/dist/commands/run.js +0 -182
- package/dist/commands/search.d.ts +0 -9
- package/dist/commands/search.js +0 -81
- package/dist/commands/twitter.d.ts +0 -138
- package/dist/commands/twitter.js +0 -374
- package/dist/commands/video.d.ts +0 -49
- package/dist/commands/video.js +0 -311
package/dist/commands/mcp.js
CHANGED
|
@@ -2,27 +2,58 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { success, error, info, hint } from "../utils/display.js";
|
|
4
4
|
import { expandHome, ensureDir } from "../utils/file.js";
|
|
5
|
-
import { MCP_CONFIGS, MCP_MANIFEST_URL, DOCS_MCP_URL, MCP_DEFAULT_SLUGS } from "../constants.js";
|
|
5
|
+
import { MCP_CONFIGS, MCP_MANIFEST_URL, MCP_CATALOG_URL, DOCS_MCP_URL, MCP_DEFAULT_SLUGS } from "../constants.js";
|
|
6
|
+
import { httpFetch, MAX_ATTEMPTS } from "../utils/http.js";
|
|
6
7
|
import { getApiKey } from "../config.js";
|
|
8
|
+
import { detectClients } from "./connect.js";
|
|
7
9
|
import { join } from "node:path";
|
|
8
|
-
const stripped = (name) => name.replace(/^AIsa\s+/i, "");
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!res.ok)
|
|
12
|
-
throw new Error(`manifest answered HTTP ${res.status}`);
|
|
13
|
-
const manifest = (await res.json());
|
|
14
|
-
const live = (manifest.servers ?? [])
|
|
10
|
+
export const stripped = (name) => name.replace(/^AIsa\s+/i, "");
|
|
11
|
+
function toLive(servers) {
|
|
12
|
+
return servers
|
|
15
13
|
.filter((s) => s.status === "live" && s.slug && s.transport?.endpoint)
|
|
16
14
|
.map((s) => ({
|
|
17
15
|
slug: s.slug,
|
|
18
16
|
name: s.name ?? s.slug,
|
|
19
17
|
endpoint: s.transport.endpoint,
|
|
20
|
-
toolCount: s.tools?.length ?? 0,
|
|
18
|
+
toolCount: s.toolCount ?? s.tools?.length ?? 0,
|
|
19
|
+
description: s.description ?? "",
|
|
20
|
+
category: s.category ?? "Other",
|
|
21
21
|
}));
|
|
22
|
+
}
|
|
23
|
+
async function readSource(url, attempts) {
|
|
24
|
+
const res = await httpFetch(url, { idempotent: true, maxAttempts: attempts, timeoutMs: 15_000 });
|
|
25
|
+
if (!res.ok)
|
|
26
|
+
throw new Error(`${url} answered HTTP ${res.status}`);
|
|
27
|
+
const doc = (await res.json());
|
|
28
|
+
const live = toLive(doc.servers ?? []);
|
|
22
29
|
if (live.length === 0)
|
|
23
|
-
throw new Error(
|
|
30
|
+
throw new Error(`${url} lists no live servers`);
|
|
24
31
|
return live;
|
|
25
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Which servers exist, asked of the host that runs them.
|
|
35
|
+
*
|
|
36
|
+
* The index is preferred and the manifest is the fallback, in that order for
|
|
37
|
+
* one reason: the manifest is a snapshot committed by hand, and on 2026-08-25
|
|
38
|
+
* it was three servers and 72 tools behind what the host was actually serving.
|
|
39
|
+
* Anything derived from a running process cannot drift that way.
|
|
40
|
+
*
|
|
41
|
+
* The fallback is not defensive padding — the index is newer than some
|
|
42
|
+
* deployments, and a self-hosted gateway configured through `baseUrl` may
|
|
43
|
+
* never grow one. A user in either case should still get a working setup from
|
|
44
|
+
* a slightly stale list rather than an error.
|
|
45
|
+
*/
|
|
46
|
+
export async function fetchLiveServers() {
|
|
47
|
+
try {
|
|
48
|
+
// One attempt only: there is somewhere else to go, so spending a retry
|
|
49
|
+
// budget here just delays the fallback. The manifest is the last resort
|
|
50
|
+
// and gets the full three.
|
|
51
|
+
return await readSource(MCP_CATALOG_URL, 1);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return await readSource(MCP_MANIFEST_URL, MAX_ATTEMPTS);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
26
57
|
/** One config entry in the shape the client actually executes. */
|
|
27
58
|
export function buildEntry(shape, endpoint, key) {
|
|
28
59
|
if (shape === "url") {
|
|
@@ -40,14 +71,72 @@ export function buildEntry(shape, endpoint, key) {
|
|
|
40
71
|
}
|
|
41
72
|
/** The dead entry every pre-v0.3 setup wrote. Remove it on sight. */
|
|
42
73
|
const DEAD_URL = "https://docs.aisa.one/mcp";
|
|
74
|
+
/**
|
|
75
|
+
* Write the chosen servers (plus the docs server) into one client's config
|
|
76
|
+
* file. Shared by `aisa mcp setup` and `aisa connect` so the two entry points
|
|
77
|
+
* cannot drift in how they treat existing files: unparseable JSON is refused,
|
|
78
|
+
* never replaced, and only the known-dead legacy entry is cleaned up.
|
|
79
|
+
*/
|
|
80
|
+
export function writeClientConfig(agent, chosen, key) {
|
|
81
|
+
const config = MCP_CONFIGS[agent];
|
|
82
|
+
if (!config)
|
|
83
|
+
return { ok: false, reason: `unknown client "${agent}"` };
|
|
84
|
+
const filePath = expandHome(config.path);
|
|
85
|
+
let existing = {};
|
|
86
|
+
if (existsSync(filePath)) {
|
|
87
|
+
try {
|
|
88
|
+
existing = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// A file we cannot parse is a file we do not own. Refuse instead of
|
|
92
|
+
// wiping a user's hand-edited config.
|
|
93
|
+
return { ok: false, reason: `${config.path} exists but is not valid JSON` };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
ensureDir(join(filePath, ".."));
|
|
98
|
+
}
|
|
99
|
+
const entries = existing[config.key] || {};
|
|
100
|
+
// Clean up the dead entry earlier releases wrote, and only that one: an
|
|
101
|
+
// "aisa" entry the user pointed somewhere else on purpose stays.
|
|
102
|
+
const stale = entries["aisa"];
|
|
103
|
+
if (stale?.url === DEAD_URL)
|
|
104
|
+
delete entries["aisa"];
|
|
105
|
+
for (const s of chosen) {
|
|
106
|
+
entries[`aisa-${s.slug}`] = buildEntry(config.shape, s.endpoint, key);
|
|
107
|
+
}
|
|
108
|
+
// The docs-search MCP is tiny, unauthenticated, and answers "how do I call
|
|
109
|
+
// this" questions — always included.
|
|
110
|
+
entries["aisa-docs"] = buildEntry(config.shape, DOCS_MCP_URL, undefined);
|
|
111
|
+
existing[config.key] = entries;
|
|
112
|
+
writeFileSync(filePath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
|
|
113
|
+
return { ok: true, written: chosen.length + 1 };
|
|
114
|
+
}
|
|
43
115
|
export async function mcpSetupAction(options) {
|
|
44
|
-
const
|
|
45
|
-
for (const agent of
|
|
116
|
+
const requested = options.agent && options.agent !== "all" ? [options.agent] : Object.keys(MCP_CONFIGS);
|
|
117
|
+
for (const agent of requested) {
|
|
46
118
|
if (!MCP_CONFIGS[agent]) {
|
|
47
119
|
error(`Unknown agent: ${agent}. Valid: ${Object.keys(MCP_CONFIGS).join(", ")}, all`);
|
|
48
120
|
return;
|
|
49
121
|
}
|
|
50
122
|
}
|
|
123
|
+
// Write only where the client actually lives. Until 2026-08-25 `--agent all`
|
|
124
|
+
// (the default) wrote to every entry in MCP_CONFIGS whether or not it was
|
|
125
|
+
// installed, which on a machine without Windsurf created ~/.codeium/ and
|
|
126
|
+
// made the next `aisa connect` offer a card for an editor that was never
|
|
127
|
+
// there — this command's output became that command's input.
|
|
128
|
+
const installed = new Set(detectClients().filter((c) => c.detected).map((c) => c.id));
|
|
129
|
+
const targets = requested.filter((a) => installed.has(a));
|
|
130
|
+
const absent = requested.filter((a) => !installed.has(a));
|
|
131
|
+
for (const a of absent) {
|
|
132
|
+
console.log(` ${chalk.gray("○")} ${a}: not installed — skipped`);
|
|
133
|
+
}
|
|
134
|
+
if (targets.length === 0) {
|
|
135
|
+
error("None of the requested clients are installed on this machine.");
|
|
136
|
+
hint("`aisa connect` detects what is here and walks you through it.");
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
51
140
|
// Fetch before touching any file: a setup that cannot see the manifest has
|
|
52
141
|
// nothing true to write.
|
|
53
142
|
let servers;
|
|
@@ -72,41 +161,30 @@ export async function mcpSetupAction(options) {
|
|
|
72
161
|
info("No API key configured — entries are written without credentials, and the");
|
|
73
162
|
info("server's OAuth flow will open in your browser on first use.");
|
|
74
163
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
// A file we cannot parse is a file we do not own. The old command
|
|
85
|
-
// replaced it with {} — a user's hand-edited config (comments,
|
|
86
|
-
// trailing commas) was wiped by a "setup". Refuse instead.
|
|
87
|
-
error(`${agent}: ${config.path} exists but is not valid JSON — refusing to overwrite it.`);
|
|
88
|
-
hint("Fix or remove the file, then re-run setup.");
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
164
|
+
// Nothing is written without saying so first. This command used to change
|
|
165
|
+
// three config files the moment it was typed, with no preview and no way to
|
|
166
|
+
// ask what it would do — the opposite of `connect`, which shows the plan and
|
|
167
|
+
// waits. --yes is how a script opts back in to writing straight away.
|
|
168
|
+
if (!options.yes) {
|
|
169
|
+
info("This would write:");
|
|
170
|
+
for (const agent of targets) {
|
|
171
|
+
console.log(` ${chalk.cyan(MCP_CONFIGS[agent].path)} (${agent}, ${MCP_CONFIGS[agent].shape})`);
|
|
91
172
|
}
|
|
92
|
-
else {
|
|
93
|
-
ensureDir(join(filePath, ".."));
|
|
94
|
-
}
|
|
95
|
-
const entries = existing[config.key] || {};
|
|
96
|
-
// Clean up the dead entry earlier releases wrote, and only that one:
|
|
97
|
-
// an "aisa" entry the user pointed somewhere else on purpose stays.
|
|
98
|
-
const stale = entries["aisa"];
|
|
99
|
-
if (stale?.url === DEAD_URL)
|
|
100
|
-
delete entries["aisa"];
|
|
101
173
|
for (const s of chosen) {
|
|
102
|
-
|
|
174
|
+
console.log(` ${chalk.gray(`aisa-${s.slug}`)} ${stripped(s.name)} (${s.toolCount} tools)`);
|
|
175
|
+
}
|
|
176
|
+
hint("Nothing was written. Re-run with --yes to apply, or use `aisa connect` to choose interactively.");
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (const agent of targets) {
|
|
180
|
+
const config = MCP_CONFIGS[agent];
|
|
181
|
+
const result = writeClientConfig(agent, chosen, key);
|
|
182
|
+
if (!result.ok) {
|
|
183
|
+
error(`${agent}: ${result.reason} — refusing to overwrite it.`);
|
|
184
|
+
hint("Fix or remove the file, then re-run setup.");
|
|
185
|
+
continue;
|
|
103
186
|
}
|
|
104
|
-
|
|
105
|
-
// this" questions — always included.
|
|
106
|
-
entries["aisa-docs"] = buildEntry(config.shape, DOCS_MCP_URL, undefined);
|
|
107
|
-
existing[config.key] = entries;
|
|
108
|
-
writeFileSync(filePath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
|
|
109
|
-
console.log(` ${chalk.green("✓")} ${agent}: ${chosen.length + 1} servers (${config.shape}) → ${config.path}`);
|
|
187
|
+
console.log(` ${chalk.green("✓")} ${agent}: ${result.written} servers (${config.shape}) → ${config.path}`);
|
|
110
188
|
}
|
|
111
189
|
success(`Configured ${chosen.length} live servers plus docs search for ${targets.length} client(s)`);
|
|
112
190
|
for (const s of chosen) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Lang } from "./flow.js";
|
|
2
|
+
/**
|
|
3
|
+
* The whole sign-in, as a function other commands can embed: browser (or
|
|
4
|
+
* paste-back) OAuth, then the mint. Stores the key and returns it; throws on
|
|
5
|
+
* any failure. `aisa login` wraps this with CLI messaging; `aisa connect`
|
|
6
|
+
* runs it as its "Sign in to AIsa" step.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Somewhere for the browser to land.
|
|
10
|
+
*
|
|
11
|
+
* `aisa login` on its own takes a random loopback port and serves one page on
|
|
12
|
+
* it. Inside `connect` that is the wrong shape: the sign-in opens in a second
|
|
13
|
+
* tab, and a random port has nothing to do with the run — the tab finishes on
|
|
14
|
+
* a page that knows nothing about the setup in progress and leaves the user
|
|
15
|
+
* to find their way back to the first tab themselves. Handing the run's own
|
|
16
|
+
* server in means the approval comes home to the address the setup already
|
|
17
|
+
* lives at, and the tab can return the reader to it.
|
|
18
|
+
*/
|
|
19
|
+
export interface OAuthCatcher {
|
|
20
|
+
/** Registered with the authorization server before the browser opens. */
|
|
21
|
+
redirectUri: string;
|
|
22
|
+
/** Resolves with the authorization code, or rejects with why it will not. */
|
|
23
|
+
wait(expectedState: string): Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
export declare function mintCliKey(options?: {
|
|
26
|
+
open?: boolean;
|
|
27
|
+
lang?: Lang;
|
|
28
|
+
catcher?: OAuthCatcher;
|
|
29
|
+
}): Promise<string>;
|
|
30
|
+
export declare function oauthLogin(options?: {
|
|
31
|
+
open?: boolean;
|
|
32
|
+
lang?: Lang;
|
|
33
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { run } from "../utils/exec.js";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { error, hint, info, success } from "../utils/display.js";
|
|
7
|
+
import { setApiKey } from "../config.js";
|
|
8
|
+
import { maskKey } from "../config.js";
|
|
9
|
+
import { httpFetch } from "../utils/http.js";
|
|
10
|
+
import { canOpenBrowser } from "../utils/browser.js";
|
|
11
|
+
import { SIGNIN, t } from "./flow.js";
|
|
12
|
+
import { renderSignInPage } from "./signin-page.js";
|
|
13
|
+
import { handOverSignInPage, SIGNIN_PAGE_TTL_MS } from "./serve-signin.js";
|
|
14
|
+
/**
|
|
15
|
+
* `aisa login` without a key: sign in once in a browser, come back with the
|
|
16
|
+
* CLI's long-lived key.
|
|
17
|
+
*
|
|
18
|
+
* The flow is the standard one every CLI converges on (gh, flyctl, claude):
|
|
19
|
+
*
|
|
20
|
+
* 1. register a public OAuth client (Clerk supports dynamic registration)
|
|
21
|
+
* 2. authorization-code + PKCE, redirecting to a loopback port
|
|
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.
|
|
26
|
+
*
|
|
27
|
+
* A machine with no browser of its own takes the paste-back variant, and it
|
|
28
|
+
* is chosen for the user rather than asked for: the URL is printed, the user
|
|
29
|
+
* authorizes wherever they are sitting, the redirect to 127.0.0.1 fails to
|
|
30
|
+
* load there — and that is fine, because the code is in the URL, which they
|
|
31
|
+
* paste back here. PKCE needs the verifier, not a reachable callback, and
|
|
32
|
+
* the verifier never left this machine. What travels through the clipboard
|
|
33
|
+
* is a one-time code, not a key.
|
|
34
|
+
*/
|
|
35
|
+
const AUTH_SERVER = "https://clerk.aisa.one";
|
|
36
|
+
/**
|
|
37
|
+
* Where the sign-in lands when the browser is on a different machine.
|
|
38
|
+
*
|
|
39
|
+
* A page that does one thing: show the one-time code so it can be carried
|
|
40
|
+
* back by hand. The loopback redirect below is better whenever it can be
|
|
41
|
+
* used — the CLI catches the code itself and nobody copies anything — but it
|
|
42
|
+
* only works when the browser and this process share a machine.
|
|
43
|
+
*
|
|
44
|
+
* This address is a published contract. Every released CLI that names it
|
|
45
|
+
* keeps sending users there, so it is not to be changed; a new one would
|
|
46
|
+
* have to be added alongside.
|
|
47
|
+
*/
|
|
48
|
+
const HOSTED_REDIRECT = "https://aisa.one/cli/auth";
|
|
49
|
+
const MINT_URL = "https://api.aisa.one/v1/keys/mint";
|
|
50
|
+
const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
51
|
+
async function registerClient(redirectUri) {
|
|
52
|
+
const res = await httpFetch(`${AUTH_SERVER}/oauth/register`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "Content-Type": "application/json" },
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
client_name: "AIsa CLI",
|
|
57
|
+
redirect_uris: [redirectUri],
|
|
58
|
+
grant_types: ["authorization_code"],
|
|
59
|
+
response_types: ["code"],
|
|
60
|
+
token_endpoint_auth_method: "none",
|
|
61
|
+
scope: "openid profile email",
|
|
62
|
+
}),
|
|
63
|
+
signal: AbortSignal.timeout(15_000),
|
|
64
|
+
});
|
|
65
|
+
const body = (await res.json());
|
|
66
|
+
if (!body.client_id)
|
|
67
|
+
throw new Error(`client registration failed (HTTP ${res.status})`);
|
|
68
|
+
return body.client_id;
|
|
69
|
+
}
|
|
70
|
+
/** Wait for the authorization code on the loopback port. */
|
|
71
|
+
/**
|
|
72
|
+
* How long to hold a loopback callback open before giving up on it.
|
|
73
|
+
*
|
|
74
|
+
* Five minutes looked generous until a sign-in took longer than that and the
|
|
75
|
+
* approval landed on a closed port: the browser said it could not connect,
|
|
76
|
+
* which reads as "I broke something" rather than "that took too long". A
|
|
77
|
+
* person who is signing in normally is done inside a minute; the ones who are
|
|
78
|
+
* not are switching machines, reading the consent screen properly, or being
|
|
79
|
+
* interrupted. Fifteen minutes costs nothing and covers all of them.
|
|
80
|
+
*/
|
|
81
|
+
const CALLBACK_TIMEOUT_MS = 15 * 60 * 1000;
|
|
82
|
+
/**
|
|
83
|
+
* And once it does expire, keep answering for a little longer.
|
|
84
|
+
*
|
|
85
|
+
* The wait is over and the terminal has said so, but the socket costs nothing
|
|
86
|
+
* to hold and an approval arriving late deserves a sentence rather than a
|
|
87
|
+
* connection error. It stops the process from lingering more than this.
|
|
88
|
+
*/
|
|
89
|
+
const AFTER_TIMEOUT_GRACE_MS = 90 * 1000;
|
|
90
|
+
function waitForCallback(port, expectedState, lang) {
|
|
91
|
+
let expired = false;
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
// A browser we opened may never come back — it was never opened at all,
|
|
94
|
+
// the tab was closed, the machine has no screen. Waiting for that with
|
|
95
|
+
// no deadline is a hang, and a hang is the one failure that tells the
|
|
96
|
+
// user nothing.
|
|
97
|
+
const giveUp = setTimeout(() => {
|
|
98
|
+
// Deliberately not closing the server: an approval that arrives now has
|
|
99
|
+
// to land somewhere that can explain itself. It closes on its own a
|
|
100
|
+
// little later, and the process does not outlive it.
|
|
101
|
+
expired = true;
|
|
102
|
+
const close = setTimeout(() => {
|
|
103
|
+
srv.close();
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}, AFTER_TIMEOUT_GRACE_MS);
|
|
106
|
+
close.unref?.();
|
|
107
|
+
reject(new Error(`no response from the browser after ${CALLBACK_TIMEOUT_MS / 60_000} minutes — run 'aisa login' again`));
|
|
108
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
109
|
+
giveUp.unref?.();
|
|
110
|
+
const srv = createServer((req, res) => {
|
|
111
|
+
clearTimeout(giveUp);
|
|
112
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
113
|
+
if (url.pathname !== "/callback") {
|
|
114
|
+
res.writeHead(404).end();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (expired) {
|
|
118
|
+
// They approved it; we had already stopped listening for an answer.
|
|
119
|
+
// Saying so is the difference between "that took too long" and the
|
|
120
|
+
// browser's own "could not connect", which reads like a broken tool.
|
|
121
|
+
res
|
|
122
|
+
.writeHead(408, { "content-type": "text/html; charset=utf-8" })
|
|
123
|
+
.end(renderSignInPage("expired"));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const code = url.searchParams.get("code");
|
|
127
|
+
const state = url.searchParams.get("state");
|
|
128
|
+
if (!code || state !== expectedState) {
|
|
129
|
+
res
|
|
130
|
+
.writeHead(400, { "content-type": "text/html; charset=utf-8" })
|
|
131
|
+
.end(renderSignInPage("failed"));
|
|
132
|
+
srv.close();
|
|
133
|
+
reject(new Error("authorization was denied or the response was malformed"));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// charset matters more than it looks: without it a browser in a Chinese
|
|
137
|
+
// locale decodes this as GBK and the em dash arrives as mojibake, which
|
|
138
|
+
// is what the last version of this line actually did.
|
|
139
|
+
res
|
|
140
|
+
.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
|
141
|
+
.end(renderSignInPage("ok", Date.now() + SIGNIN_PAGE_TTL_MS));
|
|
142
|
+
// Not srv.close(): a page that cannot survive a refresh is a page that
|
|
143
|
+
// says "this site can't be reached" to someone checking their sign-in
|
|
144
|
+
// really worked. The port goes to a detached copy that serves this one
|
|
145
|
+
// document for a few minutes, and the terminal comes back now.
|
|
146
|
+
res.on("finish", () => {
|
|
147
|
+
clearTimeout(giveUp);
|
|
148
|
+
handOverSignInPage(port, () => srv.close());
|
|
149
|
+
});
|
|
150
|
+
resolve(code);
|
|
151
|
+
});
|
|
152
|
+
srv.listen(port, "127.0.0.1");
|
|
153
|
+
srv.on("error", (e) => {
|
|
154
|
+
clearTimeout(giveUp);
|
|
155
|
+
reject(e);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** The paste-back path: read the redirect URL (or bare code) from stdin. */
|
|
160
|
+
/**
|
|
161
|
+
* Read the redirect back from whoever has the browser.
|
|
162
|
+
*
|
|
163
|
+
* Takes the whole address, which is what a person copying from an address
|
|
164
|
+
* bar actually has — but also takes a bare code, because someone who reads
|
|
165
|
+
* the query string will pull out the interesting part instead, and refusing
|
|
166
|
+
* that would be pedantry.
|
|
167
|
+
*/
|
|
168
|
+
/**
|
|
169
|
+
* Read the pasted redirect, and give the reader more than one go at it.
|
|
170
|
+
*
|
|
171
|
+
* It used to ask once and reject on anything it could not parse. Pasting a
|
|
172
|
+
* long code into a terminal is exactly where people fumble — the clipboard
|
|
173
|
+
* did not take, enter went in early, the wrong window was in front — and one
|
|
174
|
+
* fumble failed the whole sign-in. In `aisa login` that costs the command and
|
|
175
|
+
* the authorize URL together: the page they just opened is stale, so they
|
|
176
|
+
* start over from the beginning for a mistyped paste.
|
|
177
|
+
*
|
|
178
|
+
* Three tries, each saying what was wrong with the last one. An empty line is
|
|
179
|
+
* not a mistake, it is "stop asking" — the same meaning it has in every other
|
|
180
|
+
* prompt in this flow.
|
|
181
|
+
*/
|
|
182
|
+
const PASTE_TRIES = 3;
|
|
183
|
+
function parsePaste(raw) {
|
|
184
|
+
const text = raw.trim().replace(/^["']|["']$/g, "");
|
|
185
|
+
try {
|
|
186
|
+
const url = new URL(text);
|
|
187
|
+
return { code: url.searchParams.get("code"), state: url.searchParams.get("state") };
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Not a URL. A query fragment, or the code on its own.
|
|
191
|
+
const params = new URLSearchParams(text.replace(/^\?/, ""));
|
|
192
|
+
return {
|
|
193
|
+
code: params.get("code") ?? (/^[A-Za-z0-9._~-]{8,}$/.test(text) ? text : null),
|
|
194
|
+
state: params.get("state"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function waitForPaste(expectedState, lang) {
|
|
199
|
+
for (let attempt = 1; attempt <= PASTE_TRIES; attempt++) {
|
|
200
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
201
|
+
const answer = await rl.question(t(SIGNIN.prompt, lang));
|
|
202
|
+
rl.close();
|
|
203
|
+
if (answer.trim() === "") {
|
|
204
|
+
throw new Error("sign-in cancelled — run it again when you have the code");
|
|
205
|
+
}
|
|
206
|
+
const { code, state } = parsePaste(answer);
|
|
207
|
+
let wrong;
|
|
208
|
+
if (!code)
|
|
209
|
+
wrong = t(SIGNIN.badPaste, lang);
|
|
210
|
+
// A pasted bare code carries no state to check; a pasted URL does, and a
|
|
211
|
+
// mismatched one is an older sign-in that would fail confusingly at the
|
|
212
|
+
// token endpoint instead of here.
|
|
213
|
+
else if (state !== null && state !== expectedState) {
|
|
214
|
+
wrong = "that is from an older sign-in — paste the address from the page you just opened";
|
|
215
|
+
}
|
|
216
|
+
if (!wrong)
|
|
217
|
+
return code;
|
|
218
|
+
if (attempt === PASTE_TRIES)
|
|
219
|
+
throw new Error(wrong);
|
|
220
|
+
console.log(` ${chalk.yellow(wrong)}`);
|
|
221
|
+
console.log(` ${chalk.gray(`try again — ${PASTE_TRIES - attempt} left, or press enter to stop`)}\n`);
|
|
222
|
+
}
|
|
223
|
+
/* c8 ignore next */
|
|
224
|
+
throw new Error(t(SIGNIN.badPaste, lang));
|
|
225
|
+
}
|
|
226
|
+
function openBrowser(url) {
|
|
227
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
228
|
+
void run(cmd, [url], { timeout: 30_000 }).catch(() => { });
|
|
229
|
+
}
|
|
230
|
+
export async function mintCliKey(options = {}) {
|
|
231
|
+
const lang = options.lang ?? "en";
|
|
232
|
+
// Unset means "work it out": a server has no browser to open and waiting
|
|
233
|
+
// for a click on a machine with no screen is the worst way to find out.
|
|
234
|
+
// A caller that brought its own catcher has already decided.
|
|
235
|
+
const useBrowser = options.catcher ? true : (options.open ?? canOpenBrowser());
|
|
236
|
+
// The redirect has to be registered before the browser opens, and Clerk
|
|
237
|
+
// matches it exactly — so which of the three it is has to be settled here,
|
|
238
|
+
// before anything else happens. A loopback port is only worth taking when
|
|
239
|
+
// this process is the one that will catch the redirect.
|
|
240
|
+
const port = useBrowser && !options.catcher ? 10000 + Math.floor(Math.random() * 50_000) : 0;
|
|
241
|
+
const redirectUri = options.catcher
|
|
242
|
+
? options.catcher.redirectUri
|
|
243
|
+
: useBrowser
|
|
244
|
+
? `http://127.0.0.1:${port}/callback`
|
|
245
|
+
: HOSTED_REDIRECT;
|
|
246
|
+
info(t(SIGNIN.start, lang));
|
|
247
|
+
const clientId = await registerClient(redirectUri);
|
|
248
|
+
const verifier = b64url(randomBytes(32));
|
|
249
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
250
|
+
const state = b64url(randomBytes(16));
|
|
251
|
+
const authUrl = new URL(`${AUTH_SERVER}/oauth/authorize`);
|
|
252
|
+
authUrl.search = new URLSearchParams({
|
|
253
|
+
response_type: "code",
|
|
254
|
+
client_id: clientId,
|
|
255
|
+
redirect_uri: redirectUri,
|
|
256
|
+
scope: "openid profile email",
|
|
257
|
+
state,
|
|
258
|
+
code_challenge: challenge,
|
|
259
|
+
code_challenge_method: "S256",
|
|
260
|
+
}).toString();
|
|
261
|
+
let code;
|
|
262
|
+
if (options.catcher) {
|
|
263
|
+
// The run's own server is already listening; nothing to start here.
|
|
264
|
+
info(t(SIGNIN.willOpen, lang));
|
|
265
|
+
console.log(` ${t(SIGNIN.ifNot, lang)}\n ${authUrl.toString()}\n`);
|
|
266
|
+
openBrowser(authUrl.toString());
|
|
267
|
+
code = await options.catcher.wait(state);
|
|
268
|
+
}
|
|
269
|
+
else if (useBrowser) {
|
|
270
|
+
info(t(SIGNIN.willOpen, lang));
|
|
271
|
+
console.log(` ${t(SIGNIN.ifNot, lang)}\n ${authUrl.toString()}\n`);
|
|
272
|
+
openBrowser(authUrl.toString());
|
|
273
|
+
code = await waitForCallback(port, state, lang);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
if (!process.stdin.isTTY) {
|
|
277
|
+
// Nothing to open and nowhere to paste. Say so now rather than sitting
|
|
278
|
+
// on a callback that cannot arrive.
|
|
279
|
+
throw new Error(`no browser here and no terminal to paste into — sign in on a machine that has one, or run: aisa login --key <key>\n ${authUrl.toString()}`);
|
|
280
|
+
}
|
|
281
|
+
console.log(` ${t(SIGNIN.here, lang)}\n`);
|
|
282
|
+
console.log(` ${t(SIGNIN.step1, lang)}\n ${authUrl.toString()}\n`);
|
|
283
|
+
console.log(` ${t(SIGNIN.step2, lang)}\n`);
|
|
284
|
+
console.log(` ${t(SIGNIN.step3, lang)}\n`);
|
|
285
|
+
code = await waitForPaste(state, lang);
|
|
286
|
+
}
|
|
287
|
+
const tokenRes = await httpFetch(`${AUTH_SERVER}/oauth/token`, {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
290
|
+
body: new URLSearchParams({
|
|
291
|
+
grant_type: "authorization_code",
|
|
292
|
+
code,
|
|
293
|
+
redirect_uri: redirectUri,
|
|
294
|
+
client_id: clientId,
|
|
295
|
+
code_verifier: verifier,
|
|
296
|
+
}),
|
|
297
|
+
signal: AbortSignal.timeout(20_000),
|
|
298
|
+
});
|
|
299
|
+
const tokens = (await tokenRes.json());
|
|
300
|
+
if (!tokens.access_token) {
|
|
301
|
+
throw new Error(`token exchange failed: ${tokens.error_description ?? tokens.error ?? tokenRes.status}`);
|
|
302
|
+
}
|
|
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>");
|
|
314
|
+
}
|
|
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;
|
|
321
|
+
}
|
|
322
|
+
export async function oauthLogin(options = {}) {
|
|
323
|
+
if (options.open === false && !process.stdin.isTTY) {
|
|
324
|
+
error("--no-browser needs an interactive terminal to paste the redirect URL into.");
|
|
325
|
+
hint("In scripts, use: aisa login --key <key>");
|
|
326
|
+
process.exitCode = 1;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const key = await mintCliKey(options);
|
|
330
|
+
// The balance follows from the caller, so no "try aisa balance" here: being
|
|
331
|
+
// told to go and check is worse than being shown.
|
|
332
|
+
success(`Signed in — CLI key ${maskKey(key)} stored`);
|
|
333
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Put the terminal back the way a shell expects it. Safe to call twice. */
|
|
2
|
+
export declare function restoreTerminal(): void;
|
|
3
|
+
/**
|
|
4
|
+
* Let a child process own the keyboard.
|
|
5
|
+
*
|
|
6
|
+
* leaveRaw resumes stdin, which is right when this process is about to read
|
|
7
|
+
* it again and wrong when something else is. A resumed stream with no `data`
|
|
8
|
+
* listener still pulls bytes off the file descriptor and drops them — so with
|
|
9
|
+
* stdio inherited, every keystroke was a race between this process and the
|
|
10
|
+
* agent it had just started. The user saw it as characters going missing and
|
|
11
|
+
* lines submitting themselves: enter is the keystroke where losing the race
|
|
12
|
+
* has consequences.
|
|
13
|
+
*
|
|
14
|
+
* Paused, this process reads nothing and the child gets all of it. Anything
|
|
15
|
+
* here that wants stdin back takes it — the pickers call enterRaw and the
|
|
16
|
+
* prompts build a fresh readline, and both resume.
|
|
17
|
+
*/
|
|
18
|
+
export declare function handOffStdin(): void;
|
|
19
|
+
/** Can this terminal deliver arrow keys? */
|
|
20
|
+
export declare function interactive(): boolean;
|
|
21
|
+
export interface Choice {
|
|
22
|
+
/**
|
|
23
|
+
* A short label on the right, said once where a run of rows begins.
|
|
24
|
+
*
|
|
25
|
+
* Eight areas over twenty-five servers, and five of the areas hold exactly
|
|
26
|
+
* one server — so heading rows produced "heading, row, heading, row", which
|
|
27
|
+
* is not a grouping, it is a hat on every line. Sorting by area and naming
|
|
28
|
+
* it only where it changes says the same thing in no extra rows at all: the
|
|
29
|
+
* rows below inherit it from the blank space, which is what a run is.
|
|
30
|
+
*
|
|
31
|
+
* Set it on every row; this decides where to print it. Naming only the
|
|
32
|
+
* first row of a run put the name off-screen for most of a long one — the
|
|
33
|
+
* twelve SEO servers do not fit a window, so scrolling into them lost the
|
|
34
|
+
* word that said what they were. It is printed where the run changes and
|
|
35
|
+
* again at the top of the window, which is the other place a run can begin
|
|
36
|
+
* as far as the reader is concerned.
|
|
37
|
+
*/
|
|
38
|
+
tag?: string;
|
|
39
|
+
/** Left column, already coloured. */
|
|
40
|
+
label: string;
|
|
41
|
+
/** Right column, dimmed. Cut before it can wrap. */
|
|
42
|
+
meta?: string;
|
|
43
|
+
/** Extra lines under the cursor's row only. */
|
|
44
|
+
detail?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Watch for a change made somewhere else while the picker is open.
|
|
48
|
+
*
|
|
49
|
+
* Given a signal that fires the moment the picker is done, because a watcher
|
|
50
|
+
* that outlives it never stops: each step left one behind, polling every 900ms
|
|
51
|
+
* for the life of the process — three steps, three pollers, still running long
|
|
52
|
+
* after the run had finished.
|
|
53
|
+
*/
|
|
54
|
+
export type Interrupt<T> = (signal: AbortSignal,
|
|
55
|
+
/**
|
|
56
|
+
* Repaint with a selection made elsewhere, without ending the picker.
|
|
57
|
+
*
|
|
58
|
+
* A checklist is not answered by its first tick. Treating every change in
|
|
59
|
+
* the page as the answer closed this picker on the first box ticked over
|
|
60
|
+
* there and moved the run on; a person ticking their way down twenty-five
|
|
61
|
+
* servers should see the ✓ marks arrive here and keep going.
|
|
62
|
+
*/
|
|
63
|
+
apply: (indexes: number[]) => void) => Promise<T | undefined>;
|
|
64
|
+
export interface PickResult<T> {
|
|
65
|
+
/** Indexes chosen here, or undefined when something else answered. */
|
|
66
|
+
picked?: number[];
|
|
67
|
+
/** Whatever the interrupt resolved with. */
|
|
68
|
+
interrupted?: T;
|
|
69
|
+
aborted?: boolean;
|
|
70
|
+
/** Escape: the question was left rather than answered. */
|
|
71
|
+
escaped?: boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Draw a picker until the user commits, or `watch` resolves.
|
|
75
|
+
*
|
|
76
|
+
* `multi` decides whether space toggles and enter confirms a set, or the
|
|
77
|
+
* cursor itself is the answer.
|
|
78
|
+
*/
|
|
79
|
+
export declare function pick<T>(opts: {
|
|
80
|
+
title: string;
|
|
81
|
+
choices: Choice[];
|
|
82
|
+
multi: boolean;
|
|
83
|
+
initial?: number[];
|
|
84
|
+
cursor?: number;
|
|
85
|
+
hint: string;
|
|
86
|
+
watch?: Interrupt<T>;
|
|
87
|
+
/**
|
|
88
|
+
* Called whenever the local selection changes, so the other side can follow.
|
|
89
|
+
*
|
|
90
|
+
* On a checklist that is a tick; on a single choice it is the cursor
|
|
91
|
+
* itself, which is the only way this terminal has of saying "this one" —
|
|
92
|
+
* it is painted as chosen, so treating the move as a hover left the page
|
|
93
|
+
* showing nothing while the terminal plainly showed something.
|
|
94
|
+
*/
|
|
95
|
+
onToggle?: (indexes: number[]) => void;
|
|
96
|
+
}): Promise<PickResult<T>>;
|