@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
|
@@ -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
|
+
}
|