ninepm 0.24.1 → 0.27.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
@@ -6,22 +6,9 @@ This package contains only client-side deploy code. It does not include the 9pm.
6
6
 
7
7
  ## Install
8
8
 
9
- Installer:
10
-
11
- ```sh
12
- curl -fsSL https://9pm.ai/install.sh | bash
13
- ```
14
-
15
- Tarball:
16
-
17
- ```sh
18
- npm install -g https://9pm.ai/downloads/9pm/latest.tgz
19
- ```
20
-
21
- Local early access tarball:
22
-
23
9
  ```sh
24
- npm install -g ./ninepm-0.24.1.tgz
10
+ npm i -g ninepm # persistent `9pm` binary
11
+ npx ninepm <args> # run without installing
25
12
  ```
26
13
 
27
14
  ## Configure
@@ -57,10 +44,10 @@ Check installed vs published CLI version:
57
44
  9pm doctor # surfaces the same drift line alongside credential/account health
58
45
  ```
59
46
 
60
- Upgrade by re-running the installer:
47
+ Upgrade:
61
48
 
62
49
  ```sh
63
- curl -fsSL https://9pm.ai/install.sh | bash
50
+ npm i -g ninepm@latest
64
51
  ```
65
52
 
66
53
  ## Deploy
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import type { AccessGrant, AccessGrantType, AccessProviderKey, AppAccessSettings, AppAccessServiceToken, AppAccessToken, AppDatabase, AppRuntime, ContainerRegion, Deployment, ManagedFileObject, ManagedFileShare, ManagedFileUsage, Project } from "./cli-types.js";
2
+ import { Agent } from "undici";
3
+ import type { AccessGrant, AccessGrantType, AccessProviderKey, AppAccessSettings, AppAccessServiceToken, AppAccessToken, AppDatabase, AppRuntime, ContainerImageUploadInstruction, ContainerRegion, Deployment, ManagedFileObject, ManagedFileShare, ManagedFileUsage, Project } from "./cli-types.js";
3
4
  export interface DeployOptions {
4
5
  directory: string;
5
6
  apiUrl: string;
@@ -109,7 +110,7 @@ export interface CreatedServiceToken {
109
110
  clientSecret: string;
110
111
  }
111
112
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
112
- export declare const CLI_VERSION = "0.24.1";
113
+ export declare const CLI_VERSION = "0.27.0";
113
114
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
114
115
  export interface CredentialDiagnostics {
115
116
  platform: NodeJS.Platform;
@@ -264,6 +265,16 @@ export declare function getManagedFile(options: FileGetOptions): Promise<{
264
265
  }>;
265
266
  export declare function deleteManagedFile(options: FileDeleteOptions): Promise<ManagedFilePutResult>;
266
267
  export declare function shareManagedFile(options: FileShareOptions): Promise<ManagedFileShareResult>;
268
+ export interface UploadRetryOptions {
269
+ attempts: number;
270
+ baseDelayMs: number;
271
+ retryNetworkErrors?: boolean;
272
+ }
273
+ type ContainerUploadInit = RequestInit & {
274
+ dispatcher?: Agent;
275
+ };
276
+ export declare function uploadRequestWithRetry(fetchFn: FetchLike, url: string, init: ContainerUploadInit, label: string, sizeBytes: number, options?: UploadRetryOptions): Promise<Response>;
277
+ export declare function uploadContainerImage(fetchFn: FetchLike, imagePath: string, upload: ContainerImageUploadInstruction, retry?: UploadRetryOptions): Promise<void>;
267
278
  type CliCommand = {
268
279
  kind: "deploy";
269
280
  options: DeployOptions;
package/dist/index.js CHANGED
@@ -5,13 +5,14 @@ import { 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";
8
- import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, openAsBlob, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
9
9
  import { fileURLToPath } from "node:url";
10
+ import { Agent, fetch as undiciFetch } from "undici";
10
11
  import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
11
12
  import { assetFileTooLargeMessage, oversizedAssets } from "./deploy-limits.js";
12
13
  import { buildFileManifest, readManifestFile } from "./manifest.js";
13
14
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
14
- export const CLI_VERSION = "0.24.1";
15
+ export const CLI_VERSION = "0.27.0";
15
16
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
16
17
  const TOKEN_SERVICE = "9pm";
17
18
  const TOKEN_ACCOUNT = "default";
@@ -220,7 +221,9 @@ export async function deployDirectory(initialOptions) {
220
221
  }
221
222
  async function runDeploy(initialOptions, slugHolder) {
222
223
  const options = prepareFrameworkDeploy(initialOptions);
223
- const fetchFn = options.fetchFn ?? fetch;
224
+ // #374: enrich network-level fetch rejections (EPIPE, header timeouts, …)
225
+ // across every deploy call — create, upload, finalize, poll — with one wrap.
226
+ const fetchFn = withFetchDiagnostics(options.fetchFn ?? fetch);
224
227
  const apiUrl = normalizeApiUrl(options.apiUrl);
225
228
  const runtime = options.runtime ?? (options.entry ? "worker" : "static");
226
229
  if (!options.runtime && runtime === "static" && hasDockerfile(options.directory)) {
@@ -257,7 +260,10 @@ async function runDeploy(initialOptions, slugHolder) {
257
260
  healthPath: options.healthPath ?? null,
258
261
  region: options.region ?? "auto",
259
262
  alwaysOn: options.alwaysOn ?? false,
260
- volumeGb: options.volumeGb ?? 0,
263
+ // Only send volumeGb when the user specified a disk (--volume) or
264
+ // explicitly none (--no-volume). Omitting it lets the server carry the
265
+ // prior deployment's disk forward instead of dropping it.
266
+ ...(options.volumeGb !== undefined ? { volumeGb: options.volumeGb } : {}),
261
267
  ...(options.allowVolumeDestroy ? { allowVolumeDestroy: true } : {}),
262
268
  ...(options.allowNamespaceReset ? { allowNamespaceReset: true } : {}),
263
269
  ...(options.cpus !== undefined ? { cpus: options.cpus } : {}),
@@ -276,11 +282,31 @@ async function runDeploy(initialOptions, slugHolder) {
276
282
  if (!created.containerImageUpload) {
277
283
  throw new Error("Container deployment did not return an image upload session.");
278
284
  }
279
- await uploadContainerImage(fetchFn, image.path, created.containerImageUpload);
285
+ // Use undici's own fetch (not Node's built-in) so it pairs with the
286
+ // undici Agent dispatcher from the same package version. Handing an
287
+ // npm undici@7 Agent to Node's bundled fetch (undici 6.x on Node 20/22)
288
+ // crosses a major-version dispatch-handler boundary; keeping both on the
289
+ // installed undici avoids that mismatch. Tests still inject their own fetch.
290
+ await uploadContainerImage(options.fetchFn ?? undiciFetchLike, image.path, created.containerImageUpload);
280
291
  const queued = await finalizeDeployment(fetchFn, apiUrl, options.token, created.deployment.id);
281
292
  const finalized = await pollDeployment(fetchFn, apiUrl, options.token, queued.id, options.pollTimeoutMs ?? DEFAULT_CONTAINER_POLL_TIMEOUT_MS, queued);
282
293
  if (finalized.status === "failed") {
283
- throw new Error(deploymentFailureMessage(finalized));
294
+ // #374: the deployment's own message is the neutral reason + support
295
+ // ref. Inline a bounded tail of the build/push log when we can fetch it
296
+ // (captured now, before any first-deploy rollback), so the output names
297
+ // the real cause without a separate command.
298
+ const parts = [deploymentFailureMessage(finalized)];
299
+ const detail = await containerFailureDetail({ apiUrl, token: options.token, fetchFn }, project.slug, finalized.id);
300
+ if (detail) {
301
+ parts.push(`Container build/push log (tail):\n${detail}`);
302
+ }
303
+ // The `9pm logs` pointer only helps while the app still exists. A failed
304
+ // first deploy is rolled back (deleted) by deployDirectory's catch, so
305
+ // the command would 404 — only offer it for redeploys of a surviving app.
306
+ if (!slugHolder.created) {
307
+ parts.push(`Run '9pm logs ${project.slug}' for the full container build/push log.`);
308
+ }
309
+ throw new Error(parts.join("\n\n"));
284
310
  }
285
311
  return {
286
312
  project,
@@ -501,7 +527,7 @@ async function doctor(options) {
501
527
  : "up to date";
502
528
  console.log(`CLI: ${versionCheck.current} (latest ${versionCheck.latest.version}, ${status})`);
503
529
  if (versionCheck.comparison === "behind") {
504
- console.log(`Update: curl -fsSL ${normalizeApiUrl(options.apiUrl).replace(/\/$/, "").replace(/\/\/api\./, "//")}/install.sh | bash`);
530
+ console.log("Update: npm i -g ninepm@latest");
505
531
  }
506
532
  }
507
533
  else if (versionError) {
@@ -594,21 +620,16 @@ export async function fetchLatestCliVersion(options) {
594
620
  const controller = new AbortController();
595
621
  const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5000);
596
622
  try {
597
- const response = await fetchFn(`${apiUrl}/downloads/9pm/latest.json`, { signal: controller.signal });
623
+ void apiUrl; // updater now sources the public npm registry, not the platform host
624
+ const response = await fetchFn("https://registry.npmjs.org/ninepm/latest", { signal: controller.signal });
598
625
  if (!response.ok) {
599
- throw new Error(`Latest version manifest returned ${response.status}.`);
626
+ throw new Error(`npm registry returned ${response.status} for ninepm.`);
600
627
  }
601
628
  const payload = (await response.json());
602
629
  if (typeof payload.version !== "string" || !payload.version.trim()) {
603
- throw new Error("Latest version manifest is missing version.");
630
+ throw new Error("npm registry response is missing a version.");
604
631
  }
605
- return {
606
- version: payload.version.trim(),
607
- ...(typeof payload.publishedAt === "string" ? { publishedAt: payload.publishedAt } : {}),
608
- ...(typeof payload.tarballSha256 === "string" && /^[0-9a-f]{64}$/i.test(payload.tarballSha256.trim())
609
- ? { tarballSha256: payload.tarballSha256.trim().toLowerCase() }
610
- : {}),
611
- };
632
+ return { version: payload.version.trim() };
612
633
  }
613
634
  finally {
614
635
  clearTimeout(timer);
@@ -825,48 +846,209 @@ function deploymentFailureMessage(deployment) {
825
846
  const error = deployment.metadata["error"];
826
847
  return typeof error === "string" && error.trim() ? error.trim() : "Deployment failed.";
827
848
  }
828
- async function uploadContainerImage(fetchFn, imagePath, upload) {
829
- const bytes = readFileSync(imagePath);
830
- if (bytes.byteLength > upload.maxBytes) {
831
- throw new Error(`Container image is ${bytes.byteLength} bytes; upload session allows ${upload.maxBytes} bytes.`);
832
- }
833
- const partBytes = upload.partBytes && upload.partBytes > 0 ? upload.partBytes : 0;
834
- if (!partBytes || !upload.completeUrl || bytes.byteLength <= partBytes) {
835
- const response = await fetchFn(upload.uploadUrl, {
836
- method: upload.method,
837
- headers: upload.headers,
838
- body: arrayBufferFromBytes(bytes),
839
- });
840
- await expectOk(response, "upload container image");
841
- return;
849
+ const CONTAINER_UPLOAD_RETRY = { attempts: 5, baseDelayMs: 1_000 };
850
+ // undici's fetch is web-compatible at runtime but its types don't structurally
851
+ // match the DOM-based FetchLike (it requires `duplex` on Request). We only ever
852
+ // call it with string URLs, so the cast is safe.
853
+ const undiciFetchLike = undiciFetch;
854
+ function isRetryableStatus(status) {
855
+ return status === 408 || status === 429 || (status >= 500 && status <= 599);
856
+ }
857
+ function uploadBackoffDelayMs(baseDelayMs, attempt) {
858
+ return baseDelayMs * 2 ** (attempt - 1);
859
+ }
860
+ async function safeReadResponseText(response) {
861
+ try {
862
+ return (await response.text()).trim();
842
863
  }
843
- // Large images go up in parts so no single request exceeds the platform's
844
- // request-body limits.
845
- const totalParts = Math.ceil(bytes.byteLength / partBytes);
846
- let uploadId = "";
847
- const parts = [];
848
- for (let part = 1; part <= totalParts; part += 1) {
849
- const chunk = bytes.subarray((part - 1) * partBytes, Math.min(part * partBytes, bytes.byteLength));
850
- const query = uploadId ? `?part=${part}&uploadId=${encodeURIComponent(uploadId)}` : `?part=${part}`;
851
- const response = await fetchFn(`${upload.uploadUrl}${query}`, {
852
- method: upload.method,
853
- headers: upload.headers,
854
- body: arrayBufferFromBytes(chunk),
864
+ catch {
865
+ return "";
866
+ }
867
+ }
868
+ const CONTAINER_FAILURE_TAIL_LINES = 20;
869
+ const CONTAINER_FAILURE_TAIL_CHARS = 2048;
870
+ // Diagnostics must never delay the failure we already have: cap the best-effort
871
+ // logs lookup so a slow or stalled /logs can't make the CLI appear hung (#374).
872
+ const CONTAINER_LOGS_TIMEOUT_MS = 5000;
873
+ // #374: a container publish failure records only the neutral message + support
874
+ // ref on the deployment; the actionable build/push detail lives on the
875
+ // container-logs endpoint (already scrubbed server-side). Best-effort fetch a
876
+ // bounded tail so the deploy output carries the real reason inline. Any
877
+ // failure — including the timeout — returns "" so it can never mask or delay
878
+ // the original deploy failure.
879
+ async function containerFailureDetail(options, slug, deploymentId) {
880
+ try {
881
+ const result = await apiRequest(options, `/v1/sites/${encodeURIComponent(slug)}/logs`, {
882
+ method: "GET",
883
+ signal: AbortSignal.timeout(CONTAINER_LOGS_TIMEOUT_MS),
855
884
  });
856
- await expectOk(response, `upload container image part ${part}/${totalParts}`);
857
- const payload = (await response.json());
858
- if (!payload.uploadId || !payload.etag) {
859
- throw new Error(`Container image part ${part} did not return an upload receipt.`);
885
+ // The logs route returns the app's *latest* container deployment; under a
886
+ // concurrent redeploy of the same slug that may be a different one, so only
887
+ // trust the tail when it's the deployment we just watched fail.
888
+ if (result.deployment?.id !== deploymentId) {
889
+ return "";
860
890
  }
861
- uploadId = payload.uploadId;
862
- parts.push({ part, etag: payload.etag });
891
+ const source = result.logs && result.logs.trim() ? result.logs : result.reason ?? "";
892
+ return tailText(source, CONTAINER_FAILURE_TAIL_LINES, CONTAINER_FAILURE_TAIL_CHARS);
863
893
  }
864
- const completed = await fetchFn(upload.completeUrl, {
865
- method: "POST",
866
- headers: { ...upload.headers, "content-type": "application/json" },
867
- body: JSON.stringify({ uploadId, parts }),
894
+ catch {
895
+ return "";
896
+ }
897
+ }
898
+ // Last `maxLines` lines, then clamped to `maxChars` (UTF-16 code units, so an
899
+ // approximate size cap that may cut mid-line — fine for a display tail) from the
900
+ // end. Returns "" for blank input.
901
+ function tailText(text, maxLines, maxChars) {
902
+ let trimmed = text.trim();
903
+ if (!trimmed) {
904
+ return "";
905
+ }
906
+ const lines = trimmed.split("\n");
907
+ if (lines.length > maxLines) {
908
+ trimmed = lines.slice(-maxLines).join("\n");
909
+ }
910
+ if (trimmed.length > maxChars) {
911
+ trimmed = trimmed.slice(-maxChars);
912
+ }
913
+ return trimmed;
914
+ }
915
+ // Turn undici's opaque `fetch failed` into something actionable by surfacing the
916
+ // underlying cause (e.g. EPIPE, UND_ERR_HEADERS_TIMEOUT).
917
+ function describeFetchError(error) {
918
+ if (!(error instanceof Error)) {
919
+ return String(error);
920
+ }
921
+ const cause = error.cause;
922
+ let causeText = "";
923
+ if (cause instanceof Error) {
924
+ const code = cause.code;
925
+ causeText = typeof code === "string" && code ? `${code}: ${cause.message}` : cause.message;
926
+ }
927
+ else if (cause && typeof cause === "object" && "code" in cause) {
928
+ causeText = String(cause.code);
929
+ }
930
+ else if (cause !== undefined && cause !== null) {
931
+ causeText = String(cause);
932
+ }
933
+ return causeText ? `${error.message} (${causeText})` : error.message;
934
+ }
935
+ // Release a successful response we don't need to read. Undici pins the
936
+ // connection until the body is consumed or cancelled, which would otherwise
937
+ // stall `Agent.close()` when the caller ignores the payload.
938
+ async function discardResponseBody(response) {
939
+ try {
940
+ await response.body?.cancel();
941
+ }
942
+ catch {
943
+ // Best effort: the socket is released regardless of a late cancel error.
944
+ }
945
+ }
946
+ // Send one upload request, retrying transient network failures and retryable
947
+ // HTTP statuses (408/429/5xx) with exponential backoff. Non-retryable statuses
948
+ // and exhausted retries throw with the cause, label, and byte size attached.
949
+ // Idempotency is the caller's responsibility: send the uploadId-less part 1
950
+ // with attempts=1 (a re-send starts a second multipart upload), and the
951
+ // non-idempotent "complete" with retryNetworkErrors=false (a re-send after a
952
+ // lost success 500s on the consumed uploadId).
953
+ export async function uploadRequestWithRetry(fetchFn, url, init, label, sizeBytes, options = CONTAINER_UPLOAD_RETRY) {
954
+ const { attempts, baseDelayMs, retryNetworkErrors = true } = options;
955
+ let lastError = "unknown error";
956
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
957
+ let response;
958
+ try {
959
+ response = await fetchFn(url, init);
960
+ }
961
+ catch (error) {
962
+ lastError = describeFetchError(error);
963
+ if (!retryNetworkErrors) {
964
+ throw new Error(`Failed to ${label}: ${lastError}. The request may have completed on the server; ` +
965
+ `check the deployment status before retrying rather than re-running blindly. ` +
966
+ `(${formatBytesForCli(sizeBytes)})`);
967
+ }
968
+ if (attempt >= attempts) {
969
+ break;
970
+ }
971
+ await sleep(uploadBackoffDelayMs(baseDelayMs, attempt));
972
+ continue;
973
+ }
974
+ if (response.ok) {
975
+ return response;
976
+ }
977
+ const body = await safeReadResponseText(response);
978
+ lastError = `${response.status} ${body}`.trim();
979
+ if (attempt >= attempts || !isRetryableStatus(response.status)) {
980
+ throw new Error(`Failed to ${label}: ${lastError} (${formatBytesForCli(sizeBytes)}, attempt ${attempt}/${attempts})`);
981
+ }
982
+ await sleep(uploadBackoffDelayMs(baseDelayMs, attempt));
983
+ }
984
+ throw new Error(`Failed to ${label} after ${attempts} attempts (${formatBytesForCli(sizeBytes)}): ${lastError}`);
985
+ }
986
+ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = CONTAINER_UPLOAD_RETRY) {
987
+ // Back the body with the file on disk instead of reading it all into memory:
988
+ // slices are lazy views, so peak memory stays flat regardless of image size,
989
+ // and a retried attempt just re-reads the same blob.
990
+ const image = await openAsBlob(imagePath);
991
+ const totalBytes = image.size;
992
+ if (totalBytes > upload.maxBytes) {
993
+ throw new Error(`Container image is ${totalBytes} bytes; upload session allows ${upload.maxBytes} bytes.`);
994
+ }
995
+ // Disable undici's headers/body timeouts for the upload: on a slow link the
996
+ // server sends no response headers until the whole body arrives, so the
997
+ // default 300s timeouts abort a slow-but-live transfer. Keep a bounded
998
+ // connect timeout so a dead host still fails fast. This Agent must be driven
999
+ // by a matching-version fetch (the caller passes undici's own fetch in
1000
+ // production) — see the call site.
1001
+ const dispatcher = new Agent({
1002
+ headersTimeout: 0,
1003
+ bodyTimeout: 0,
1004
+ keepAliveTimeout: 60_000,
1005
+ connect: { timeout: 30_000 },
868
1006
  });
869
- await expectOk(completed, "complete container image upload");
1007
+ try {
1008
+ const partBytes = upload.partBytes && upload.partBytes > 0 ? upload.partBytes : 0;
1009
+ if (!partBytes || !upload.completeUrl || totalBytes <= partBytes) {
1010
+ // A single PUT writes the whole object at a content-addressed key, so a
1011
+ // re-send is idempotent and safe to retry.
1012
+ const response = await uploadRequestWithRetry(fetchFn, upload.uploadUrl, { method: upload.method, headers: upload.headers, body: image, dispatcher }, "upload container image", totalBytes, retry);
1013
+ await discardResponseBody(response);
1014
+ return;
1015
+ }
1016
+ // Large images go up in parts so no single request exceeds the platform's
1017
+ // request-body limits.
1018
+ const totalParts = Math.ceil(totalBytes / partBytes);
1019
+ let uploadId = "";
1020
+ const parts = [];
1021
+ for (let part = 1; part <= totalParts; part += 1) {
1022
+ const chunk = image.slice((part - 1) * partBytes, Math.min(part * partBytes, totalBytes));
1023
+ const query = uploadId ? `?part=${part}&uploadId=${encodeURIComponent(uploadId)}` : `?part=${part}`;
1024
+ // Part 1 has no uploadId yet, so the server starts a fresh multipart
1025
+ // upload for it — retrying a part 1 whose response was lost would orphan
1026
+ // the first upload's parts. Send it once; parts 2..N carry the uploadId
1027
+ // and are safe to retry.
1028
+ const partRetry = uploadId ? retry : { ...retry, attempts: 1 };
1029
+ const response = await uploadRequestWithRetry(fetchFn, `${upload.uploadUrl}${query}`, { method: upload.method, headers: upload.headers, body: chunk, dispatcher }, `upload container image part ${part}/${totalParts}`, chunk.size, partRetry);
1030
+ const payload = (await response.json());
1031
+ if (!payload.uploadId || !payload.etag) {
1032
+ throw new Error(`Container image part ${part} did not return an upload receipt.`);
1033
+ }
1034
+ uploadId = payload.uploadId;
1035
+ parts.push({ part, etag: payload.etag });
1036
+ }
1037
+ // Completing consumes the uploadId server-side, so it is not idempotent: if
1038
+ // this succeeds but the response is lost, a blind re-send 500s on the gone
1039
+ // uploadId. Retry only explicit retryable statuses (server says "not done"),
1040
+ // never an ambiguous network error.
1041
+ const completed = await uploadRequestWithRetry(fetchFn, upload.completeUrl, {
1042
+ method: "POST",
1043
+ headers: { ...upload.headers, "content-type": "application/json" },
1044
+ body: JSON.stringify({ uploadId, parts }),
1045
+ dispatcher,
1046
+ }, "complete container image upload", totalBytes, { ...retry, retryNetworkErrors: false });
1047
+ await discardResponseBody(completed);
1048
+ }
1049
+ finally {
1050
+ await dispatcher.close().catch(() => { });
1051
+ }
870
1052
  }
871
1053
  function buildContainerImage(directory) {
872
1054
  const root = path.resolve(directory);
@@ -921,6 +1103,66 @@ async function expectOk(response, action) {
921
1103
  const text = await response.text();
922
1104
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
923
1105
  }
1106
+ // #374: Node's fetch rejects network-level failures (no HTTP response at all)
1107
+ // as a generic "fetch failed" TypeError and stashes the real reason on
1108
+ // `error.cause` (e.g. { code: "EPIPE" }). That code never reaches the user.
1109
+ // Pull it out so a failed deploy explains itself. This is the fetch-*rejection*
1110
+ // path only — expectOk already handles non-2xx HTTP responses, which are
1111
+ // resolved fetches and never surface here.
1112
+ function describeFetchCause(error) {
1113
+ if (!(error instanceof Error) || error.cause == null) {
1114
+ return null;
1115
+ }
1116
+ const cause = error.cause;
1117
+ if (typeof cause.code === "string" && cause.code) {
1118
+ return cause.code;
1119
+ }
1120
+ if (typeof cause.message === "string" && cause.message) {
1121
+ return cause.message;
1122
+ }
1123
+ return null;
1124
+ }
1125
+ // Short, platform-opaque hint for a network cause. Keyed on the Node/undici
1126
+ // error code; falls back to a generic connectivity nudge for anything else
1127
+ // (including bare cause messages).
1128
+ function fetchCauseHint(cause) {
1129
+ switch (cause) {
1130
+ case "EPIPE":
1131
+ case "ECONNRESET":
1132
+ return "connection reset — check your connection and retry; large images upload slowly";
1133
+ case "UND_ERR_HEADERS_TIMEOUT":
1134
+ case "UND_ERR_CONNECT_TIMEOUT":
1135
+ case "UND_ERR_BODY_TIMEOUT":
1136
+ case "ETIMEDOUT":
1137
+ return "the connection timed out — check your connection and retry";
1138
+ case "ENOTFOUND":
1139
+ case "EAI_AGAIN":
1140
+ return "could not resolve the host — check your connection and the API URL";
1141
+ case "ECONNREFUSED":
1142
+ return "the server refused the connection — check the API URL and retry";
1143
+ default:
1144
+ return "check your connection and retry";
1145
+ }
1146
+ }
1147
+ // Wrap the deploy flow's fetch so a network-level rejection carries its cause
1148
+ // code + hint instead of a bare "fetch failed". Only rejections are enriched;
1149
+ // resolved responses (including non-2xx, handled by expectOk) pass straight
1150
+ // through, and any non-network error is rethrown verbatim with its cause
1151
+ // preserved for debugging.
1152
+ function withFetchDiagnostics(fetchFn) {
1153
+ return async (input, init) => {
1154
+ try {
1155
+ return await fetchFn(input, init);
1156
+ }
1157
+ catch (error) {
1158
+ const cause = describeFetchCause(error);
1159
+ if (!cause) {
1160
+ throw error;
1161
+ }
1162
+ throw new Error(`Network request failed: ${cause} — ${fetchCauseHint(cause)}`, { cause: error });
1163
+ }
1164
+ };
1165
+ }
924
1166
  function normalizeApiUrl(value) {
925
1167
  return value.replace(/\/+$/g, "");
926
1168
  }
@@ -958,6 +1200,7 @@ const VALID_DEPLOY_FLAGS = new Set([
958
1200
  "region",
959
1201
  "always-on",
960
1202
  "volume",
1203
+ "no-volume",
961
1204
  "allow-data-wipe",
962
1205
  "allow-namespace-reset",
963
1206
  "cpus",
@@ -1138,6 +1381,16 @@ export function parseArgs(argv) {
1138
1381
  }
1139
1382
  options.volumeGb = volumeGb;
1140
1383
  }
1384
+ // --no-volume is the explicit "this app has no persistent disk" intent. Omitting
1385
+ // both --volume and --no-volume leaves volumeGb unset, so a redeploy keeps the
1386
+ // app's existing disk; --no-volume sends an explicit 0 to drop it (which the
1387
+ // server refuses without --allow-data-wipe when a disk already exists).
1388
+ if (flags.has("no-volume")) {
1389
+ if (volumeGb !== undefined) {
1390
+ throw new Error("--volume and --no-volume cannot be combined.");
1391
+ }
1392
+ options.volumeGb = 0;
1393
+ }
1141
1394
  const cpus = readIntegerFlag(flags, "cpus");
1142
1395
  if (cpus !== undefined) {
1143
1396
  if (cpus < 1 || cpus > 64) {
@@ -2372,15 +2625,11 @@ async function runLatestCommand(command) {
2372
2625
  return;
2373
2626
  }
2374
2627
  console.log(`Installed: ${check.current}`);
2375
- console.log(`Latest: ${check.latest.version}${check.latest.publishedAt ? ` (published ${check.latest.publishedAt})` : ""}`);
2376
- if (check.latest.tarballSha256) {
2377
- console.log(`SHA-256: ${check.latest.tarballSha256}`);
2378
- }
2628
+ console.log(`Latest: ${check.latest.version}`);
2379
2629
  if (check.comparison === "behind") {
2380
- const installHost = normalizeApiUrl(command.apiUrl).replace(/\/$/, "").replace(/\/\/api\./, "//");
2381
2630
  console.log("");
2382
2631
  console.log("A newer 9pm CLI is available. Update with:");
2383
- console.log(` curl -fsSL ${installHost}/install.sh | bash`);
2632
+ console.log(" npm i -g ninepm@latest (or run ad-hoc via: npx ninepm <args>)");
2384
2633
  }
2385
2634
  else if (check.comparison === "ahead") {
2386
2635
  console.log("Installed version is ahead of the published latest.");
@@ -2440,7 +2689,7 @@ function printUsage() {
2440
2689
  9pm --version
2441
2690
  9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth]
2442
2691
  9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
2443
- 9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb>] [--cpus <n>] [--memory <size>]
2692
+ 9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
2444
2693
  9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
2445
2694
  9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
2446
2695
  9pm deploy [directory] --description "<note>" optional short note shown next to the version in the dashboard
@@ -2498,8 +2747,12 @@ Container deploys:
2498
2747
  disk is mounted and its path is provided via NINEPM_DATA_DIR. Declare what the app needs with
2499
2748
  --cpus <n> and --memory <size> (512m, 2048, 4g); the platform picks a placement that fits.
2500
2749
  All of these may require platform enablement.
2501
- --allow-data-wipe acknowledges that switching an app to a different container placement may
2502
- destroy its existing persistent state; without it such a switch is refused.
2750
+ A redeploy KEEPS the app's existing disk automatically: omit --volume and the current disk is
2751
+ carried forward. To remove the disk on purpose pass --no-volume (and --allow-data-wipe, since it
2752
+ destroys the data). If a disk was previously detached by an old CLI, redeploy with --volume <same
2753
+ gb> --region <same region> to reattach the existing disk (it is reused, not recreated empty).
2754
+ --allow-data-wipe acknowledges that dropping a disk, or switching to a placement that can't keep
2755
+ it, destroys the app's existing persistent state; without it such a deploy is refused.
2503
2756
  --allow-namespace-reset acknowledges that a redeploy may start the app from a fresh, empty
2504
2757
  persistent store when its recorded one can no longer be found; without it such a deploy is refused.
2505
2758
  Regions are provider-neutral: auto, us-east, us-west, europe, asia, south-america, middle-east, oceania, africa.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.24.1",
3
+ "version": "0.27.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -30,7 +30,10 @@
30
30
  "prepack": "npm run build"
31
31
  },
32
32
  "engines": {
33
- "node": ">=20"
33
+ "node": ">=20.18.1"
34
+ },
35
+ "dependencies": {
36
+ "undici": "^7.10.0"
34
37
  },
35
38
  "devDependencies": {
36
39
  "@types/node": "^24.10.1",