ninepm 0.65.0 → 0.69.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
@@ -62,6 +62,15 @@ Deploy a static directory:
62
62
  9pm deploy . --name my-app --slug my-app
63
63
  ```
64
64
 
65
+ ### How long an app's address stays the same
66
+
67
+ Check this before printing a link or QR code. The live URL is `<slug>-<suffix>.9pm.ai`, and the suffix comes from the app's internal id, not from the slug:
68
+
69
+ - Redeploying under the same slug keeps the address.
70
+ - Deleting the app and creating it again under the same slug gives it a **new** address, because the new app gets a new id. Anything printed with the old one stops working.
71
+ - There is no rename: a different address means a different app.
72
+ - A custom domain you own, added on the dashboard's Domains page, gives the app an address of your own. Not every plan includes custom domains.
73
+
65
74
  Deploy a built framework output directory:
66
75
 
67
76
  ```sh
@@ -106,7 +115,7 @@ Deploy a first-class Worker runtime app:
106
115
 
107
116
  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
117
 
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.
118
+ 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
119
 
111
120
  Deploy a container app from a local Docker build:
112
121
 
@@ -137,6 +146,8 @@ Delete an app, its managed database, managed container app, and retained image:
137
146
  9pm delete my-app --confirm my-app
138
147
  ```
139
148
 
149
+ The app's address goes with it: creating an app again under the same slug gives it a new address, so printed links and QR codes stop working. `9pm delete` prints this warning before it deletes anything.
150
+
140
151
  ## Access Modes
141
152
 
142
153
  Apps are public by default.
@@ -221,6 +232,7 @@ Options:
221
232
  - `--name <name>` sets the app display name
222
233
  - `--slug <slug>` sets the URL-safe app slug
223
234
  - `--with-db` requests managed storage for the app
235
+ - `--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
236
  - `--database-name <name>` customizes the requested database name
225
237
  - `--binding <name>` customizes the database binding name
