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 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.64.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): DeviceAuthProgressReporter;
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.64.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 sleep(intervalMs);
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 label = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [