@aisa-one/cli 0.2.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +172 -1
  2. package/LICENSE +21 -0
  3. package/README.md +163 -169
  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 +43 -9
  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 +79 -2
  38. package/dist/commands/mcp.js +262 -48
  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 +307 -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.js +76 -2
  64. package/dist/constants.d.ts +78 -38
  65. package/dist/constants.js +80 -102
  66. package/dist/index.js +155 -357
  67. package/dist/json-text.d.ts +12 -0
  68. package/dist/json-text.js +60 -0
  69. package/dist/router.d.ts +39 -0
  70. package/dist/router.js +59 -0
  71. package/dist/skills-registry.js +12 -3
  72. package/dist/utils/banner.d.ts +9 -0
  73. package/dist/utils/banner.js +41 -0
  74. package/dist/utils/browser.d.ts +19 -0
  75. package/dist/utils/browser.js +31 -0
  76. package/dist/utils/exec.d.ts +43 -0
  77. package/dist/utils/exec.js +61 -0
  78. package/dist/utils/http.d.ts +53 -0
  79. package/dist/utils/http.js +124 -0
  80. package/dist/utils/install-method.d.ts +36 -0
  81. package/dist/utils/install-method.js +48 -0
  82. package/dist/utils/journal.d.ts +57 -0
  83. package/dist/utils/journal.js +188 -0
  84. package/dist/utils/key-check.d.ts +20 -0
  85. package/dist/utils/key-check.js +24 -0
  86. package/dist/utils/update-check.d.ts +23 -0
  87. package/dist/utils/update-check.js +116 -0
  88. package/dist/utils/width.d.ts +32 -0
  89. package/dist/utils/width.js +73 -0
  90. package/package.json +8 -4
  91. package/vscode-extension/README.md +6 -0
  92. package/vscode-extension/aisa-vscode.vsix +0 -0
  93. package/dist/commands/finance.d.ts +0 -17
  94. package/dist/commands/finance.js +0 -342
  95. package/dist/commands/run.d.ts +0 -28
  96. package/dist/commands/run.js +0 -182
  97. package/dist/commands/search.d.ts +0 -9
  98. package/dist/commands/search.js +0 -81
  99. package/dist/commands/twitter.d.ts +0 -138
  100. package/dist/commands/twitter.js +0 -374
  101. package/dist/commands/video.d.ts +0 -49
  102. package/dist/commands/video.js +0 -311
