ninepm 0.64.0 → 0.68.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 CHANGED
@@ -106,7 +106,7 @@ Deploy a first-class Worker runtime app:
106
106
 
107
107
  The `--runtime worker` entry must be a bundled fetch-compatible JavaScript module with a default export. With `--with-db`, the app receives `env.DB` and should use SQLite-compatible prepared statements.
108
108
 
109
- Choose which files are public with `--assets <dir>`: only the files under that directory are served, from the app's root (`public/index.html` answers on `/`), and nothing else in the deploy directory is uploaded. Use `--no-assets` when the entry serves every response itself. The app remembers the choice, so later deploys need not repeat the flag; pass the other flag, or another directory, to change it — there is no way back to publishing everything. A worker app that has never chosen serves **every file in the deploy directory except the entry** publicly, server helpers and leftovers like `index.js.orig` included; every deploy of such an app prints a warning naming those files, and `9pm deploy --check` lists every file a deploy would make public.
109
+ Choose which files are public with `--assets <dir>`: only the files under that directory are served, from the app's root (`public/index.html` answers on `/`), and nothing else in the deploy directory is uploaded. Use `--no-assets` when the entry serves every response itself. The app remembers the choice, so later deploys need not repeat the flag; pass the other flag, or another directory, to change it — there is no way back to publishing everything. A worker app that has never chosen serves **every file in the deploy directory except the entry** publicly, server helpers and leftovers like `index.js.orig` included; every deploy of such an app prints a warning naming those files, and `9pm deploy --check` lists every file a deploy would make public. Its summary counts the files scanned apart from the files selected for upload (the entry and the public files); `--check` itself uploads nothing.
110
110
 
111
111
  Deploy a container app from a local Docker build:
112
112
 
@@ -221,6 +221,7 @@ Options:
221
221
  - `--name <name>` sets the app display name
222
222
  - `--slug <slug>` sets the URL-safe app slug
223
223
  - `--with-db` requests managed storage for the app
224
+ - `--with-auth` gives the app 9pm-managed end-user sign-in (email one-time codes) and implies `--with-db`. Once enabled it stays on for every later deploy of the app, with or without the flag; it can't be turned off
224
225
  - `--database-name <name>` customizes the requested database name
225
226
  - `--binding <name>` customizes the database binding name
226
227
  - `--environment <name>` sets deployment environment metadata
package/dist/index.d.ts CHANGED
@@ -38,10 +38,17 @@ export interface DeployResult {
38
38
  project: Project;
39
39
  deployment: Deployment;
40
40
  database: AppDatabase | null;
41
+ databases: BoundDatabase[];
41
42
  uploaded: number;
42
43
  url: string;
43
44
  containerImageBytes?: number;
44
45
  }
