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 +2 -1
- package/dist/index.d.ts +25 -3
- package/dist/index.js +232 -15
- package/package.json +1 -1
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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)`}
|
|
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
|
|
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)
|