226
238
  - `--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.65.0";
127
+ export declare const CLI_VERSION = "0.69.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 = {
@@ -331,6 +343,7 @@ export interface ListSitesResult {
331
343
  export declare function listSites(options: ApiOptions & {
332
344
  owner?: string;
333
345
  }): Promise<ListSitesResult>;
346
+ export declare function deleteWarningLines(slug: string): string[];
334
347
  export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
335
348
  export interface DeviceAuthFeedbackOptions {
336
349
  progressStream?: NodeJS.WriteStream;
@@ -664,6 +677,17 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
664
677
  value: string;
665
678
  kind: "secret" | "var";
666
679
  }>;
680
+ export type ListedDeployment = Pick<Deployment, "id" | "version" | "status" | "createdAt"> & {
681
+ active: boolean;
682
+ };
683
+ export declare function deploymentStatusLabel(deployment: ListedDeployment): string;
684
+ export declare function formatDeploymentsList(deployments: ListedDeployment[]): string[];
685
+ export declare function deploymentsListJson<T extends ListedDeployment>(deployments: T[]): {
686
+ servingDeploymentId: string | null;
687
+ deployments: Array<T & {
688
+ serving: boolean;
689
+ }>;
690
+ };
667
691
  export declare function healthWithReason(status: string | null | undefined, reason: string | null | undefined): string;
668
692
  export declare function workerPublicFiles(paths: readonly string[], serverEntry: string, selection: string | null): Array<{
669
693
  path: string;
package/dist/index.js CHANGED
@@ -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.65.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.69.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
  }
@@ -1324,6 +1380,16 @@ export async function listSites(options) {
1324
1380
  const payload = await apiRequest(options, route, { method: "GET" });
1325
1381
  return { projects: payload.projects };
1326
1382
  }
1383
+ // Printed before the delete request, never as a prompt: agents run this non-interactively
1384
+ // and `--confirm` is already the consent. The address part is the one nobody expects:
1385
+ // the hostname's suffix comes from the project id (appHostnameForProject), a new app gets
1386
+ // a new random id, and no route renames a project, so the same slug never gets it back.
1387
+ export function deleteWarningLines(slug) {
1388
+ return [
1389
+ `Warning: deleting ${slug} also gives up its address for good. An app created again under the same slug gets a new address, so links, bookmarks and printed QR codes pointing at the current one will stop working.`,
1390
+ "Its deployments, files and managed database (if it has one) are deleted with it and cannot be recovered.",
1391
+ ];
1392
+ }
1327
1393
  export async function deleteSite(options) {
1328
1394
  const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
1329
1395
  const payload = await apiRequest(options, route, {
@@ -2305,7 +2371,14 @@ function buildContainerImage(directory) {
2305
2371
  const tempDir = mkdtempSync(path.join(os.tmpdir(), "9pm-container-"));
2306
2372
  const tag = `9pm-local-${Date.now()}-${process.pid}`;
2307
2373
  const imagePath = path.join(tempDir, "image.tar");
2308
- runDocker(["build", "--platform", "linux/amd64", "-t", tag, root], "build container image");
2374
+ // Build without Buildx's default provenance attestation. With it, Docker Desktop saves the
2375
+ // image as an index carrying an attestation manifest, and the push to Cloudflare's registry
2376
+ // through `wrangler containers push` fails with "blob unknown to registry"; the same image
2377
+ // without the attestation pushes (#1906: two of two attempts each way). The environment
2378
+ // variable rather than `--provenance=false`: it is what that test pushed, and a builder that
2379
+ // has no attestations - the legacy non-Buildx `docker build` - ignores an unknown variable
2380
+ // where it would reject an unknown flag. SBOM generation is untouched: it is off by default.
2381
+ runDocker(["build", "--platform", "linux/amd64", "-t", tag, root], "build container image", { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" });
2309
2382
  const inspected = spawnSync("docker", ["image", "inspect", tag, "--format", "{{.Os}}/{{.Architecture}}"], { encoding: "utf8" });
2310
2383
  if (inspected.status !== 0) {
2311
2384
  throw new Error(`Failed to inspect container image: ${inspected.stderr || inspected.stdout}`.trim());
@@ -2318,8 +2391,8 @@ function buildContainerImage(directory) {
2318
2391
  const sha256 = createHash("sha256").update(readFileSync(imagePath)).digest("hex");
2319
2392
  return { tag, path: imagePath, size, sha256 };
2320
2393
  }
2321
- function runDocker(args, action) {
2322
- const result = spawnSync("docker", args, { stdio: "inherit" });
2394
+ function runDocker(args, action, extraEnv = {}) {
2395
+ const result = spawnSync("docker", args, { stdio: "inherit", env: { ...process.env, ...extraEnv } });
2323
2396
  if (result.status !== 0) {
2324
2397
  throw new Error(`Failed to ${action}.`);
2325
2398
  }
@@ -3053,21 +3126,45 @@ function parseDeploymentsArgs(argv) {
3053
3126
  }
3054
3127
  throw new Error(`Unknown deployments subcommand: ${subcommand}`);
3055
3128
  }
3129
+ export function deploymentStatusLabel(deployment) {
3130
+ if (deployment.active) {
3131
+ return "serving";
3132
+ }
3133
+ return deployment.status === "active" ? "active, not serving" : deployment.status;
3134
+ }
3135
+ export function formatDeploymentsList(deployments) {
3136
+ if (deployments.length === 0) {
3137
+ return ["No deployments."];
3138
+ }
3139
+ const header = ["ID", "VERSION", "STATUS", "CREATED"];
3140
+ const rows = deployments.map((deployment) => [deployment.id, `v${deployment.version}`, deploymentStatusLabel(deployment), deployment.createdAt]);
3141
+ const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
3142
+ const render = (cells) => cells.map((cell, column) => (column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0))).join(" ");
3143
+ const lines = [render(header), ...rows.map(render), ""];
3144
+ const serving = deployments.find((deployment) => deployment.active);
3145
+ lines.push(serving ? `Serving: v${serving.version} (${serving.id}).` : "No deployment is serving this app.");
3146
+ if (deployments.some((deployment) => !deployment.active && deployment.status === "active")) {
3147
+ lines.push('Rows marked "active, not serving" are earlier deploys that finished; they no longer receive traffic.');
3148
+ }
3149
+ return lines;
3150
+ }
3151
+ export function deploymentsListJson(deployments) {
3152
+ return {
3153
+ servingDeploymentId: deployments.find((deployment) => deployment.active)?.id ?? null,
3154
+ deployments: deployments.map((deployment) => ({ ...deployment, serving: deployment.active })),
3155
+ };
3156
+ }
3056
3157
  async function runDeploymentsCommand(command) {
3057
3158
  const base = { apiUrl: command.apiUrl, token: command.token };
3058
3159
  const action = command.action;
3059
3160
  if (action.kind === "ls") {
3060
3161
  const result = await apiRequest(base, `/v1/sites/${encodeURIComponent(action.site)}/deployments`, { method: "GET" });
3061
3162
  if (action.json) {
3062
- console.log(JSON.stringify(result, null, 2));
3163
+ console.log(JSON.stringify({ ...result, ...deploymentsListJson(result.deployments) }, null, 2));
3063
3164
  return;
3064
3165
  }
3065
- if (result.deployments.length === 0) {
3066
- console.log("No deployments.");
3067
- return;
3068
- }
3069
- for (const deployment of result.deployments) {
3070
- console.log(`${deployment.active ? "*" : " "} ${deployment.id}\tv${deployment.version}\t${deployment.status}\t${deployment.createdAt}`);
3166
+ for (const line of formatDeploymentsList(result.deployments)) {
3167
+ console.log(line);
3071
3168
  }
3072
3169
  return;
3073
3170
  }
@@ -4389,7 +4486,7 @@ async function main() {
4389
4486
  console.log("Deploy check passed: Dockerfile found and Docker is available.");
4390
4487
  }
4391
4488
  else {
4392
- console.log(`Deploy check passed: ${check.files} file(s), ${check.bytes} byte(s).`);
4489
+ console.log(deployCheckSummary(check, command.options.publicAssets));
4393
4490
  if (check.serverCode) {
4394
4491
  // The compressed server-code figure, printed on success as well as on
4395
4492
  // warning/rejection, so the number the size limit is enforced on is always
@@ -4438,10 +4535,10 @@ async function main() {
4438
4535
  const siteFlag = slug ? ` --site ${quoteSiteArg(slug)}` : "";
4439
4536
  throw new Error(`${base}\n\nStuck? Report it with context: 9pm support${siteFlag} -m "<what happened>"`);
4440
4537
  }
4441
- const databaseLine = result.database ? `database ${result.database.bindingName}:${result.database.name}` : "no database";
4538
+ const databaseClause = deployDatabaseClause(result.databases);
4442
4539
  const queued = result.deployment.status === "queued" || result.deployment.status === "publishing";
4443
4540
  console.log(`${queued ? "Queued" : "Deployed"} ${result.project.slug} -> ${result.url}`);
4444
- console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}, ${databaseLine}.`);
4541
+ console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}${databaseClause ? `, ${databaseClause}` : ""}.`);
4445
4542
  if (queued) {
4446
4543
  console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
4447
4544
  }
@@ -4637,6 +4734,9 @@ async function main() {
4637
4734
  return;
4638
4735
  }
4639
4736
  if (command.kind === "delete") {
4737
+ for (const line of deleteWarningLines(command.confirmSlug)) {
4738
+ console.warn(line);
4739
+ }
4640
4740
  const deleted = await deleteSite({
4641
4741
  apiUrl: command.apiUrl,
4642
4742
  token: command.token,
@@ -5028,7 +5128,7 @@ Container deploys:
5028
5128
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
5029
5129
  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.
5030
5130
  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.
5031
- 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).
5131
+ 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.
5032
5132
  With --with-email, a signed-in end user can send app invitation emails; it implies --with-auth and works on public apps only.
5033
5133
  9pm env set <app> KEY=value [KEY2=value2 ...] [--var] (secret by default; --var = readable config)
5034
5134
  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.65.0",
3
+ "version": "0.69.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [