ninepm 0.64.0 → 0.65.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/dist/index.d.ts +13 -3
- package/dist/index.js +162 -8
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -117,7 +117,7 @@ export interface CreatedServiceToken {
|
|
|
117
117
|
clientSecret: string;
|
|
118
118
|
}
|
|
119
119
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
120
|
-
export declare const CLI_VERSION = "0.
|
|
120
|
+
export declare const CLI_VERSION = "0.65.0";
|
|
121
121
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
122
122
|
export interface StoredCredentialEntry {
|
|
123
123
|
token: string;
|
|
@@ -334,12 +334,19 @@ export declare function listSites(options: ApiOptions & {
|
|
|
334
334
|
export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
|
|
335
335
|
export interface DeviceAuthFeedbackOptions {
|
|
336
336
|
progressStream?: NodeJS.WriteStream;
|
|
337
|
-
openBrowserFn?: (url: string) => BrowserOpenResult
|
|
337
|
+
openBrowserFn?: (url: string) => BrowserOpenResult | Promise<BrowserOpenResult>;
|
|
338
338
|
storeCredentialFn?: (token: string, options: {
|
|
339
339
|
allowFileFallback?: boolean | undefined;
|
|
340
340
|
apiUrl?: string | undefined;
|
|
341
341
|
}) => string;
|
|
342
|
+
inputStream?: NodeJS.ReadStream;
|
|
343
|
+
signalTarget?: Pick<NodeJS.EventEmitter, "once" | "removeListener">;
|
|
342
344
|
}
|
|
345
|
+
export declare class DeviceAuthCancelledError extends Error {
|
|
346
|
+
readonly purpose: "login" | "signup";
|
|
347
|
+
constructor(purpose: "login" | "signup");
|
|
348
|
+
}
|
|
349
|
+
export declare const CANCELLED_EXIT_CODE = 130;
|
|
343
350
|
export type ReplacedCredential = {
|
|
344
351
|
kind: "revoked";
|
|
345
352
|
apiUrl: string;
|
|
@@ -453,9 +460,12 @@ export interface UploadProgressReporter {
|
|
|
453
460
|
export declare function createUploadProgressReporter(stream?: NodeJS.WriteStream): UploadProgressReporter;
|
|
454
461
|
export interface DeviceAuthProgressReporter {
|
|
455
462
|
tick(remainingMs: number): void;
|
|
463
|
+
note(line: string): void;
|
|
456
464
|
stop(): void;
|
|
457
465
|
}
|
|
458
|
-
export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream
|
|
466
|
+
export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream, options?: {
|
|
467
|
+
hint?: string;
|
|
468
|
+
}): DeviceAuthProgressReporter;
|
|
459
469
|
export declare function noteCliOutdatedAdvisory(response: Response, log?: (line: string) => void): void;
|
|
460
470
|
export declare function resetCliOutdatedAdvisoryForTests(): void;
|
|
461
471
|
export declare function dashboardUrlForApiUrl(apiUrl: string): string;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
|
|
3
3
|
// Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
@@ -16,7 +16,7 @@ import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WOR
|
|
|
16
16
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
17
17
|
import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
|
|
18
18
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
19
|
-
export const CLI_VERSION = "0.
|
|
19
|
+
export const CLI_VERSION = "0.65.0";
|
|
20
20
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
21
21
|
const TOKEN_SERVICE = "9pm";
|
|
22
22
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -1332,6 +1332,21 @@ export async function deleteSite(options) {
|
|
|
1332
1332
|
});
|
|
1333
1333
|
return payload.deleted;
|
|
1334
1334
|
}
|
|
1335
|
+
// Thrown out of the device flow when the person pressed Ctrl-C while it waited
|
|
1336
|
+
// (#1902). It travels to main() like any other failure, so every `finally` on the
|
|
1337
|
+
// way has already run when the process sets its exit code; exiting from inside the
|
|
1338
|
+
// signal handler would skip them and leave stdin holding the process open.
|
|
1339
|
+
export class DeviceAuthCancelledError extends Error {
|
|
1340
|
+
purpose;
|
|
1341
|
+
constructor(purpose) {
|
|
1342
|
+
super(`Cancelled. Run "9pm ${purpose}" to start again.`);
|
|
1343
|
+
this.name = "DeviceAuthCancelledError";
|
|
1344
|
+
this.purpose = purpose;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
// The exit status of a command the person interrupted: 128 + SIGINT, which is what
|
|
1348
|
+
// a shell reports for one, so a script can tell a cancel from a failure.
|
|
1349
|
+
export const CANCELLED_EXIT_CODE = 130;
|
|
1335
1350
|
export async function loginDevice(options) {
|
|
1336
1351
|
return deviceAuthFlow({ ...options, purpose: "login" });
|
|
1337
1352
|
}
|
|
@@ -1390,7 +1405,7 @@ async function deviceAuthFlow(options) {
|
|
|
1390
1405
|
// lines above plus the caller's unchanged result line, so anything already
|
|
1391
1406
|
// reading it is unaffected.
|
|
1392
1407
|
const progressStream = options.progressStream ?? process.stderr;
|
|
1393
|
-
const browser = (options.openBrowserFn ?? openBrowser)(start.verification_uri);
|
|
1408
|
+
const browser = await (options.openBrowserFn ?? openBrowser)(start.verification_uri);
|
|
1394
1409
|
if (browser === "failed") {
|
|
1395
1410
|
// Say it the moment it happens. A swallowed browser failure used to look
|
|
1396
1411
|
// exactly like a flow nobody had approved yet.
|
|
@@ -1402,16 +1417,83 @@ async function deviceAuthFlow(options) {
|
|
|
1402
1417
|
progressStream.write("Open the URL above in any browser — on this machine or another — to approve this device.\n");
|
|
1403
1418
|
const deadline = Date.now() + start.expires_in * 1000;
|
|
1404
1419
|
const intervalMs = Math.max(1, start.interval ?? 2) * 1000;
|
|
1420
|
+
// #1902: the way out of the wait, said on the line itself. Enter is offered only
|
|
1421
|
+
// when it works - stdin a terminal to read it from, stderr a terminal to answer on
|
|
1422
|
+
// - and is read in the terminal's normal line mode, so no terminal setting changes
|
|
1423
|
+
// and Ctrl-C stays the terminal's own signal.
|
|
1424
|
+
const input = options.inputStream ?? process.stdin;
|
|
1425
|
+
const signals = options.signalTarget ?? process;
|
|
1426
|
+
const interactive = input.isTTY === true && progressStream.isTTY === true;
|
|
1427
|
+
let finished = false;
|
|
1428
|
+
let cancelled = false;
|
|
1429
|
+
let wake;
|
|
1430
|
+
// One reopen at a time: presses made while a launch is still running are
|
|
1431
|
+
// ignored rather than queued into more tabs.
|
|
1432
|
+
let reopening = false;
|
|
1433
|
+
// Registered with `once`: a second Ctrl-C finds no listener and gets Node's
|
|
1434
|
+
// default, which ends the process at once. That is the escape hatch for a poll
|
|
1435
|
+
// request that never answers, since the first press waits for it (below).
|
|
1436
|
+
const onSigint = () => {
|
|
1437
|
+
cancelled = true;
|
|
1438
|
+
wake?.();
|
|
1439
|
+
};
|
|
1440
|
+
const onInput = (chunk) => {
|
|
1441
|
+
if (finished || cancelled || !/[\r\n]/.test(String(chunk)))
|
|
1442
|
+
return;
|
|
1443
|
+
// Every Enter echoes a newline that moves the cursor below the spinner line,
|
|
1444
|
+
// ignored presses included; step back up and clear it so the next redraw lands
|
|
1445
|
+
// where the line was instead of stranding a copy above it.
|
|
1446
|
+
progressStream.write("\x1b[1A\r\x1b[K");
|
|
1447
|
+
if (reopening)
|
|
1448
|
+
return;
|
|
1449
|
+
reopening = true;
|
|
1450
|
+
// Asynchronous and bounded (openBrowserAsync), so a slow launcher stalls
|
|
1451
|
+
// neither the polling nor Ctrl-C while it runs.
|
|
1452
|
+
void Promise.resolve()
|
|
1453
|
+
.then(() => (options.openBrowserFn ?? openBrowserAsync)(start.verification_uri))
|
|
1454
|
+
.catch(() => "failed")
|
|
1455
|
+
.then((reopened) => {
|
|
1456
|
+
reopening = false;
|
|
1457
|
+
if (finished)
|
|
1458
|
+
return;
|
|
1459
|
+
progress.note(reopened === "opened"
|
|
1460
|
+
? "Opened the page again."
|
|
1461
|
+
: reopened === "failed"
|
|
1462
|
+
? "Could not open a browser — use the URL above."
|
|
1463
|
+
: // "unknown": the launcher ran, but nothing says a page appeared.
|
|
1464
|
+
"Asked the system to open the page again. If it did not appear, use the URL above.");
|
|
1465
|
+
});
|
|
1466
|
+
};
|
|
1405
1467
|
// stop() in a finally so no outcome — approval, denial, network throw,
|
|
1406
|
-
// expiry — can leave the spinner timer alive or the line half-drawn.
|
|
1407
|
-
const progress = createDeviceAuthProgressReporter(progressStream
|
|
1468
|
+
// expiry, cancellation — can leave the spinner timer alive or the line half-drawn.
|
|
1469
|
+
const progress = createDeviceAuthProgressReporter(progressStream, {
|
|
1470
|
+
hint: interactive ? "Enter to reopen the page, Ctrl-C to cancel" : "Ctrl-C to cancel",
|
|
1471
|
+
});
|
|
1472
|
+
signals.once("SIGINT", onSigint);
|
|
1473
|
+
if (interactive)
|
|
1474
|
+
input.on("data", onInput);
|
|
1408
1475
|
try {
|
|
1409
1476
|
while (Date.now() < deadline) {
|
|
1477
|
+
// Checked here and after the sleep, never during a poll request: once a poll
|
|
1478
|
+
// is in flight the control plane may already have minted the key, and
|
|
1479
|
+
// abandoning that response would leave a live key nobody holds (#859). An
|
|
1480
|
+
// approval that lands in the same instant as Ctrl-C therefore completes.
|
|
1481
|
+
if (cancelled)
|
|
1482
|
+
throw new DeviceAuthCancelledError(options.purpose);
|
|
1410
1483
|
// Tick before the sleep so the first feedback is immediate rather than
|
|
1411
1484
|
// one poll interval late. Remaining time is derived from the deadline the
|
|
1412
1485
|
// server's expires_in produced, never from a CLI-side constant.
|
|
1413
1486
|
progress.tick(deadline - Date.now());
|
|
1414
|
-
await
|
|
1487
|
+
await new Promise((resolve) => {
|
|
1488
|
+
const timer = setTimeout(resolve, intervalMs);
|
|
1489
|
+
wake = () => {
|
|
1490
|
+
clearTimeout(timer);
|
|
1491
|
+
resolve();
|
|
1492
|
+
};
|
|
1493
|
+
});
|
|
1494
|
+
wake = undefined;
|
|
1495
|
+
if (cancelled)
|
|
1496
|
+
throw new DeviceAuthCancelledError(options.purpose);
|
|
1415
1497
|
const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
|
|
1416
1498
|
method: "POST",
|
|
1417
1499
|
headers: { "content-type": "application/json", ...cliVersionHeader() },
|
|
@@ -1529,7 +1611,15 @@ async function deviceAuthFlow(options) {
|
|
|
1529
1611
|
}
|
|
1530
1612
|
}
|
|
1531
1613
|
finally {
|
|
1614
|
+
finished = true;
|
|
1532
1615
|
progress.stop();
|
|
1616
|
+
signals.removeListener("SIGINT", onSigint);
|
|
1617
|
+
if (interactive) {
|
|
1618
|
+
input.removeListener("data", onInput);
|
|
1619
|
+
// Adding the listener put stdin into flowing mode; left flowing, it would
|
|
1620
|
+
// hold the process open after the command is done.
|
|
1621
|
+
input.pause();
|
|
1622
|
+
}
|
|
1533
1623
|
}
|
|
1534
1624
|
// Reached only when the client's own deadline elapses without the server having
|
|
1535
1625
|
// said anything terminal — rarer than the 410 route above, but it must say the
|
|
@@ -2117,14 +2207,26 @@ export function createUploadProgressReporter(stream = process.stderr) {
|
|
|
2117
2207
|
}
|
|
2118
2208
|
// Off-TTY ceiling: at most one line per 30s of the window (~20 lines over 600s).
|
|
2119
2209
|
const DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS = 30_000;
|
|
2120
|
-
export function createDeviceAuthProgressReporter(stream = process.stderr) {
|
|
2210
|
+
export function createDeviceAuthProgressReporter(stream = process.stderr, options = {}) {
|
|
2121
2211
|
const isTty = stream.isTTY === true;
|
|
2122
2212
|
let timer;
|
|
2123
2213
|
let frame = 0;
|
|
2124
2214
|
let finished = false;
|
|
2125
2215
|
let remainingMs;
|
|
2126
2216
|
let lastLineRemainingMs;
|
|
2127
|
-
const
|
|
2217
|
+
const base = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
|
|
2218
|
+
// The hint (#1902) rides the TTY line only: off a TTY nobody is at a keyboard to
|
|
2219
|
+
// act on it. It is dropped when the line would not fit, because a wrapped line
|
|
2220
|
+
// defeats the `\r` redraw and the spinner would stack a copy per frame.
|
|
2221
|
+
const label = (ms) => {
|
|
2222
|
+
const plain = base(ms);
|
|
2223
|
+
if (!isTty || !options.hint)
|
|
2224
|
+
return plain;
|
|
2225
|
+
const full = `${plain} — ${options.hint}`;
|
|
2226
|
+
const width = typeof stream.columns === "number" ? stream.columns : 0;
|
|
2227
|
+
// +2 for the spinner glyph and its space.
|
|
2228
|
+
return width > 0 && full.length + 2 >= width ? plain : full;
|
|
2229
|
+
};
|
|
2128
2230
|
const redraw = () => {
|
|
2129
2231
|
if (remainingMs === undefined)
|
|
2130
2232
|
return;
|
|
@@ -2163,6 +2265,16 @@ export function createDeviceAuthProgressReporter(stream = process.stderr) {
|
|
|
2163
2265
|
lastLineRemainingMs = nextRemainingMs;
|
|
2164
2266
|
stream.write(`${label(nextRemainingMs)}\n`);
|
|
2165
2267
|
},
|
|
2268
|
+
note(line) {
|
|
2269
|
+
if (finished)
|
|
2270
|
+
return;
|
|
2271
|
+
if (!isTty) {
|
|
2272
|
+
stream.write(`${line}\n`);
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
stream.write(`\r\x1b[K${line}\n`);
|
|
2276
|
+
redraw();
|
|
2277
|
+
},
|
|
2166
2278
|
// Idempotent; called from a finally around the poll loop. On a TTY it erases
|
|
2167
2279
|
// the in-place line and writes nothing in its place: unlike the upload
|
|
2168
2280
|
// reporter this deliberately leaves no ✓/✗ summary, because the flow's own
|
|
@@ -4160,6 +4272,41 @@ function openBrowser(url) {
|
|
|
4160
4272
|
...(result.error ? { error: result.error } : {}),
|
|
4161
4273
|
});
|
|
4162
4274
|
}
|
|
4275
|
+
// How long a reopen waits for the launcher to say how it went (#1902). `open` and
|
|
4276
|
+
// `xdg-open` hand off and return in well under a second; one still running after
|
|
4277
|
+
// this is left to finish on its own, and the person is pointed at the URL instead.
|
|
4278
|
+
const BROWSER_LAUNCH_TIMEOUT_MS = 5_000;
|
|
4279
|
+
// openBrowser without blocking: the reopen on Enter runs while the login waits,
|
|
4280
|
+
// and spawnSync there would freeze the poll loop and the Ctrl-C handler for as long
|
|
4281
|
+
// as the launcher took. Same platform choice and verdicts as openBrowser.
|
|
4282
|
+
function openBrowserAsync(url) {
|
|
4283
|
+
if (hasNoGraphicalDisplay(process.platform, process.env)) {
|
|
4284
|
+
return Promise.resolve("failed");
|
|
4285
|
+
}
|
|
4286
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
4287
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
4288
|
+
return new Promise((resolve) => {
|
|
4289
|
+
let settled = false;
|
|
4290
|
+
const settle = (result) => {
|
|
4291
|
+
if (settled)
|
|
4292
|
+
return;
|
|
4293
|
+
settled = true;
|
|
4294
|
+
clearTimeout(timer);
|
|
4295
|
+
resolve(result);
|
|
4296
|
+
};
|
|
4297
|
+
const child = spawn(command, args, { stdio: "ignore" });
|
|
4298
|
+
// Unref'd at once, not when the wait below runs out: a launcher still running
|
|
4299
|
+
// when the login ends (Ctrl-C, approval) must not keep the CLI alive behind it.
|
|
4300
|
+
// Its exit event still arrives while the CLI is running for another reason.
|
|
4301
|
+
child.unref();
|
|
4302
|
+
// A launcher that has not answered in time may still open the page, so this
|
|
4303
|
+
// is "unknown" rather than "failed".
|
|
4304
|
+
const timer = setTimeout(() => settle("unknown"), BROWSER_LAUNCH_TIMEOUT_MS);
|
|
4305
|
+
timer.unref();
|
|
4306
|
+
child.once("error", (error) => settle(classifyBrowserOpen({ platform: process.platform, status: null, error })));
|
|
4307
|
+
child.once("exit", (status) => settle(classifyBrowserOpen({ platform: process.platform, status })));
|
|
4308
|
+
});
|
|
4309
|
+
}
|
|
4163
4310
|
function sleep(ms) {
|
|
4164
4311
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4165
4312
|
}
|
|
@@ -4562,6 +4709,13 @@ async function main() {
|
|
|
4562
4709
|
process.exitCode = 0;
|
|
4563
4710
|
return;
|
|
4564
4711
|
}
|
|
4712
|
+
if (error instanceof DeviceAuthCancelledError) {
|
|
4713
|
+
// The person asked for this, so it is said plainly rather than reported as a
|
|
4714
|
+
// failure with whatever decoration reportCliError adds.
|
|
4715
|
+
console.error(error.message);
|
|
4716
|
+
process.exitCode = CANCELLED_EXIT_CODE;
|
|
4717
|
+
return;
|
|
4718
|
+
}
|
|
4565
4719
|
reportCliError(error);
|
|
4566
4720
|
process.exitCode = 1;
|
|
4567
4721
|
}
|