@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +165 -1
  2. package/LICENSE +21 -0
  3. package/README.md +164 -170
  4. package/dist/api.d.ts +10 -0
  5. package/dist/api.js +14 -3
  6. package/dist/catalog.d.ts +1 -18
  7. package/dist/catalog.js +1 -24
  8. package/dist/cli-error.d.ts +17 -0
  9. package/dist/cli-error.js +25 -0
  10. package/dist/cli-guidance.d.ts +14 -0
  11. package/dist/cli-guidance.js +55 -0
  12. package/dist/commands/account.d.ts +17 -0
  13. package/dist/commands/account.js +45 -1
  14. package/dist/commands/api.d.ts +0 -11
  15. package/dist/commands/api.js +6 -175
  16. package/dist/commands/auth.d.ts +2 -1
  17. package/dist/commands/auth.js +45 -11
  18. package/dist/commands/brand-logos.d.ts +6 -0
  19. package/dist/commands/brand-logos.js +21 -0
  20. package/dist/commands/configCmd.js +8 -3
  21. package/dist/commands/connect-shared.d.ts +136 -0
  22. package/dist/commands/connect-shared.js +143 -0
  23. package/dist/commands/connect-t2.d.ts +18 -0
  24. package/dist/commands/connect-t2.js +1599 -0
  25. package/dist/commands/connect-terminal.d.ts +65 -0
  26. package/dist/commands/connect-terminal.js +822 -0
  27. package/dist/commands/connect.d.ts +31 -0
  28. package/dist/commands/connect.js +3181 -0
  29. package/dist/commands/flow.d.ts +685 -0
  30. package/dist/commands/flow.js +697 -0
  31. package/dist/commands/install.d.ts +91 -0
  32. package/dist/commands/install.js +258 -0
  33. package/dist/commands/llm-config.d.ts +103 -0
  34. package/dist/commands/llm-config.js +452 -0
  35. package/dist/commands/manifest.d.ts +68 -0
  36. package/dist/commands/manifest.js +90 -0
  37. package/dist/commands/mcp.d.ts +59 -0
  38. package/dist/commands/mcp.js +122 -44
  39. package/dist/commands/oauth-login.d.ts +33 -0
  40. package/dist/commands/oauth-login.js +333 -0
  41. package/dist/commands/prompt.d.ts +96 -0
  42. package/dist/commands/prompt.js +397 -0
  43. package/dist/commands/serve-results.d.ts +61 -0
  44. package/dist/commands/serve-results.js +245 -0
  45. package/dist/commands/serve-signin.d.ts +28 -0
  46. package/dist/commands/serve-signin.js +90 -0
  47. package/dist/commands/signin-page.d.ts +49 -0
  48. package/dist/commands/signin-page.js +250 -0
  49. package/dist/commands/skills.js +87 -50
  50. package/dist/commands/tool-help.d.ts +69 -0
  51. package/dist/commands/tool-help.js +189 -0
  52. package/dist/commands/tool-input.d.ts +22 -0
  53. package/dist/commands/tool-input.js +220 -0
  54. package/dist/commands/tools.d.ts +12 -0
  55. package/dist/commands/tools.js +306 -0
  56. package/dist/commands/update.d.ts +11 -0
  57. package/dist/commands/update.js +92 -0
  58. package/dist/commands/vscode.d.ts +65 -0
  59. package/dist/commands/vscode.js +173 -0
  60. package/dist/commands/wrappers.d.ts +23 -0
  61. package/dist/commands/wrappers.js +102 -0
  62. package/dist/completion.js +3 -80
  63. package/dist/config.d.ts +3 -0
  64. package/dist/config.js +80 -3
  65. package/dist/constants.d.ts +51 -38
  66. package/dist/constants.js +53 -109
  67. package/dist/index.js +152 -355
  68. package/dist/json-text.d.ts +12 -0
  69. package/dist/json-text.js +60 -0
  70. package/dist/router.d.ts +39 -0
  71. package/dist/router.js +59 -0
  72. package/dist/skills-registry.js +12 -3
  73. package/dist/utils/banner.d.ts +9 -0
  74. package/dist/utils/banner.js +41 -0
  75. package/dist/utils/browser.d.ts +19 -0
  76. package/dist/utils/browser.js +31 -0
  77. package/dist/utils/exec.d.ts +43 -0
  78. package/dist/utils/exec.js +61 -0
  79. package/dist/utils/http.d.ts +53 -0
  80. package/dist/utils/http.js +124 -0
  81. package/dist/utils/install-method.d.ts +36 -0
  82. package/dist/utils/install-method.js +48 -0
  83. package/dist/utils/journal.d.ts +57 -0
  84. package/dist/utils/journal.js +188 -0
  85. package/dist/utils/key-check.d.ts +20 -0
  86. package/dist/utils/key-check.js +24 -0
  87. package/dist/utils/update-check.d.ts +23 -0
  88. package/dist/utils/update-check.js +116 -0
  89. package/dist/utils/width.d.ts +32 -0
  90. package/dist/utils/width.js +73 -0
  91. package/package.json +8 -4
  92. package/vscode-extension/README.md +6 -0
  93. package/vscode-extension/aisa-vscode.vsix +0 -0
  94. package/dist/commands/finance.d.ts +0 -17
  95. package/dist/commands/finance.js +0 -342
  96. package/dist/commands/run.d.ts +0 -28
  97. package/dist/commands/run.js +0 -182
  98. package/dist/commands/search.d.ts +0 -9
  99. package/dist/commands/search.js +0 -81
  100. package/dist/commands/twitter.d.ts +0 -138
  101. package/dist/commands/twitter.js +0 -374
  102. package/dist/commands/video.d.ts +0 -49
  103. package/dist/commands/video.js +0 -311
@@ -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
- async function fetchLiveServers() {
10
- const res = await fetch(MCP_MANIFEST_URL, { signal: AbortSignal.timeout(15_000) });
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("manifest lists no live servers");
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 targets = options.agent && options.agent !== "all" ? [options.agent] : Object.keys(MCP_CONFIGS);
45
- for (const agent of targets) {
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
- for (const agent of targets) {
76
- const config = MCP_CONFIGS[agent];
77
- const filePath = expandHome(config.path);
78
- let existing = {};
79
- if (existsSync(filePath)) {
80
- try {
81
- existing = JSON.parse(readFileSync(filePath, "utf-8"));
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
- entries[`aisa-${s.slug}`] = buildEntry(config.shape, s.endpoint, key);
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
- // The docs-search MCP is tiny, unauthenticated, and answers "how do I call
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>>;