@@ -0,0 +1,245 @@
1
+ import { createServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import { readFileSync, writeFileSync, unlinkSync, mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { renderT2Page } from "./connect-t2.js";
7
+ import { isInstalled } from "./install.js";
8
+ import { renderDone } from "./connect.js";
9
+ /**
10
+ * Keeping the results page up after the terminal has been handed back.
11
+ *
12
+ * A shell waits on the process it started, so there is no way to return the
13
+ * prompt and keep serving in the same one: the choice used to be the page or
14
+ * the prompt, and picking Exit gave neither — the run said it was handing the
15
+ * terminal back and then sat on it for half an hour.
16
+ *
17
+ * So the page is handed over instead. The foreground process writes down what
18
+ * the page needs, starts a detached copy of itself that serves only that, and
19
+ * exits. The browser tab keeps working on the same URL because the child
20
+ * takes the same port, which the parent has just released.
21
+ *
22
+ * The child holds no state of its own and does nothing but answer: no MCP
23
+ * writes, no installs, no apply route. Whatever the run did is already done.
24
+ */
25
+ /** Where a detached results server writes down that it exists. */
26
+ export function resultsPidPath() {
27
+ return join(tmpdir(), "aisa-connect-results.json");
28
+ }
29
+ /**
30
+ * Close a results page left over from an earlier run.
31
+ *
32
+ * Called at the start of every `connect`: the new run is what the user is
33
+ * looking at now, and an older page still answering on some port only invites
34
+ * reading a result that no longer describes this machine.
35
+ */
36
+ export async function closeStaleResults() {
37
+ let rec;
38
+ try {
39
+ rec = JSON.parse(readFileSync(resultsPidPath(), "utf-8"));
40
+ }
41
+ catch {
42
+ return;
43
+ }
44
+ const forget = () => {
45
+ try {
46
+ unlinkSync(resultsPidPath());
47
+ }
48
+ catch {
49
+ /* nothing to remove */
50
+ }
51
+ };
52
+ if (!rec.pid || !rec.port || !rec.token)
53
+ return forget();
54
+ // Prove it is ours before signalling it. SIGUSR1 terminates a process that
55
+ // has no handler for it, and a pid read out of a file left behind by a
56
+ // crash may belong to something else entirely by now. Answering on that
57
+ // port with that token is the only proof that counts.
58
+ try {
59
+ const res = await fetch(`http://127.0.0.1:${rec.port}/status?token=${rec.token}`, {
60
+ signal: AbortSignal.timeout(1_500),
61
+ });
62
+ if (!res.ok)
63
+ return forget();
64
+ }
65
+ catch {
66
+ return forget();
67
+ }
68
+ try {
69
+ // SIGUSR1, not SIGTERM: killing it outright leaves whoever was reading
70
+ // that tab staring at a page that stopped answering with no explanation.
71
+ // The signal starts a countdown instead, and the page says what happened
72
+ // and how long it has. The child removes its own record when it goes.
73
+ process.kill(rec.pid, "SIGUSR1");
74
+ }
75
+ catch {
76
+ forget();
77
+ }
78
+ }
79
+ /** How long a superseded page stays readable before it closes. */
80
+ export const SUPERSEDE_GRACE_MS = 60 * 1000;
81
+ /**
82
+ * Hand the page to a process that outlives this terminal.
83
+ *
84
+ * Returns false when the handover could not be started, which the caller
85
+ * should treat as "the page is closing" rather than pretending otherwise.
86
+ */
87
+ export function handOverResults(h, closeParent) {
88
+ let file;
89
+ try {
90
+ file = join(mkdtempSync(join(tmpdir(), "aisa-results-")), "handover.json");
91
+ writeFileSync(file, JSON.stringify(h), { mode: 0o600 });
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ // Release the port before the child reaches for it. The child retries
97
+ // anyway — closing a listener is not instantaneous — but starting from a
98
+ // freed port keeps the usual case to a single attempt.
99
+ closeParent();
100
+ try {
101
+ const child = spawn(process.execPath, [process.argv[1], "__serve-results", file], {
102
+ detached: true,
103
+ stdio: "ignore",
104
+ env: process.env,
105
+ });
106
+ // Unreferenced and in its own process group: the shell will not wait for
107
+ // it, and Ctrl-C in the terminal it came from does not reach it.
108
+ child.unref();
109
+ // Leave its pid where the next run will look. A results server is not a
110
+ // run — it cannot apply anything — so it must never block one; but a new
111
+ // `aisa connect` supersedes it, and two pages on one machine showing
112
+ // different runs is worse than one that has closed.
113
+ try {
114
+ writeFileSync(resultsPidPath(),
115
+ // The token travels with it so the next run can prove this record
116
+ // points at our page before it signals the pid inside.
117
+ JSON.stringify({ pid: child.pid, port: h.port, token: h.token, until: h.until }), { mode: 0o600 });
118
+ }
119
+ catch {
120
+ /* the child still runs; it just outlives its own record */
121
+ }
122
+ return true;
123
+ }
124
+ catch {
125
+ try {
126
+ unlinkSync(file);
127
+ }
128
+ catch {
129
+ /* nothing to clean up */
130
+ }
131
+ return false;
132
+ }
133
+ }
134
+ /** Wait for the port the parent just gave up. */
135
+ async function listenWithRetry(srv, port) {
136
+ for (let attempt = 0; attempt < 20; attempt++) {
137
+ const ok = await new Promise((resolve) => {
138
+ const onError = () => resolve(false);
139
+ srv.once("error", onError);
140
+ srv.listen(port, "127.0.0.1", () => {
141
+ srv.off("error", onError);
142
+ resolve(true);
143
+ });
144
+ });
145
+ if (ok)
146
+ return true;
147
+ await new Promise((r) => setTimeout(r, 150));
148
+ }
149
+ return false;
150
+ }
151
+ /**
152
+ * The detached half: serve the done page until its deadline, then stop.
153
+ *
154
+ * Read-only by construction — the routes that change a machine are simply not
155
+ * here, so a page left open cannot start anything.
156
+ */
157
+ export async function serveResultsAction(file) {
158
+ let h;
159
+ try {
160
+ h = JSON.parse(readFileSync(file, "utf-8"));
161
+ }
162
+ catch {
163
+ return;
164
+ }
165
+ try {
166
+ unlinkSync(file);
167
+ }
168
+ catch {
169
+ /* it served its purpose either way */
170
+ }
171
+ const remaining = h.until - Date.now();
172
+ if (remaining <= 0)
173
+ return;
174
+ const srv = createServer((req, res) => {
175
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
176
+ const tokenOk = url.searchParams.get("token") === h.token || req.headers["x-connect-token"] === h.token;
177
+ if (!tokenOk) {
178
+ res.writeHead(403).end("forbidden");
179
+ return;
180
+ }
181
+ if (req.method === "GET" && url.pathname === "/status") {
182
+ // One field, whatever the reason. A page that is about to stop
183
+ // answering should say so whether a newer run took over or the half
184
+ // hour simply ran out — the second was silent, which is the same
185
+ // unexplained dead tab the first one was fixed to avoid.
186
+ const closingAt = supersededUntil ?? h.until;
187
+ res
188
+ .writeHead(200, { "content-type": "application/json" })
189
+ .end(JSON.stringify({ ...h.state, closingAt, superseded: Boolean(supersededUntil) }));
190
+ return;
191
+ }
192
+ if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/done")) {
193
+ const body = h.template === "t2"
194
+ ? renderT2Page(h.servers, h.clients, h.token, h.keyed, h.canInstall, !isInstalled("aisa"), "done", h.lang)
195
+ : renderDone(h.chosenServers, h.chosenClients, h.state.steps, h.servers, h.state.balanceMicros ?? null, h.state.llmMode);
196
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(body);
197
+ return;
198
+ }
199
+ // Everything that could change this machine is absent, not refused: there
200
+ // is no apply, no launch, no select. A tab left open overnight can only
201
+ // read what already happened.
202
+ res.writeHead(404).end();
203
+ });
204
+ if (!(await listenWithRetry(srv, h.port)))
205
+ return;
206
+ // Set when a newer run takes over. The page reads it from /status and shows
207
+ // the countdown; this process closes when it runs out.
208
+ let supersededUntil;
209
+ process.on("SIGUSR1", () => {
210
+ if (supersededUntil)
211
+ return;
212
+ supersededUntil = Date.now() + SUPERSEDE_GRACE_MS;
213
+ setTimeout(() => {
214
+ srv.close();
215
+ process.exit(0);
216
+ }, SUPERSEDE_GRACE_MS).unref?.();
217
+ // Nothing else keeps this alive once the original deadline passes, so
218
+ // hold it open for the grace period explicitly.
219
+ setTimeout(() => process.exit(0), SUPERSEDE_GRACE_MS + 2_000);
220
+ });
221
+ const forget = () => {
222
+ try {
223
+ const rec = JSON.parse(readFileSync(resultsPidPath(), "utf-8"));
224
+ if (rec.pid === process.pid)
225
+ unlinkSync(resultsPidPath());
226
+ }
227
+ catch {
228
+ /* someone else's record, or none */
229
+ }
230
+ };
231
+ process.on("exit", forget);
232
+ for (const sig of ["SIGTERM", "SIGINT"]) {
233
+ process.on(sig, () => {
234
+ forget();
235
+ process.exit(0);
236
+ });
237
+ }
238
+ const stop = setTimeout(() => {
239
+ srv.close();
240
+ process.exit(0);
241
+ }, remaining);
242
+ // The timer alone keeps this alive; nothing else should.
243
+ stop.unref?.();
244
+ setTimeout(() => process.exit(0), remaining + 5_000);
245
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Keeping the sign-in page up after the terminal has gone.
3
+ *
4
+ * A shell waits on the process it started, so the CLI cannot both return the
5
+ * prompt and keep serving. Closing the socket the moment the code arrives is
6
+ * what the CLI did, and it left a page that could not survive a refresh: the
7
+ * user reloads to check it really worked, and the browser says the site
8
+ * cannot be reached — which reads like the sign-in failed.
9
+ *
10
+ * So the page is handed over. The parent releases the port, starts a
11
+ * detached copy of itself that serves that one page, and exits. The tab
12
+ * keeps working on the same address because the child takes the same port.
13
+ *
14
+ * It holds nothing: one static document, no token, no state, no routes that
15
+ * could change anything. The sign-in is already finished by the time this
16
+ * exists.
17
+ */
18
+ /** How long the page keeps answering after the terminal is handed back. */
19
+ export declare const SIGNIN_PAGE_TTL_MS: number;
20
+ /**
21
+ * Hand the page to a process the shell is not waiting on.
22
+ *
23
+ * Returns false when the child could not be started, which the caller should
24
+ * treat as "the page is going away" rather than pretending otherwise.
25
+ */
26
+ export declare function handOverSignInPage(port: number, closeParent: () => void): boolean;
27
+ /** The detached half: serve that one page until its deadline, then stop. */
28
+ export declare function serveSignInAction(portArg: string, untilArg: string): Promise<void>;
@@ -0,0 +1,90 @@
1
+ import { createServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import { renderSignInPage } from "./signin-page.js";
4
+ /**
5
+ * Keeping the sign-in page up after the terminal has gone.
6
+ *
7
+ * A shell waits on the process it started, so the CLI cannot both return the
8
+ * prompt and keep serving. Closing the socket the moment the code arrives is
9
+ * what the CLI did, and it left a page that could not survive a refresh: the
10
+ * user reloads to check it really worked, and the browser says the site
11
+ * cannot be reached — which reads like the sign-in failed.
12
+ *
13
+ * So the page is handed over. The parent releases the port, starts a
14
+ * detached copy of itself that serves that one page, and exits. The tab
15
+ * keeps working on the same address because the child takes the same port.
16
+ *
17
+ * It holds nothing: one static document, no token, no state, no routes that
18
+ * could change anything. The sign-in is already finished by the time this
19
+ * exists.
20
+ */
21
+ /** How long the page keeps answering after the terminal is handed back. */
22
+ export const SIGNIN_PAGE_TTL_MS = 5 * 60 * 1000;
23
+ /**
24
+ * Hand the page to a process the shell is not waiting on.
25
+ *
26
+ * Returns false when the child could not be started, which the caller should
27
+ * treat as "the page is going away" rather than pretending otherwise.
28
+ */
29
+ export function handOverSignInPage(port, closeParent) {
30
+ const until = Date.now() + SIGNIN_PAGE_TTL_MS;
31
+ // Release the port before the child reaches for it. The child retries
32
+ // anyway — closing a listener is not instantaneous — but starting from a
33
+ // freed port keeps the usual case to a single attempt.
34
+ closeParent();
35
+ try {
36
+ const child = spawn(process.execPath, [process.argv[1], "__serve-signin", String(port), String(until)], {
37
+ detached: true,
38
+ stdio: "ignore",
39
+ env: process.env,
40
+ });
41
+ // Unreferenced and in its own process group: the shell will not wait for
42
+ // it, and Ctrl-C in the terminal it came from does not reach it.
43
+ child.unref();
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /** The detached half: serve that one page until its deadline, then stop. */
51
+ export async function serveSignInAction(portArg, untilArg) {
52
+ const port = Number(portArg);
53
+ const until = Number(untilArg);
54
+ if (!port || !until || until <= Date.now())
55
+ return;
56
+ const srv = createServer((_req, res) => {
57
+ res
58
+ .writeHead(200, {
59
+ "content-type": "text/html; charset=utf-8",
60
+ "cache-control": "no-store",
61
+ "referrer-policy": "no-referrer",
62
+ })
63
+ .end(renderSignInPage("ok", until));
64
+ });
65
+ // The parent has just closed its listener; a socket takes a moment to let
66
+ // go, so keep asking rather than giving the page a dead port on the
67
+ // strength of one failed attempt.
68
+ for (let attempt = 0; attempt < 20; attempt++) {
69
+ const ok = await new Promise((resolve) => {
70
+ const onError = () => resolve(false);
71
+ srv.once("error", onError);
72
+ srv.listen(port, "127.0.0.1", () => {
73
+ srv.off("error", onError);
74
+ resolve(true);
75
+ });
76
+ });
77
+ if (ok)
78
+ break;
79
+ if (attempt === 19)
80
+ return;
81
+ await new Promise((r) => setTimeout(r, 150));
82
+ }
83
+ const stop = setTimeout(() => {
84
+ srv.close();
85
+ process.exit(0);
86
+ }, until - Date.now());
87
+ // The timer alone keeps this alive; nothing else should.
88
+ stop.unref?.();
89
+ setTimeout(() => process.exit(0), until - Date.now() + 5_000);
90
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The page the browser lands on when a sign-in finishes on this machine.
3
+ *
4
+ * For a long time this was one unstyled line of HTML served without a
5
+ * charset: two faults in one, because a browser in a Chinese locale decoded
6
+ * the em dash as GBK and rendered it as mojibake. The person reading it has
7
+ * just finished trusting us with their account, and it looked like a crash.
8
+ *
9
+ * Same proportions as https://aisa.one/cli/auth — the other page a sign-in
10
+ * can land on. One of the two is always the last thing a user sees, and they
11
+ * should not look like they came from different products. English on both,
12
+ * for the same reason: one language read by everyone beats two read by
13
+ * halves.
14
+ *
15
+ * Self-contained on purpose. The socket serving it closes moments later, so
16
+ * anything it referenced would already be gone by the time the browser asked
17
+ * — and a stylesheet from another origin carries this page's URL, which is
18
+ * the authorization code.
19
+ */
20
+ export type SignInOutcome = "ok" | "failed" | "expired";
21
+ /**
22
+ * @param closesAt Absolute epoch ms when this page stops being served.
23
+ * Given one, the page counts itself down near the end.
24
+ */
25
+ export declare function renderSignInPage(outcome: SignInOutcome, closesAt?: number): string;
26
+ /**
27
+ * The page a sign-in started from inside `connect` lands on.
28
+ *
29
+ * The difference from the one above is entirely about what happens next.
30
+ * Standalone `aisa login` is finished when the browser lands: "you can close
31
+ * this tab" is the whole of the remaining instruction. Inside a run it is
32
+ * not — a setup is still going one tab over, and the user has just been sent
33
+ * away from it by a redirect they did not choose. Leaving them on a
34
+ * congratulations page with a close button makes finding the way back their
35
+ * problem.
36
+ *
37
+ * So this one goes back on its own, after a beat long enough to read the
38
+ * tick. window.close() is tried first and will almost always fail — the tab
39
+ * was opened by the operating system, not by a script, and browsers do not
40
+ * let a page close what it did not open — which is exactly why the redirect
41
+ * cannot be left to it. A plain link sits underneath for anyone whose
42
+ * browser blocks both.
43
+ */
44
+ export type ReturnOutcome = "ok" | "failed" | "stale";
45
+ export declare function renderReturnPage(outcome: ReturnOutcome, back: string, copy: {
46
+ title: string;
47
+ body: string;
48
+ link: string;
49
+ }): string;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The page the browser lands on when a sign-in finishes on this machine.
3
+ *
4
+ * For a long time this was one unstyled line of HTML served without a
5
+ * charset: two faults in one, because a browser in a Chinese locale decoded
6
+ * the em dash as GBK and rendered it as mojibake. The person reading it has
7
+ * just finished trusting us with their account, and it looked like a crash.
8
+ *
9
+ * Same proportions as https://aisa.one/cli/auth — the other page a sign-in
10
+ * can land on. One of the two is always the last thing a user sees, and they
11
+ * should not look like they came from different products. English on both,
12
+ * for the same reason: one language read by everyone beats two read by
13
+ * halves.
14
+ *
15
+ * Self-contained on purpose. The socket serving it closes moments later, so
16
+ * anything it referenced would already be gone by the time the browser asked
17
+ * — and a stylesheet from another origin carries this page's URL, which is
18
+ * the authorization code.
19
+ */
20
+ /** Fixed to the bottom; quiet until the final minute. */
21
+ /**
22
+ * What every ending looks like, here and in the connect page.
23
+ *
24
+ * This was a line along the bottom, silent until the last minute. It was
25
+ * honest and it was also easy to miss — a page nobody is watching any more
26
+ * ends without saying so, and the reader comes back to a tab that has
27
+ * quietly stopped working. A dialog cannot be missed, and it is the shape
28
+ * the connect page already uses when its own time runs out, so the two
29
+ * surfaces end the same way.
30
+ *
31
+ * It carries the two lines every ending carries: where the account lives,
32
+ * and the command that starts the next one. Addresses rather than buttons —
33
+ * a button is clicked once and forgotten; `console.aisa.one` and
34
+ * `aisa connect` are things someone can still type next week.
35
+ */
36
+ const ENDING_MARKUP = [
37
+ '<div class="ending" id="ending" hidden><div class="endbox">',
38
+ ' <h2 id="endtitle"></h2>',
39
+ ' <p id="endbody"></p>',
40
+ ' <div class="endcta">',
41
+ ' <p>Usage, spending and top-ups live at <a href="https://console.aisa.one?source=aisa_cli_signin">console.aisa.one</a>.</p>',
42
+ ' <p>Run <code>aisa connect</code> any time \u2014 add servers, switch models, or connect another agent.</p>',
43
+ " </div>",
44
+ "</div></div>",
45
+ "<scr" + "ipt>",
46
+ "(function () {",
47
+ " var until = __UNTIL__;",
48
+ ' var box = document.getElementById("ending");',
49
+ ' var t = document.getElementById("endtitle"), b = document.getElementById("endbody");',
50
+ " function show(title, body) {",
51
+ " t.textContent = title; b.textContent = body; box.hidden = false;",
52
+ " }",
53
+ " function tick() {",
54
+ " var left = Math.ceil((until - Date.now()) / 1000);",
55
+ " if (left <= 0) {",
56
+ ' show("This page has closed", "Nothing here is needed any more \u2014 your key was saved on the machine you signed in from.");',
57
+ " return;",
58
+ " }",
59
+ // Silent until the last minute, as before: a countdown running for five
60
+ // minutes is furniture. What changed is that it ends in a dialog rather
61
+ // than in a line nobody was looking at.
62
+ " if (left > 60) { setTimeout(tick, 1000); return; }",
63
+ ' show("This page closes in " + left + " seconds", "You can close it now \u2014 your key is already saved.");',
64
+ " setTimeout(tick, 1000);",
65
+ " }",
66
+ " tick();",
67
+ "})();",
68
+ "</scr" + "ipt>",
69
+ ].join("\n");
70
+ const COPY = {
71
+ ok: {
72
+ kicker: "SIGNED IN",
73
+ title: "You're all set",
74
+ body: "Your key was created on the machine you started from — it never travelled through this browser. You can close this tab.",
75
+ /**
76
+ * The one moment a mention of the console is welcome rather than in the
77
+ * way — and a sentence, not a button.
78
+ *
79
+ * A button is a demand for attention, and this person's attention
80
+ * belongs back in the terminal they started from. It also teaches them
81
+ * nothing: they click it once and could not tell you afterwards where
82
+ * they went. The address, written out and worth clicking, is the thing
83
+ * they can still type next week. Carries the source parameter the CLI's
84
+ * other console links use, with its own value, so this surface can be
85
+ * told apart from someone typing it from memory — which is the point.
86
+ */
87
+ cta: {
88
+ href: "https://console.aisa.one?source=aisa_cli_signin",
89
+ domain: "console.aisa.one",
90
+ before: "Usage, spending and API keys live at ",
91
+ after: ".",
92
+ },
93
+ },
94
+ failed: {
95
+ kicker: "NOT COMPLETED",
96
+ title: "Sign-in was not completed",
97
+ body: "Nothing was changed. Return to your terminal and run <code>aisa login</code> again.",
98
+ },
99
+ expired: {
100
+ kicker: "EXPIRED",
101
+ title: "This sign-in timed out",
102
+ body: "The terminal stopped waiting before you got here, so this approval has nowhere to go. Nothing was changed — run <code>aisa login</code> again and it will take a few seconds.",
103
+ },
104
+ };
105
+ /**
106
+ * @param closesAt Absolute epoch ms when this page stops being served.
107
+ * Given one, the page counts itself down near the end.
108
+ */
109
+ export function renderSignInPage(outcome, closesAt) {
110
+ const c = COPY[outcome];
111
+ const good = outcome === "ok";
112
+ return `<!doctype html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="utf-8">
116
+ <meta name="viewport" content="width=device-width,initial-scale=1">
117
+ <meta name="referrer" content="no-referrer">
118
+ <meta name="robots" content="noindex,nofollow">
119
+ <title>AIsa CLI</title>
120
+ <style>
121
+ :root{
122
+ --bg:#faf9f7; --fg:#1c1917; --dim:#6f6864; --faint:#a8a29e;
123
+ --line:#e7e4e0; --accent:${good ? "#c2410c" : "#9a3412"};
124
+ --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
125
+ }
126
+ @media (prefers-color-scheme:dark){
127
+ :root{--bg:#0d0c0b; --fg:#f5f4f2; --dim:#a09a95; --faint:#6f6864;
128
+ --line:#282523; --accent:${good ? "#e2703a" : "#c2410c"}}
129
+ }
130
+ *{box-sizing:border-box}
131
+ html,body{height:100%}
132
+ body{margin:0;display:flex;align-items:center;justify-content:center;
133
+ padding:48px 32px;background:var(--bg);color:var(--fg);
134
+ font:17px/1.65 ui-sans-serif,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
135
+ -webkit-font-smoothing:antialiased}
136
+ .wrap{width:min(720px,74vw)}
137
+ .tick{display:flex;align-items:center;gap:12px;margin-bottom:28px}
138
+ .tick span{width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;
139
+ display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 auto}
140
+ .tick b{font-size:15px;font-weight:600;letter-spacing:.02em;color:var(--dim)}
141
+ h1{font-size:34px;line-height:1.25;margin:0 0 16px;letter-spacing:-.021em;font-weight:650}
142
+ p{margin:0;font-size:18px;color:var(--dim);max-width:32em}
143
+ code{font-family:var(--mono);font-size:16px;color:var(--fg)}
144
+ .cta{margin:22px 0 0;font-size:17px;color:var(--dim);max-width:32em}
145
+ .ending{position:fixed;inset:0;z-index:9;display:flex;align-items:center;justify-content:center;
146
+ padding:32px;background:color-mix(in srgb,var(--bg) 78%,transparent);backdrop-filter:blur(3px)}
147
+ .ending[hidden]{display:none}
148
+ .endbox{width:min(520px,100%);background:var(--bg);border:1px solid var(--line);
149
+ border-radius:14px;padding:26px 28px;box-shadow:0 18px 48px rgba(0,0,0,.14)}
150
+ .endbox h2{font-size:21px;margin:0 0 10px;letter-spacing:-.012em}
151
+ .endbox p{font-size:15.5px;margin:0}
152
+ .endcta{margin:18px 0 0;padding:15px 0 0;border-top:1px solid var(--line)}
153
+ .endcta p{font-size:14px;line-height:1.55;margin:0 0 6px;color:var(--dim)}
154
+ .endcta p:last-child{margin-bottom:0}
155
+ .endcta a{color:var(--accent);font-family:var(--mono);text-decoration:none;
156
+ border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent)}
157
+ .endcta a:hover{border-bottom-color:var(--accent)}
158
+ .endcta code{font-family:var(--mono);font-size:13.5px;color:var(--fg);
159
+ background:color-mix(in srgb,var(--fg) 6%,transparent);border-radius:5px;padding:1px 5px}
160
+ .cta a{color:var(--accent);text-decoration:none;font-family:var(--mono);
161
+ font-size:16px;border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent)}
162
+ .cta a:hover{border-bottom-color:var(--accent)}
163
+ @media (max-width:760px){
164
+ body{padding:32px 20px} .wrap{width:100%}
165
+ h1{font-size:27px} p{font-size:16.5px}
166
+ .cta{font-size:16px}
167
+ }
168
+ </style>
169
+ </head>
170
+ <body>
171
+ <div class="wrap">
172
+ <div class="tick"><span>${good ? "✓" : "!"}</span><b>${c.kicker}</b></div>
173
+ <h1>${c.title}</h1>
174
+ <p>${c.body}</p>
175
+ ${c.cta
176
+ ? `<p class="cta">${c.cta.before}<a href="${c.cta.href}" rel="noopener">${c.cta.domain}</a>${c.cta.after}</p>`
177
+ : ""}
178
+ </div>
179
+ ${closesAt ? ENDING_MARKUP.replace("__UNTIL__", String(closesAt)) : ""}
180
+ </body>
181
+ </html>`;
182
+ }
183
+ const RETURN_DWELL_MS = 1400;
184
+ export function renderReturnPage(outcome, back, copy) {
185
+ const good = outcome === "ok";
186
+ // Only a successful sign-in is sent back on its own. A failure has
187
+ // something to read, and bouncing someone off an explanation before they
188
+ // have read it is how a run becomes a mystery.
189
+ const auto = good;
190
+ return `<!doctype html>
191
+ <html lang="en">
192
+ <head>
193
+ <meta charset="utf-8">
194
+ <meta name="viewport" content="width=device-width,initial-scale=1">
195
+ <meta name="referrer" content="no-referrer">
196
+ <meta name="robots" content="noindex,nofollow">
197
+ <title>AIsa CLI</title>
198
+ <style>
199
+ :root{
200
+ --bg:#faf9f7; --fg:#1c1917; --dim:#6f6864; --faint:#a8a29e;
201
+ --line:#e7e4e0; --accent:${good ? "#c2410c" : "#9a3412"};
202
+ --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
203
+ }
204
+ @media (prefers-color-scheme:dark){
205
+ :root{--bg:#0d0c0b; --fg:#f5f4f2; --dim:#a09a95; --faint:#6f6864;
206
+ --line:#282523; --accent:${good ? "#e2703a" : "#c2410c"}}
207
+ }
208
+ *{box-sizing:border-box}
209
+ html,body{height:100%}
210
+ body{margin:0;display:flex;align-items:center;justify-content:center;
211
+ padding:48px 32px;background:var(--bg);color:var(--fg);
212
+ font:17px/1.65 ui-sans-serif,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
213
+ -webkit-font-smoothing:antialiased}
214
+ .wrap{width:min(720px,74vw)}
215
+ .tick{display:flex;align-items:center;gap:12px;margin-bottom:28px}
216
+ .tick span{width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;
217
+ display:flex;align-items:center;justify-content:center;font-size:15px;flex:0 0 auto}
218
+ h1{font-size:34px;line-height:1.25;margin:0 0 16px;letter-spacing:-.021em;font-weight:650}
219
+ p{margin:0;font-size:18px;color:var(--dim);max-width:32em}
220
+ .cta{margin:22px 0 0;font-size:17px}
221
+ .cta a{color:var(--accent);text-decoration:none;font-family:var(--mono);font-size:16px;
222
+ border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent)}
223
+ .cta a:hover{border-bottom-color:var(--accent)}
224
+ @media (max-width:760px){
225
+ body{padding:32px 20px} .wrap{width:100%}
226
+ h1{font-size:27px} p{font-size:16.5px} .cta{font-size:16px}
227
+ }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <div class="wrap">
232
+ <div class="tick"><span>${good ? "✓" : "!"}</span></div>
233
+ <h1>${copy.title}</h1>
234
+ <p>${copy.body}</p>
235
+ ${back ? `<p class="cta"><a id="back" href="${back}">${copy.link}</a></p>` : ""}
236
+ </div>
237
+ <script>
238
+ (function () {
239
+ var el = document.getElementById("back");
240
+ if (!el) return;
241
+ var back = el.getAttribute("href");
242
+ ${auto ? `setTimeout(function () {
243
+ try { window.close(); } catch (e) { /* not ours to close */ }
244
+ location.replace(back);
245
+ }, ${RETURN_DWELL_MS});` : ""}
246
+ })();
247
+ </script>
248
+ </body>
249
+ </html>`;
250
+ }