@sailresearch/code 0.1.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.
- package/README.md +87 -0
- package/bin/sail-code.js +7 -0
- package/package.json +30 -0
- package/src/cli.js +166 -0
- package/src/constants.js +177 -0
- package/src/credentials.js +283 -0
- package/src/login.js +453 -0
- package/src/run.js +328 -0
- package/src/setup.js +482 -0
- package/src/toml.js +143 -0
package/src/login.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser device-flow login against the Sail dashboard's `/cli-auth` page.
|
|
3
|
+
*
|
|
4
|
+
* We open `{app}/cli-auth?name=…&redirect_uri=http://127.0.0.1:{port}/callback&state=…`
|
|
5
|
+
* in the user's browser. After the user authenticates, the page mints an API
|
|
6
|
+
* key and form-POSTs `api_key`, `state`, `mode`, and `api_url` back to our
|
|
7
|
+
* loopback listener. The key is validated against the returned API URL with a
|
|
8
|
+
* cheap authenticated call (`GET /v1/models`) before being stored.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_APP_URL, MODE_API_URLS, MODE_VALUES } from "./constants.js";
|
|
16
|
+
import {
|
|
17
|
+
authPath,
|
|
18
|
+
maskSecret,
|
|
19
|
+
saveAuthKey,
|
|
20
|
+
saveLoginSettings,
|
|
21
|
+
} from "./credentials.js";
|
|
22
|
+
|
|
23
|
+
const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
24
|
+
const MAX_CALLBACK_BODY_BYTES = 64 * 1024;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reject a login callback that landed on a different environment than the
|
|
28
|
+
* one the invocation explicitly requested (via --mode, SAIL_MODE,
|
|
29
|
+
* SAIL_API_URL, or a stored config target). Without this, `SAIL_API_URL=…
|
|
30
|
+
* sail-code` with no stored key would let the default (prod) browser login
|
|
31
|
+
* silently retarget the session and store a key tagged for the wrong
|
|
32
|
+
* environment. The implicit prod default is not enforced, so SAIL_APP_URL
|
|
33
|
+
* driven logins against other environments stay possible. Exported for tests.
|
|
34
|
+
*/
|
|
35
|
+
export function assertLoginTargetMatches({
|
|
36
|
+
expectedTarget,
|
|
37
|
+
expectedSource,
|
|
38
|
+
apiUrl,
|
|
39
|
+
}) {
|
|
40
|
+
if (!expectedTarget || expectedSource === "default") return;
|
|
41
|
+
const expected = expectedTarget.trim().replace(/\/+$/, "");
|
|
42
|
+
if (expected !== "" && expected !== apiUrl) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`this login is for ${apiUrl}, but ${expectedSource} requests ${expected}; ` +
|
|
45
|
+
`set SAIL_APP_URL to that environment's web app and try again`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fail *before* opening the browser when the dashboard we'd open cannot mint a
|
|
52
|
+
* key for the requested environment. The only unambiguous case is the default
|
|
53
|
+
* (prod) web app paired with an explicit non-prod/custom target (`--mode dev`,
|
|
54
|
+
* `SAIL_MODE=staging`, a non-prod `SAIL_API_URL`, or a stored non-prod target):
|
|
55
|
+
* that app can only mint a production key, which `assertLoginTargetMatches`
|
|
56
|
+
* would later reject — but only after the dashboard already minted an unused
|
|
57
|
+
* prod credential. Preflighting here avoids that orphan key. A custom
|
|
58
|
+
* `SAIL_APP_URL` is left alone: it may legitimately mint the requested
|
|
59
|
+
* environment's key, and the loopback callback + `assertLoginTargetMatches`
|
|
60
|
+
* adjudicate it. Exported for tests. `appUrl` is the normalized dashboard URL.
|
|
61
|
+
*/
|
|
62
|
+
export function assertLoginAppCanMintTarget({
|
|
63
|
+
expectedTarget,
|
|
64
|
+
expectedSource,
|
|
65
|
+
appUrl,
|
|
66
|
+
}) {
|
|
67
|
+
if (!expectedTarget || expectedSource === "default") return;
|
|
68
|
+
const expected = expectedTarget.trim().replace(/\/+$/, "");
|
|
69
|
+
const defaultApp = DEFAULT_APP_URL.replace(/\/+$/, "");
|
|
70
|
+
const prod = MODE_API_URLS.prod.replace(/\/+$/, "");
|
|
71
|
+
if (appUrl === defaultApp && expected !== "" && expected !== prod) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`logging in at ${defaultApp} can only mint a production key, but ` +
|
|
74
|
+
`${expectedSource} requests ${expected}. Set SAIL_APP_URL to that ` +
|
|
75
|
+
`environment's web app, or set SAIL_API_KEY to a key for it.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run the browser login flow and persist the credential. Returns
|
|
82
|
+
* `{ apiKey, apiUrl }`. `expectedTarget`/`expectedSource` (from
|
|
83
|
+
* `resolveTarget`) pin the environment this invocation asked for: a callback
|
|
84
|
+
* for a different one fails loudly rather than storing a key tagged for the
|
|
85
|
+
* wrong target.
|
|
86
|
+
*/
|
|
87
|
+
export async function browserLogin({
|
|
88
|
+
expectedTarget,
|
|
89
|
+
expectedSource,
|
|
90
|
+
_readPastedKey = readPastedKeyFromStdin,
|
|
91
|
+
} = {}) {
|
|
92
|
+
const appUrl = (process.env.SAIL_APP_URL?.trim() || DEFAULT_APP_URL).replace(
|
|
93
|
+
/\/+$/,
|
|
94
|
+
"",
|
|
95
|
+
);
|
|
96
|
+
// Bail before minting an unusable key if the dashboard can't serve the
|
|
97
|
+
// requested environment (e.g. `--mode dev` with the default prod app).
|
|
98
|
+
assertLoginAppCanMintTarget({ expectedTarget, expectedSource, appUrl });
|
|
99
|
+
const state = crypto.randomBytes(32).toString("hex");
|
|
100
|
+
|
|
101
|
+
const { server, port, callback } = await startCallbackServer(state);
|
|
102
|
+
const loginUrl =
|
|
103
|
+
`${appUrl}/cli-auth?name=sail-code` +
|
|
104
|
+
`&redirect_uri=${encodeURIComponent(`http://127.0.0.1:${port}/callback`)}` +
|
|
105
|
+
`&state=${state}`;
|
|
106
|
+
// Same page without redirect_uri/state renders the key as copyable text
|
|
107
|
+
// instead of auto-POSTing it — the manual path for when the browser can't
|
|
108
|
+
// reach this machine's loopback (SSH, WSL, a container).
|
|
109
|
+
const manualUrl = `${appUrl}/cli-auth?name=sail-code`;
|
|
110
|
+
|
|
111
|
+
console.error(`Opening browser to log in to Sail…\n ${loginUrl}\n`);
|
|
112
|
+
openBrowser(loginUrl);
|
|
113
|
+
console.error(
|
|
114
|
+
"If your browser opened on a different machine (SSH/WSL/container) and " +
|
|
115
|
+
"the login doesn't complete on its own, open this URL instead, then " +
|
|
116
|
+
"paste the key here and press Enter:\n" +
|
|
117
|
+
` ${manualUrl}\n`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// The manual paste yields only the key; its environment is this invocation's
|
|
121
|
+
// resolved target (or prod by default). validateKey below rejects a paste
|
|
122
|
+
// for the wrong environment, so a mismatch fails loudly rather than storing
|
|
123
|
+
// a mis-tagged key.
|
|
124
|
+
const manualApiUrl = manualPasteApiUrl(expectedTarget);
|
|
125
|
+
// Only SAIL_APP_URL customized the dashboard (no --mode / SAIL_API_URL): the
|
|
126
|
+
// loopback callback would learn that app's API URL from the POST, but the
|
|
127
|
+
// paste path has no such signal and manualApiUrl falls back to prod. Storing
|
|
128
|
+
// a key minted by a custom dashboard as prod is wrong, so the paste path
|
|
129
|
+
// fails closed here and asks for an explicit SAIL_API_URL. (The loopback
|
|
130
|
+
// path is unaffected and still works for a local browser.)
|
|
131
|
+
const pasteTargetAmbiguous = pasteTargetIsAmbiguous({
|
|
132
|
+
appUrl,
|
|
133
|
+
expectedSource,
|
|
134
|
+
});
|
|
135
|
+
const paste = _readPastedKey();
|
|
136
|
+
|
|
137
|
+
let result;
|
|
138
|
+
try {
|
|
139
|
+
result = await Promise.race([
|
|
140
|
+
callback,
|
|
141
|
+
paste.promise.then((apiKey) => {
|
|
142
|
+
if (pasteTargetAmbiguous) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`SAIL_APP_URL points at ${appUrl}, but no SAIL_API_URL was set, so ` +
|
|
145
|
+
"a pasted key's environment can't be determined (it would be " +
|
|
146
|
+
"stored as production). Re-run with SAIL_API_URL set to that " +
|
|
147
|
+
"environment's API endpoint, or complete the login in a local " +
|
|
148
|
+
"browser.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
api_key: apiKey,
|
|
153
|
+
// Recover the named mode from the resolved target so a pasted login
|
|
154
|
+
// for `--mode dev`/SAIL_MODE=staging persists the mode (not just the
|
|
155
|
+
// api_url override) to ~/.sail — otherwise the Sail CLI/SDK keep prod
|
|
156
|
+
// defaults for sibling endpoints (sailbox/imagebuilder). The loopback
|
|
157
|
+
// path gets this from the page; the paste path has only the URL.
|
|
158
|
+
mode: modeForApiUrl(manualApiUrl),
|
|
159
|
+
api_url: manualApiUrl,
|
|
160
|
+
};
|
|
161
|
+
}),
|
|
162
|
+
]);
|
|
163
|
+
} finally {
|
|
164
|
+
server.close();
|
|
165
|
+
// Node 18's close() leaves idle keep-alive sockets (e.g. the browser's)
|
|
166
|
+
// open for their timeout, which would keep the process alive post-login.
|
|
167
|
+
server.closeAllConnections?.();
|
|
168
|
+
paste.cancel();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const apiUrl = result.api_url?.trim().replace(/\/+$/, "");
|
|
172
|
+
if (!apiUrl) {
|
|
173
|
+
throw new Error("login callback did not include an API URL — try again");
|
|
174
|
+
}
|
|
175
|
+
const mode = MODE_VALUES.includes(result.mode) ? result.mode : null;
|
|
176
|
+
|
|
177
|
+
assertLoginTargetMatches({ expectedTarget, expectedSource, apiUrl });
|
|
178
|
+
|
|
179
|
+
await validateKey(result.api_key, apiUrl);
|
|
180
|
+
|
|
181
|
+
saveAuthKey(result.api_key);
|
|
182
|
+
saveLoginSettings({ target: apiUrl, mode });
|
|
183
|
+
console.error(
|
|
184
|
+
`Logged in. Credential ${maskSecret(result.api_key)} saved to ${authPath()}.`,
|
|
185
|
+
);
|
|
186
|
+
return { apiKey: result.api_key, apiUrl };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A cheap authenticated call proves the key works before we store it. */
|
|
190
|
+
async function validateKey(apiKey, apiUrl) {
|
|
191
|
+
let res;
|
|
192
|
+
try {
|
|
193
|
+
res = await fetch(`${apiUrl}/v1/models`, {
|
|
194
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
195
|
+
signal: AbortSignal.timeout(30_000),
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`could not validate the API key against ${apiUrl} (${err.message})`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (res.status === 401 || res.status === 403) {
|
|
203
|
+
throw new Error(`API key rejected by ${apiUrl} (HTTP ${res.status})`);
|
|
204
|
+
}
|
|
205
|
+
if (!res.ok) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`could not validate the API key against ${apiUrl} (HTTP ${res.status})`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Loopback listener for the `/cli-auth` form POST. Requests with a wrong
|
|
214
|
+
* state or shape get an error response and the listener keeps waiting, so a
|
|
215
|
+
* stray request cannot hijack or kill the login.
|
|
216
|
+
*/
|
|
217
|
+
function startCallbackServer(expectedState) {
|
|
218
|
+
return new Promise((resolveServer, rejectServer) => {
|
|
219
|
+
let settle;
|
|
220
|
+
const callback = new Promise((resolve, reject) => {
|
|
221
|
+
settle = { resolve, reject };
|
|
222
|
+
setTimeout(
|
|
223
|
+
() =>
|
|
224
|
+
reject(
|
|
225
|
+
new Error(
|
|
226
|
+
"timed out waiting for the browser login — run `sail-code login` to retry, " +
|
|
227
|
+
"or create a key at the Sail dashboard and export SAIL_API_KEY",
|
|
228
|
+
),
|
|
229
|
+
),
|
|
230
|
+
LOGIN_TIMEOUT_MS,
|
|
231
|
+
).unref();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Any throw in here must answer the request, never kill the process: a
|
|
235
|
+
// stray or hostile local request may not end the login flow.
|
|
236
|
+
const reject400 = (res) => {
|
|
237
|
+
try {
|
|
238
|
+
res
|
|
239
|
+
.writeHead(400, { "Content-Type": "text/plain" })
|
|
240
|
+
.end("invalid login callback");
|
|
241
|
+
} catch {
|
|
242
|
+
// Socket already gone; nothing to answer.
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const server = http.createServer((req, res) => {
|
|
246
|
+
try {
|
|
247
|
+
handleCallbackRequest(req, res);
|
|
248
|
+
} catch {
|
|
249
|
+
reject400(res);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
function handleCallbackRequest(req, res) {
|
|
254
|
+
if (
|
|
255
|
+
req.method !== "POST" ||
|
|
256
|
+
new URL(req.url, "http://127.0.0.1").pathname !== "/callback"
|
|
257
|
+
) {
|
|
258
|
+
res.writeHead(404, { "Content-Type": "text/plain" }).end("not found");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let body = "";
|
|
262
|
+
let overflow = false;
|
|
263
|
+
req.on("data", (chunk) => {
|
|
264
|
+
body += chunk;
|
|
265
|
+
if (body.length > MAX_CALLBACK_BODY_BYTES) {
|
|
266
|
+
overflow = true;
|
|
267
|
+
req.destroy();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
req.on("end", () => {
|
|
271
|
+
if (overflow) return;
|
|
272
|
+
try {
|
|
273
|
+
const params = new URLSearchParams(body);
|
|
274
|
+
const state = params.get("state") ?? "";
|
|
275
|
+
const apiKey = params.get("api_key") ?? "";
|
|
276
|
+
// Compare byte buffers: timingSafeEqual throws on unequal lengths,
|
|
277
|
+
// and multibyte characters make string length ≠ byte length.
|
|
278
|
+
const stateBuf = Buffer.from(state);
|
|
279
|
+
const expectedBuf = Buffer.from(expectedState);
|
|
280
|
+
const stateOk =
|
|
281
|
+
stateBuf.length === expectedBuf.length &&
|
|
282
|
+
crypto.timingSafeEqual(stateBuf, expectedBuf);
|
|
283
|
+
if (!stateOk || apiKey === "") {
|
|
284
|
+
reject400(res);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
res
|
|
288
|
+
.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
289
|
+
.end(
|
|
290
|
+
"<!doctype html><title>Sail login complete</title>" +
|
|
291
|
+
'<body style="font-family:system-ui;display:grid;place-items:center;height:90vh">' +
|
|
292
|
+
"<p>Login complete — return to your terminal.</p></body>",
|
|
293
|
+
);
|
|
294
|
+
settle.resolve({
|
|
295
|
+
api_key: apiKey,
|
|
296
|
+
mode: params.get("mode") ?? "",
|
|
297
|
+
api_url: params.get("api_url") ?? "",
|
|
298
|
+
});
|
|
299
|
+
} catch {
|
|
300
|
+
reject400(res);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
server.on("error", (err) => {
|
|
306
|
+
rejectServer(
|
|
307
|
+
new Error(`could not start the login listener: ${err.message}`),
|
|
308
|
+
);
|
|
309
|
+
settle.reject(err);
|
|
310
|
+
});
|
|
311
|
+
server.listen(0, "127.0.0.1", () => {
|
|
312
|
+
resolveServer({ server, port: server.address().port, callback });
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Open a URL in the default browser, best effort (the URL is also printed). */
|
|
318
|
+
function openBrowser(url) {
|
|
319
|
+
const platform = process.platform;
|
|
320
|
+
let cmd;
|
|
321
|
+
let args;
|
|
322
|
+
if (platform === "darwin") {
|
|
323
|
+
cmd = "open";
|
|
324
|
+
args = [url];
|
|
325
|
+
} else if (platform === "win32") {
|
|
326
|
+
// Not `cmd /c start`: cmd.exe applies %VAR% expansion and &-parsing to the
|
|
327
|
+
// command string, which can corrupt the login URL's percent-encoded
|
|
328
|
+
// redirect_uri / query before `start` ever sees it. explorer.exe hands the
|
|
329
|
+
// URL straight to ShellExecute (the default browser) with no shell
|
|
330
|
+
// parsing, so % and & survive intact. (It exits 1 even on success, but we
|
|
331
|
+
// ignore the exit code — this is best effort and the URL is also printed.)
|
|
332
|
+
cmd = "explorer.exe";
|
|
333
|
+
args = [url];
|
|
334
|
+
} else {
|
|
335
|
+
cmd = "xdg-open";
|
|
336
|
+
args = [url];
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
340
|
+
child.on("error", () => {});
|
|
341
|
+
child.unref();
|
|
342
|
+
} catch {
|
|
343
|
+
// The URL is printed; the user can open it by hand.
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The environment a manually pasted key is validated/stored against. The
|
|
349
|
+
* manual `/cli-auth` page only hands back the key, not its API URL, so we use
|
|
350
|
+
* the invocation's resolved target (trailing slash normalized), falling back
|
|
351
|
+
* to prod when none was requested. Exported for tests.
|
|
352
|
+
*/
|
|
353
|
+
export function manualPasteApiUrl(expectedTarget) {
|
|
354
|
+
const target = expectedTarget?.trim().replace(/\/+$/, "");
|
|
355
|
+
return target || MODE_API_URLS.prod;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether a manually pasted key's environment can't be determined: only
|
|
360
|
+
* `SAIL_APP_URL` pointed the browser at a non-default dashboard, with no
|
|
361
|
+
* `--mode`/`SAIL_API_URL` to say which API endpoint minted the key. The
|
|
362
|
+
* loopback path learns that from the callback POST; the paste path can't, so
|
|
363
|
+
* it must fail closed rather than store the key against prod. Exported for
|
|
364
|
+
* tests. `appUrl` is the already-normalized dashboard URL browserLogin opens.
|
|
365
|
+
*/
|
|
366
|
+
export function pasteTargetIsAmbiguous({ appUrl, expectedSource }) {
|
|
367
|
+
const defaultApp = DEFAULT_APP_URL.replace(/\/+$/, "");
|
|
368
|
+
return appUrl !== defaultApp && expectedSource === "default";
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The mode name whose API URL equals `apiUrl`, or "" for a custom/self-hosted
|
|
373
|
+
* URL with no named mode. Lets the paste path persist the mode the same way
|
|
374
|
+
* the loopback callback does. Exported for tests.
|
|
375
|
+
*/
|
|
376
|
+
export function modeForApiUrl(apiUrl) {
|
|
377
|
+
for (const [mode, url] of Object.entries(MODE_API_URLS)) {
|
|
378
|
+
if (url === apiUrl) return mode;
|
|
379
|
+
}
|
|
380
|
+
return "";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Read a pasted API key from stdin, concurrently with the loopback callback,
|
|
385
|
+
* so remote sessions whose browser can't reach 127.0.0.1 still finish login.
|
|
386
|
+
* Returns `{ promise, cancel }`: `promise` resolves with the first non-empty
|
|
387
|
+
* line; `cancel` stops reading (called once the race settles). Non-interactive
|
|
388
|
+
* stdin has no paste path, so the promise never resolves and the loopback
|
|
389
|
+
* timeout governs.
|
|
390
|
+
*/
|
|
391
|
+
export function readPastedKeyFromStdin(input = process.stdin) {
|
|
392
|
+
if (!input.isTTY) {
|
|
393
|
+
return { promise: new Promise(() => {}), cancel: () => {} };
|
|
394
|
+
}
|
|
395
|
+
// Read with echo disabled: the pasted value is a secret API key, and a
|
|
396
|
+
// cooked-mode TTY (what readline uses) echoes it into the terminal
|
|
397
|
+
// scrollback and any session log. Raw mode stops the TTY driver from
|
|
398
|
+
// echoing; we consume the bytes ourselves and never write them back.
|
|
399
|
+
let settled = false;
|
|
400
|
+
let buf = "";
|
|
401
|
+
let onData;
|
|
402
|
+
const restore = () => {
|
|
403
|
+
if (onData) input.removeListener("data", onData);
|
|
404
|
+
try {
|
|
405
|
+
input.setRawMode?.(false);
|
|
406
|
+
} catch {
|
|
407
|
+
// Not a real TTY (e.g. tests); nothing to restore.
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const promise = new Promise((resolve, reject) => {
|
|
411
|
+
try {
|
|
412
|
+
input.setRawMode?.(true);
|
|
413
|
+
} catch {
|
|
414
|
+
// Best effort — fall back to the stream's current mode.
|
|
415
|
+
}
|
|
416
|
+
input.resume?.();
|
|
417
|
+
onData = (chunk) => {
|
|
418
|
+
if (settled) return;
|
|
419
|
+
buf += chunk.toString("utf8");
|
|
420
|
+
// Ctrl-C / Ctrl-D: raw mode swallows the terminal's own SIGINT/EOF, so
|
|
421
|
+
// we must act on them ourselves — restore the terminal and reject so the
|
|
422
|
+
// login aborts now instead of hanging until the loopback timeout (the
|
|
423
|
+
// race would otherwise keep waiting for the callback for 10 minutes).
|
|
424
|
+
if (buf.includes("\x03") || buf.includes("\x04")) {
|
|
425
|
+
const eof = !buf.includes("\x03");
|
|
426
|
+
settled = true;
|
|
427
|
+
restore();
|
|
428
|
+
reject(new Error(eof ? "login canceled (EOF)" : "login canceled"));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
let nl;
|
|
432
|
+
while ((nl = buf.search(/[\r\n]/)) !== -1) {
|
|
433
|
+
const line = buf.slice(0, nl).trim();
|
|
434
|
+
buf = buf.slice(nl + 1);
|
|
435
|
+
if (line && !settled) {
|
|
436
|
+
settled = true;
|
|
437
|
+
restore();
|
|
438
|
+
resolve(line);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
input.on("data", onData);
|
|
444
|
+
});
|
|
445
|
+
const cancel = () => {
|
|
446
|
+
settled = true;
|
|
447
|
+
restore();
|
|
448
|
+
// Release the stream so it can't keep the process alive or steal input
|
|
449
|
+
// from a subsequently spawned `claude`.
|
|
450
|
+
input.pause?.();
|
|
451
|
+
};
|
|
452
|
+
return { promise, cancel };
|
|
453
|
+
}
|