46
+ export interface BoundDatabase {
47
+ bindingName: string;
48
+ name: string | null;
49
+ }
50
+ export declare function boundDatabasesForDeployment(deployment: Deployment, provisioned: AppDatabase | null): BoundDatabase[];
51
+ export declare function deployDatabaseClause(databases: readonly BoundDatabase[]): string | null;
45
52
  export interface ApiOptions {
46
53
  apiUrl: string;
47
54
  token: string;
@@ -117,7 +124,7 @@ export interface CreatedServiceToken {
117
124
  clientSecret: string;
118
125
  }
119
126
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
120
- export declare const CLI_VERSION = "0.64.0";
127
+ export declare const CLI_VERSION = "0.68.0";
121
128
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
122
129
  export interface StoredCredentialEntry {
123
130
  token: string;
@@ -191,6 +198,10 @@ export interface DeployCheckResult {
191
198
  path: string;
192
199
  url: string;
193
200
  }>;
201
+ upload?: {
202
+ files: number;
203
+ bytes: number;
204
+ };
194
205
  }
195
206
  export type DetectedFramework = "nextjs" | "astro" | "sveltekit" | "vite";
196
207
  export declare function detectFramework(directory: string): DetectedFramework | null;
@@ -230,6 +241,7 @@ export declare function measureWorkerModuleFiles(root: string, modulePaths: read
230
241
  compressedBytes: number;
231
242
  }>;
232
243
  export declare function checkDeployDirectory(options: DeployOptions): Promise<DeployCheckResult>;
244
+ export declare function deployCheckSummary(check: DeployCheckResult, publicAssets: string | undefined): string;
233
245
  export declare function whoami(options: ApiOptions): Promise<WhoamiResult>;
234
246
  export declare function publicWhoamiResult(result: WhoamiResult): PublicWhoamiResult;
235
247
  export type CredentialRevocation = {
@@ -334,12 +346,19 @@ export declare function listSites(options: ApiOptions & {
334
346
  export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
335
347
  export interface DeviceAuthFeedbackOptions {
336
348
  progressStream?: NodeJS.WriteStream;
337
- openBrowserFn?: (url: string) => BrowserOpenResult;
349
+ openBrowserFn?: (url: string) => BrowserOpenResult | Promise<BrowserOpenResult>;
338
350
  storeCredentialFn?: (token: string, options: {
339
351
  allowFileFallback?: boolean | undefined;
340
352
  apiUrl?: string | undefined;
341
353
  }) => string;
354
+ inputStream?: NodeJS.ReadStream;
355
+ signalTarget?: Pick<NodeJS.EventEmitter, "once" | "removeListener">;
356
+ }
357
+ export declare class DeviceAuthCancelledError extends Error {
358
+ readonly purpose: "login" | "signup";
359
+ constructor(purpose: "login" | "signup");
342
360
  }
361
+ export declare const CANCELLED_EXIT_CODE = 130;
343
362
  export type ReplacedCredential = {
344
363
  kind: "revoked";
345
364
  apiUrl: string;
@@ -453,9 +472,12 @@ export interface UploadProgressReporter {
453
472
  export declare function createUploadProgressReporter(stream?: NodeJS.WriteStream): UploadProgressReporter;
454
473
  export interface DeviceAuthProgressReporter {
455
474
  tick(remainingMs: number): void;
475
+ note(line: string): void;
456
476
  stop(): void;
457
477
  }
458
- export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
478
+ export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream, options?: {
479
+ hint?: string;
480
+ }): DeviceAuthProgressReporter;
459
481
  export declare function noteCliOutdatedAdvisory(response: Response, log?: (line: string) => void): void;
460
482
  export declare function resetCliOutdatedAdvisoryForTests(): void;
461
483
  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,40 @@ 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
+ // What the deployment binds, read from the deployment the server returned (#1949). The
20
+ // server binds every provisioned database of the app on each publish whatever flags were
21
+ // passed, so `--with-db` says nothing about the result. Only a deployment carrying no
22
+ // `databases` record at all - the local mock target, or a server that predates recording
23
+ // it for this runtime - falls back to what this run itself provisioned.
24
+ export function boundDatabasesForDeployment(deployment, provisioned) {
25
+ const recorded = deployment.metadata["databases"];
26
+ if (!Array.isArray(recorded)) {
27
+ return provisioned ? [{ bindingName: provisioned.bindingName, name: provisioned.name }] : [];
28
+ }
29
+ const bound = [];
30
+ for (const entry of recorded) {
31
+ if (!entry || typeof entry !== "object") {
32
+ continue;
33
+ }
34
+ const record = entry;
35
+ if (typeof record["bindingName"] !== "string") {
36
+ continue;
37
+ }
38
+ const name = typeof record["name"] === "string" ? record["name"] : provisioned && record["id"] === provisioned.id ? provisioned.name : null;
39
+ bound.push({ bindingName: record["bindingName"], name });
40
+ }
41
+ return bound;
42
+ }
43
+ // The database clause of the deploy summary, or null when nothing is bound - the summary
44
+ // then says nothing about a database rather than "no database".
45
+ export function deployDatabaseClause(databases) {
46
+ if (databases.length === 0) {
47
+ return null;
48
+ }
49
+ const listed = databases.map((database) => (database.name ? `${database.bindingName}:${database.name}` : database.bindingName)).join(", ");
50
+ return `${databases.length === 1 ? "database" : "databases"} ${listed}`;
51
+ }
52
+ export const CLI_VERSION = "0.68.0";
20
53
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
21
54
  const TOKEN_SERVICE = "9pm";
22
55
  const TOKEN_ACCOUNT = "default";
@@ -442,6 +475,7 @@ async function runDeploy(initialOptions, slugHolder) {
442
475
  project,
443
476
  deployment: finalized,
444
477
  database,
478
+ databases: boundDatabasesForDeployment(finalized, database),
445
479
  uploaded: 1,
446
480
  url: finalized.url,
447
481
  containerImageBytes: image.size,
@@ -637,6 +671,7 @@ async function runDeploy(initialOptions, slugHolder) {
637
671
  project,
638
672
  deployment: finalized,
639
673
  database,
674
+ databases: boundDatabasesForDeployment(finalized, database),
640
675
  uploaded,
641
676
  url: finalized.url,
642
677
  };
@@ -812,9 +847,12 @@ export async function checkDeployDirectory(options) {
812
847
  // #1847: `--check` has no project, so it cannot see a choice made on an earlier deploy;
813
848
  // it reports what this invocation's flags would publish, and says so for the legacy case.
814
849
  let publicFiles;
850
+ let upload;
815
851
  if (serverEntry) {
816
852
  const selection = options.publicAssets ?? null;
817
853
  publicFiles = workerPublicFiles(manifest.map((file) => file.path), serverEntry, selection);
854
+ const selected = uploadedFiles(manifest, serverEntry, selection);
855
+ upload = { files: selected.length, bytes: selected.reduce((sum, file) => sum + file.size, 0) };
818
856
  if (selection !== null && selection !== NO_PUBLIC_ASSETS && publicFiles.length === 0) {
819
857
  errors.push(missingAssetsDirectoryMessage(selection));
820
858
  }
@@ -847,8 +885,26 @@ export async function checkDeployDirectory(options) {
847
885
  bytes: totalBytes,
848
886
  ...(serverCode ? { serverCode } : {}),
849
887
  ...(publicFiles ? { publicFiles } : {}),
888
+ ...(upload ? { upload } : {}),
850
889
  };
851
890
  }
891
+ // The `--check` success line. Static and bundle deploys upload every file they scan, so one
892
+ // count is the whole story there. An entry-based worker deploy uploads only the entry and
893
+ // its public files, so it reports both counts; with no --assets/--no-assets, a choice the
894
+ // app saved on an earlier deploy applies on the server, which a local check cannot see.
895
+ export function deployCheckSummary(check, publicAssets) {
896
+ if (!check.upload) {
897
+ return `Deploy check passed: ${check.files} file(s), ${check.bytes} byte(s).`;
898
+ }
899
+ const publicCount = check.publicFiles?.length ?? 0;
900
+ const breakdown = publicAssets === NO_PUBLIC_ASSETS ? "the entry only" : `the entry + ${publicCount} public file(s)`;
901
+ const summary = `Deploy check passed: ${check.files} file(s) scanned, ${check.upload.files} selected for upload (${breakdown}), ` +
902
+ `${check.upload.bytes.toLocaleString("en-US")} byte(s) selected.`;
903
+ if (publicAssets !== undefined) {
904
+ return summary;
905
+ }
906
+ return `${summary}\n Counted from the flags supplied (no --assets or --no-assets). If this app saved a public-assets choice on an earlier deploy, that choice applies on deploy and only the entry and the files it makes public are uploaded.`;
907
+ }
852
908
  export async function whoami(options) {
853
909
  return apiRequest(options, "/v1/me", { method: "GET" });
854
910
  }
@@ -1332,6 +1388,21 @@ export async function deleteSite(options) {
1332
1388
  });
1333
1389
  return payload.deleted;
1334
1390
  }
1391
+ // Thrown out of the device flow when the person pressed Ctrl-C while it waited
1392
+ // (#1902). It travels to main() like any other failure, so every `finally` on the
1393
+ // way has already run when the process sets its exit code; exiting from inside the
1394
+ // signal handler would skip them and leave stdin holding the process open.
1395
+ export class DeviceAuthCancelledError extends Error {
1396
+ purpose;
1397
+ constructor(purpose) {
1398
+ super(`Cancelled. Run "9pm ${purpose}" to start again.`);
1399
+ this.name = "DeviceAuthCancelledError";
1400
+ this.purpose = purpose;
1401
+ }
1402
+ }
1403
+ // The exit status of a command the person interrupted: 128 + SIGINT, which is what
1404
+ // a shell reports for one, so a script can tell a cancel from a failure.
1405
+ export const CANCELLED_EXIT_CODE = 130;
1335
1406
  export async function loginDevice(options) {
1336
1407
  return deviceAuthFlow({ ...options, purpose: "login" });
1337
1408
  }
@@ -1390,7 +1461,7 @@ async function deviceAuthFlow(options) {
1390
1461
  // lines above plus the caller's unchanged result line, so anything already
1391
1462
  // reading it is unaffected.
1392
1463
  const progressStream = options.progressStream ?? process.stderr;
1393
- const browser = (options.openBrowserFn ?? openBrowser)(start.verification_uri);
1464
+ const browser = await (options.openBrowserFn ?? openBrowser)(start.verification_uri);
1394
1465
  if (browser === "failed") {
1395
1466
  // Say it the moment it happens. A swallowed browser failure used to look
1396
1467
  // exactly like a flow nobody had approved yet.
@@ -1402,16 +1473,83 @@ async function deviceAuthFlow(options) {
1402
1473
  progressStream.write("Open the URL above in any browser — on this machine or another — to approve this device.\n");
1403
1474
  const deadline = Date.now() + start.expires_in * 1000;
1404
1475
  const intervalMs = Math.max(1, start.interval ?? 2) * 1000;
1476
+ // #1902: the way out of the wait, said on the line itself. Enter is offered only
1477
+ // when it works - stdin a terminal to read it from, stderr a terminal to answer on
1478
+ // - and is read in the terminal's normal line mode, so no terminal setting changes
1479
+ // and Ctrl-C stays the terminal's own signal.
1480
+ const input = options.inputStream ?? process.stdin;
1481
+ const signals = options.signalTarget ?? process;
1482
+ const interactive = input.isTTY === true && progressStream.isTTY === true;
1483
+ let finished = false;
1484
+ let cancelled = false;
1485
+ let wake;
1486
+ // One reopen at a time: presses made while a launch is still running are
1487
+ // ignored rather than queued into more tabs.
1488
+ let reopening = false;
1489
+ // Registered with `once`: a second Ctrl-C finds no listener and gets Node's
1490
+ // default, which ends the process at once. That is the escape hatch for a poll
1491
+ // request that never answers, since the first press waits for it (below).
1492
+ const onSigint = () => {
1493
+ cancelled = true;
1494
+ wake?.();
1495
+ };
1496
+ const onInput = (chunk) => {
1497
+ if (finished || cancelled || !/[\r\n]/.test(String(chunk)))
1498
+ return;
1499
+ // Every Enter echoes a newline that moves the cursor below the spinner line,
1500
+ // ignored presses included; step back up and clear it so the next redraw lands
1501
+ // where the line was instead of stranding a copy above it.
1502
+ progressStream.write("\x1b[1A\r\x1b[K");
1503
+ if (reopening)
1504
+ return;
1505
+ reopening = true;
1506
+ // Asynchronous and bounded (openBrowserAsync), so a slow launcher stalls
1507
+ // neither the polling nor Ctrl-C while it runs.
1508
+ void Promise.resolve()
1509
+ .then(() => (options.openBrowserFn ?? openBrowserAsync)(start.verification_uri))
1510
+ .catch(() => "failed")
1511
+ .then((reopened) => {
1512
+ reopening = false;
1513
+ if (finished)
1514
+ return;
1515
+ progress.note(reopened === "opened"
1516
+ ? "Opened the page again."
1517
+ : reopened === "failed"
1518
+ ? "Could not open a browser — use the URL above."
1519
+ : // "unknown": the launcher ran, but nothing says a page appeared.
1520
+ "Asked the system to open the page again. If it did not appear, use the URL above.");
1521
+ });
1522
+ };
1405
1523
  // 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);
1524
+ // expiry, cancellation — can leave the spinner timer alive or the line half-drawn.
1525
+ const progress = createDeviceAuthProgressReporter(progressStream, {
1526
+ hint: interactive ? "Enter to reopen the page, Ctrl-C to cancel" : "Ctrl-C to cancel",
1527
+ });
1528
+ signals.once("SIGINT", onSigint);
1529
+ if (interactive)
1530
+ input.on("data", onInput);
1408
1531
  try {
1409
1532
  while (Date.now() < deadline) {
1533
+ // Checked here and after the sleep, never during a poll request: once a poll
1534
+ // is in flight the control plane may already have minted the key, and
1535
+ // abandoning that response would leave a live key nobody holds (#859). An
1536
+ // approval that lands in the same instant as Ctrl-C therefore completes.
1537
+ if (cancelled)
1538
+ throw new DeviceAuthCancelledError(options.purpose);
1410
1539
  // Tick before the sleep so the first feedback is immediate rather than
1411
1540
  // one poll interval late. Remaining time is derived from the deadline the
1412
1541
  // server's expires_in produced, never from a CLI-side constant.
1413
1542
  progress.tick(deadline - Date.now());
1414
- await sleep(intervalMs);
1543
+ await new Promise((resolve) => {
1544
+ const timer = setTimeout(resolve, intervalMs);
1545
+ wake = () => {
1546
+ clearTimeout(timer);
1547
+ resolve();
1548
+ };
1549
+ });
1550
+ wake = undefined;
1551
+ if (cancelled)
1552
+ throw new DeviceAuthCancelledError(options.purpose);
1415
1553
  const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
1416
1554
  method: "POST",
1417
1555
  headers: { "content-type": "application/json", ...cliVersionHeader() },
@@ -1529,7 +1667,15 @@ async function deviceAuthFlow(options) {
1529
1667
  }
1530
1668
  }
1531
1669
  finally {
1670
+ finished = true;
1532
1671
  progress.stop();
1672
+ signals.removeListener("SIGINT", onSigint);
1673
+ if (interactive) {
1674
+ input.removeListener("data", onInput);
1675
+ // Adding the listener put stdin into flowing mode; left flowing, it would
1676
+ // hold the process open after the command is done.
1677
+ input.pause();
1678
+ }
1533
1679
  }
1534
1680
  // Reached only when the client's own deadline elapses without the server having
1535
1681
  // said anything terminal — rarer than the 410 route above, but it must say the
@@ -2117,14 +2263,26 @@ export function createUploadProgressReporter(stream = process.stderr) {
2117
2263
  }
2118
2264
  // Off-TTY ceiling: at most one line per 30s of the window (~20 lines over 600s).
2119
2265
  const DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS = 30_000;
2120
- export function createDeviceAuthProgressReporter(stream = process.stderr) {
2266
+ export function createDeviceAuthProgressReporter(stream = process.stderr, options = {}) {
2121
2267
  const isTty = stream.isTTY === true;
2122
2268
  let timer;
2123
2269
  let frame = 0;
2124
2270
  let finished = false;
2125
2271
  let remainingMs;
2126
2272
  let lastLineRemainingMs;
2127
- const label = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
2273
+ const base = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
2274
+ // The hint (#1902) rides the TTY line only: off a TTY nobody is at a keyboard to
2275
+ // act on it. It is dropped when the line would not fit, because a wrapped line
2276
+ // defeats the `\r` redraw and the spinner would stack a copy per frame.
2277
+ const label = (ms) => {
2278
+ const plain = base(ms);
2279
+ if (!isTty || !options.hint)
2280
+ return plain;
2281
+ const full = `${plain} — ${options.hint}`;
2282
+ const width = typeof stream.columns === "number" ? stream.columns : 0;
2283
+ // +2 for the spinner glyph and its space.
2284
+ return width > 0 && full.length + 2 >= width ? plain : full;
2285
+ };
2128
2286
  const redraw = () => {
2129
2287
  if (remainingMs === undefined)
2130
2288
  return;
@@ -2163,6 +2321,16 @@ export function createDeviceAuthProgressReporter(stream = process.stderr) {
2163
2321
  lastLineRemainingMs = nextRemainingMs;
2164
2322
  stream.write(`${label(nextRemainingMs)}\n`);
2165
2323
  },
2324
+ note(line) {
2325
+ if (finished)
2326
+ return;
2327
+ if (!isTty) {
2328
+ stream.write(`${line}\n`);
2329
+ return;
2330
+ }
2331
+ stream.write(`\r\x1b[K${line}\n`);
2332
+ redraw();
2333
+ },
2166
2334
  // Idempotent; called from a finally around the poll loop. On a TTY it erases
2167
2335
  // the in-place line and writes nothing in its place: unlike the upload
2168
2336
  // reporter this deliberately leaves no ✓/✗ summary, because the flow's own
@@ -2193,7 +2361,14 @@ function buildContainerImage(directory) {
2193
2361
  const tempDir = mkdtempSync(path.join(os.tmpdir(), "9pm-container-"));
2194
2362
  const tag = `9pm-local-${Date.now()}-${process.pid}`;
2195
2363
  const imagePath = path.join(tempDir, "image.tar");
2196
- runDocker(["build", "--platform", "linux/amd64", "-t", tag, root], "build container image");
2364
+ // Build without Buildx's default provenance attestation. With it, Docker Desktop saves the
2365
+ // image as an index carrying an attestation manifest, and the push to Cloudflare's registry
2366
+ // through `wrangler containers push` fails with "blob unknown to registry"; the same image
2367
+ // without the attestation pushes (#1906: two of two attempts each way). The environment
2368
+ // variable rather than `--provenance=false`: it is what that test pushed, and a builder that
2369
+ // has no attestations - the legacy non-Buildx `docker build` - ignores an unknown variable
2370
+ // where it would reject an unknown flag. SBOM generation is untouched: it is off by default.
2371
+ runDocker(["build", "--platform", "linux/amd64", "-t", tag, root], "build container image", { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" });
2197
2372
  const inspected = spawnSync("docker", ["image", "inspect", tag, "--format", "{{.Os}}/{{.Architecture}}"], { encoding: "utf8" });
2198
2373
  if (inspected.status !== 0) {
2199
2374
  throw new Error(`Failed to inspect container image: ${inspected.stderr || inspected.stdout}`.trim());
@@ -2206,8 +2381,8 @@ function buildContainerImage(directory) {
2206
2381
  const sha256 = createHash("sha256").update(readFileSync(imagePath)).digest("hex");
2207
2382
  return { tag, path: imagePath, size, sha256 };
2208
2383
  }
2209
- function runDocker(args, action) {
2210
- const result = spawnSync("docker", args, { stdio: "inherit" });
2384
+ function runDocker(args, action, extraEnv = {}) {
2385
+ const result = spawnSync("docker", args, { stdio: "inherit", env: { ...process.env, ...extraEnv } });
2211
2386
  if (result.status !== 0) {
2212
2387
  throw new Error(`Failed to ${action}.`);
2213
2388
  }
@@ -4160,6 +4335,41 @@ function openBrowser(url) {
4160
4335
  ...(result.error ? { error: result.error } : {}),
4161
4336
  });
4162
4337
  }
4338
+ // How long a reopen waits for the launcher to say how it went (#1902). `open` and
4339
+ // `xdg-open` hand off and return in well under a second; one still running after
4340
+ // this is left to finish on its own, and the person is pointed at the URL instead.
4341
+ const BROWSER_LAUNCH_TIMEOUT_MS = 5_000;
4342
+ // openBrowser without blocking: the reopen on Enter runs while the login waits,
4343
+ // and spawnSync there would freeze the poll loop and the Ctrl-C handler for as long
4344
+ // as the launcher took. Same platform choice and verdicts as openBrowser.
4345
+ function openBrowserAsync(url) {
4346
+ if (hasNoGraphicalDisplay(process.platform, process.env)) {
4347
+ return Promise.resolve("failed");
4348
+ }
4349
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
4350
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
4351
+ return new Promise((resolve) => {
4352
+ let settled = false;
4353
+ const settle = (result) => {
4354
+ if (settled)
4355
+ return;
4356
+ settled = true;
4357
+ clearTimeout(timer);
4358
+ resolve(result);
4359
+ };
4360
+ const child = spawn(command, args, { stdio: "ignore" });
4361
+ // Unref'd at once, not when the wait below runs out: a launcher still running
4362
+ // when the login ends (Ctrl-C, approval) must not keep the CLI alive behind it.
4363
+ // Its exit event still arrives while the CLI is running for another reason.
4364
+ child.unref();
4365
+ // A launcher that has not answered in time may still open the page, so this
4366
+ // is "unknown" rather than "failed".
4367
+ const timer = setTimeout(() => settle("unknown"), BROWSER_LAUNCH_TIMEOUT_MS);
4368
+ timer.unref();
4369
+ child.once("error", (error) => settle(classifyBrowserOpen({ platform: process.platform, status: null, error })));
4370
+ child.once("exit", (status) => settle(classifyBrowserOpen({ platform: process.platform, status })));
4371
+ });
4372
+ }
4163
4373
  function sleep(ms) {
4164
4374
  return new Promise((resolve) => setTimeout(resolve, ms));
4165
4375
  }
@@ -4242,7 +4452,7 @@ async function main() {
4242
4452
  console.log("Deploy check passed: Dockerfile found and Docker is available.");
4243
4453
  }
4244
4454
  else {
4245
- console.log(`Deploy check passed: ${check.files} file(s), ${check.bytes} byte(s).`);
4455
+ console.log(deployCheckSummary(check, command.options.publicAssets));
4246
4456
  if (check.serverCode) {
4247
4457
  // The compressed server-code figure, printed on success as well as on
4248
4458
  // warning/rejection, so the number the size limit is enforced on is always
@@ -4291,10 +4501,10 @@ async function main() {
4291
4501
  const siteFlag = slug ? ` --site ${quoteSiteArg(slug)}` : "";
4292
4502
  throw new Error(`${base}\n\nStuck? Report it with context: 9pm support${siteFlag} -m "<what happened>"`);
4293
4503
  }
4294
- const databaseLine = result.database ? `database ${result.database.bindingName}:${result.database.name}` : "no database";
4504
+ const databaseClause = deployDatabaseClause(result.databases);
4295
4505
  const queued = result.deployment.status === "queued" || result.deployment.status === "publishing";
4296
4506
  console.log(`${queued ? "Queued" : "Deployed"} ${result.project.slug} -> ${result.url}`);
4297
- console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}, ${databaseLine}.`);
4507
+ console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}${databaseClause ? `, ${databaseClause}` : ""}.`);
4298
4508
  if (queued) {
4299
4509
  console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
4300
4510
  }
@@ -4562,6 +4772,13 @@ async function main() {
4562
4772
  process.exitCode = 0;
4563
4773
  return;
4564
4774
  }
4775
+ if (error instanceof DeviceAuthCancelledError) {
4776
+ // The person asked for this, so it is said plainly rather than reported as a
4777
+ // failure with whatever decoration reportCliError adds.
4778
+ console.error(error.message);
4779
+ process.exitCode = CANCELLED_EXIT_CODE;
4780
+ return;
4781
+ }
4565
4782
  reportCliError(error);
4566
4783
  process.exitCode = 1;
4567
4784
  }
@@ -4874,7 +5091,7 @@ Container deploys:
4874
5091
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
4875
5092
  The app must listen on 0.0.0.0:$PORT - always read the PORT env var; on some placements the platform holds the selected --port and hands the app a different one. Use --health-path for a cheap readiness endpoint.
4876
5093
  With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge, plus NINEPM_SQL_TOKEN where that bridge requires one - send it as "Authorization: Bearer <token>" or every query is refused.
4877
- With --with-auth, the app gets 9pm-managed end-user accounts (email one-time codes); it implies --with-db and is one-way (enable-only).
5094
+ With --with-auth, the app gets 9pm-managed end-user accounts (email one-time codes); it implies --with-db and is one-way: once enabled it stays on for every later deploy of the app, with or without the flag.
4878
5095
  With --with-email, a signed-in end user can send app invitation emails; it implies --with-auth and works on public apps only.
4879
5096
  9pm env set <app> KEY=value [KEY2=value2 ...] [--var] (secret by default; --var = readable config)
4880
5097
  9pm env set <app> --from-file <path> [--var] (KEY=value lines; # comments, export prefix, quotes ok)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.64.0",
3
+ "version": "0.68.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [