ninepm 0.26.0 → 0.30.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.
@@ -41,6 +41,7 @@ export interface ContainerImageUploadInstruction {
41
41
  expiresAt: string;
42
42
  maxBytes: number;
43
43
  partBytes?: number;
44
+ uploadId?: string;
44
45
  headers: Record<string, string>;
45
46
  }
46
47
  export interface AppDatabase {
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;
@@ -28,6 +29,7 @@ export interface DeployOptions {
28
29
  pollTimeoutMs?: number;
29
30
  fetchFn?: FetchLike;
30
31
  check?: boolean;
32
+ onUploadProgress?: UploadProgress;
31
33
  }
32
34
  export interface DeployResult {
33
35
  project: Project;
@@ -47,6 +49,7 @@ export interface SiteAccessOptions extends ApiOptions {
47
49
  }
48
50
  export interface DeleteSiteOptions extends SiteAccessOptions {
49
51
  confirmSlug: string;
52
+ keepRecord?: boolean;
50
53
  }
51
54
  export interface AccessState {
52
55
  access: AppAccessSettings;
@@ -109,7 +112,7 @@ export interface CreatedServiceToken {
109
112
  clientSecret: string;
110
113
  }
111
114
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
112
- export declare const CLI_VERSION = "0.26.0";
115
+ export declare const CLI_VERSION = "0.30.0";
113
116
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
114
117
  export interface CredentialDiagnostics {
115
118
  platform: NodeJS.Platform;
@@ -264,6 +267,29 @@ export declare function getManagedFile(options: FileGetOptions): Promise<{
264
267
  }>;
265
268
  export declare function deleteManagedFile(options: FileDeleteOptions): Promise<ManagedFilePutResult>;
266
269
  export declare function shareManagedFile(options: FileShareOptions): Promise<ManagedFileShareResult>;
270
+ export interface UploadRetryOptions {
271
+ attempts: number;
272
+ baseDelayMs: number;
273
+ retryNetworkErrors?: boolean;
274
+ }
275
+ export interface UploadProgressEvent {
276
+ phase: "start" | "part" | "done";
277
+ part: number;
278
+ totalParts: number;
279
+ uploadedBytes: number;
280
+ totalBytes: number;
281
+ }
282
+ export type UploadProgress = (event: UploadProgressEvent) => void;
283
+ type ContainerUploadInit = RequestInit & {
284
+ dispatcher?: Agent;
285
+ };
286
+ export declare function uploadRequestWithRetry(fetchFn: FetchLike, url: string, init: ContainerUploadInit, label: string, sizeBytes: number, options?: UploadRetryOptions): Promise<Response>;
287
+ export declare function uploadContainerImage(fetchFn: FetchLike, imagePath: string, upload: ContainerImageUploadInstruction, retry?: UploadRetryOptions, onProgress?: UploadProgress): Promise<void>;
288
+ export interface UploadProgressReporter {
289
+ onProgress: UploadProgress;
290
+ stop(): void;
291
+ }
292
+ export declare function createUploadProgressReporter(stream?: NodeJS.WriteStream): UploadProgressReporter;
267
293
  type CliCommand = {
268
294
  kind: "deploy";
269
295
  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.26.0";
15
+ export const CLI_VERSION = "0.30.0";
15
16
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
16
17
  const TOKEN_SERVICE = "9pm";
17
18
  const TOKEN_ACCOUNT = "default";
@@ -196,10 +197,19 @@ export async function deployDirectory(initialOptions) {
196
197
  }
197
198
  catch (error) {
198
199
  // If this invocation created the project (vs. redeploying an existing app)
199
- // and the deploy then failed, roll the project back so a rejected first
200
- // deploy leaves no orphaned app holding the slug and a Sites quota slot
201
- // (#195). Best-effort: a cleanup failure must not mask the deploy error.
202
- if (slugHolder.created && slugHolder.slug) {
200
+ // and the deploy then failed, tear the project's resources back down so a
201
+ // rejected first deploy leaves no orphaned Worker/machine, slug, or Sites
202
+ // quota slot (#195). When the failure left a queryable record (`retain`), pass
203
+ // keepRecord so the live resources are still reclaimed now but the project +
204
+ // failed deployment + captured logs survive for `9pm logs` — the control plane
205
+ // sweeps the leftover record after a retention window (#403). A failed
206
+ // redeploy of a retained failed-only project (created=false, never active)
207
+ // gets the same keepRecord teardown: there's no live app to protect, and
208
+ // without it the new attempt's resources would linger until the sweep. A
209
+ // project this invocation didn't create is never fully deleted. Best-effort
210
+ // either way: a cleanup failure must not mask the deploy error.
211
+ const teardownRetainedRedeploy = !slugHolder.created && slugHolder.neverActive === true && slugHolder.retain === true;
212
+ if (slugHolder.slug && (slugHolder.created || teardownRetainedRedeploy)) {
203
213
  try {
204
214
  await deleteSite({
205
215
  apiUrl: initialOptions.apiUrl,
@@ -207,6 +217,7 @@ export async function deployDirectory(initialOptions) {
207
217
  ...(initialOptions.fetchFn ? { fetchFn: initialOptions.fetchFn } : {}),
208
218
  site: slugHolder.slug,
209
219
  confirmSlug: slugHolder.slug,
220
+ ...(slugHolder.retain ? { keepRecord: true } : {}),
210
221
  });
211
222
  }
212
223
  catch {
@@ -220,7 +231,9 @@ export async function deployDirectory(initialOptions) {
220
231
  }
221
232
  async function runDeploy(initialOptions, slugHolder) {
222
233
  const options = prepareFrameworkDeploy(initialOptions);
223
- const fetchFn = options.fetchFn ?? fetch;
234
+ // #374: enrich network-level fetch rejections (EPIPE, header timeouts, …)
235
+ // across every deploy call — create, upload, finalize, poll — with one wrap.
236
+ const fetchFn = withFetchDiagnostics(options.fetchFn ?? fetch);
224
237
  const apiUrl = normalizeApiUrl(options.apiUrl);
225
238
  const runtime = options.runtime ?? (options.entry ? "worker" : "static");
226
239
  if (!options.runtime && runtime === "static" && hasDockerfile(options.directory)) {
@@ -236,6 +249,7 @@ async function runDeploy(initialOptions, slugHolder) {
236
249
  const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, options.slug ? { ...projectInput, slug: options.slug } : projectInput);
237
250
  slugHolder.slug = project.slug;
238
251
  slugHolder.created = projectCreated;
252
+ slugHolder.neverActive = project.activeDeploymentId === null;
239
253
  const database = options.withDatabase
240
254
  ? await createDatabase(fetchFn, apiUrl, options.token, project.id, {
241
255
  name: options.databaseName ?? `${project.slug}-db`,
@@ -279,11 +293,50 @@ async function runDeploy(initialOptions, slugHolder) {
279
293
  if (!created.containerImageUpload) {
280
294
  throw new Error("Container deployment did not return an image upload session.");
281
295
  }
282
- await uploadContainerImage(fetchFn, image.path, created.containerImageUpload);
296
+ // Use undici's own fetch (not Node's built-in) so it pairs with the
297
+ // undici Agent dispatcher from the same package version. Handing an
298
+ // npm undici@7 Agent to Node's bundled fetch (undici 6.x on Node 20/22)
299
+ // crosses a major-version dispatch-handler boundary; keeping both on the
300
+ // installed undici avoids that mismatch. Tests still inject their own fetch.
301
+ // Live upload feedback (#404): a large image over a slow uplink otherwise
302
+ // uploads in total silence and looks hung. options.onUploadProgress is the
303
+ // explicit seam; without it, an injected fetchFn is our signal for a
304
+ // programmatic/test caller (the bundled CLI never sets it — parseDeployArgs
305
+ // builds options purely from argv), so only the real CLI run gets the
306
+ // implicit stderr reporter. stop() in the finally is a no-op on success and
307
+ // closes the spinner (timer + line) on failure, so the reporter can't
308
+ // outlive the upload.
309
+ const uploadProgress = options.onUploadProgress || options.fetchFn ? undefined : createUploadProgressReporter();
310
+ try {
311
+ await uploadContainerImage(options.fetchFn ?? undiciFetchLike, image.path, created.containerImageUpload, undefined, options.onUploadProgress ?? uploadProgress?.onProgress);
312
+ }
313
+ finally {
314
+ uploadProgress?.stop();
315
+ }
283
316
  const queued = await finalizeDeployment(fetchFn, apiUrl, options.token, created.deployment.id);
284
317
  const finalized = await pollDeployment(fetchFn, apiUrl, options.token, queued.id, options.pollTimeoutMs ?? DEFAULT_CONTAINER_POLL_TIMEOUT_MS, queued);
285
318
  if (finalized.status === "failed") {
286
- throw new Error(deploymentFailureMessage(finalized));
319
+ // The deploy left a finalized failure record. Keep it (skip the #195
320
+ // first-deploy rollback) so `9pm logs` / `9pm deployments ls` work for a
321
+ // first-time crash-on-boot — the exact moment the debugging surface used
322
+ // to disappear (#403). The control plane reclaims it after a retention
323
+ // window, so this doesn't re-introduce a permanent orphan.
324
+ slugHolder.retain = true;
325
+ // #374: the deployment's own message is the neutral reason + support ref.
326
+ // Inline a bounded tail of the captured container output when we can fetch
327
+ // it, so the output names the real cause without a separate command.
328
+ const parts = [deploymentFailureMessage(finalized)];
329
+ const detail = await containerFailureDetail({ apiUrl, token: options.token, fetchFn }, project.slug, finalized.id);
330
+ if (detail) {
331
+ // Neutral label on purpose: the captured text is the container's own
332
+ // output when the placement could obtain it (#403), else the build/push
333
+ // or health detail — the server doesn't tell us which, so don't claim.
334
+ parts.push(`Failure log (tail):\n${detail}`);
335
+ }
336
+ // The record now survives a failed first deploy too, so always point at
337
+ // `9pm logs` for the full captured log.
338
+ parts.push(`Run '9pm logs ${quoteSiteArg(project.slug)}' for the full failure log.`);
339
+ throw new Error(parts.join("\n\n"));
287
340
  }
288
341
  return {
289
342
  project,
@@ -640,7 +693,7 @@ export async function listSites(options) {
640
693
  export async function deleteSite(options) {
641
694
  const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}`, {
642
695
  method: "DELETE",
643
- body: JSON.stringify({ confirmSlug: options.confirmSlug }),
696
+ body: JSON.stringify({ confirmSlug: options.confirmSlug, ...(options.keepRecord ? { keepRecord: true } : {}) }),
644
697
  });
645
698
  return payload.deleted;
646
699
  }
@@ -823,48 +876,315 @@ function deploymentFailureMessage(deployment) {
823
876
  const error = deployment.metadata["error"];
824
877
  return typeof error === "string" && error.trim() ? error.trim() : "Deployment failed.";
825
878
  }
826
- async function uploadContainerImage(fetchFn, imagePath, upload) {
827
- const bytes = readFileSync(imagePath);
828
- if (bytes.byteLength > upload.maxBytes) {
829
- throw new Error(`Container image is ${bytes.byteLength} bytes; upload session allows ${upload.maxBytes} bytes.`);
879
+ const CONTAINER_UPLOAD_RETRY = { attempts: 5, baseDelayMs: 1_000 };
880
+ // undici's fetch is web-compatible at runtime but its types don't structurally
881
+ // match the DOM-based FetchLike (it requires `duplex` on Request). We only ever
882
+ // call it with string URLs, so the cast is safe.
883
+ const undiciFetchLike = undiciFetch;
884
+ function isRetryableStatus(status) {
885
+ return status === 408 || status === 429 || (status >= 500 && status <= 599);
886
+ }
887
+ function uploadBackoffDelayMs(baseDelayMs, attempt) {
888
+ return baseDelayMs * 2 ** (attempt - 1);
889
+ }
890
+ async function safeReadResponseText(response) {
891
+ try {
892
+ return (await response.text()).trim();
830
893
  }
831
- const partBytes = upload.partBytes && upload.partBytes > 0 ? upload.partBytes : 0;
832
- if (!partBytes || !upload.completeUrl || bytes.byteLength <= partBytes) {
833
- const response = await fetchFn(upload.uploadUrl, {
834
- method: upload.method,
835
- headers: upload.headers,
836
- body: arrayBufferFromBytes(bytes),
894
+ catch {
895
+ return "";
896
+ }
897
+ }
898
+ const CONTAINER_FAILURE_TAIL_LINES = 20;
899
+ const CONTAINER_FAILURE_TAIL_CHARS = 2048;
900
+ // Diagnostics must never delay the failure we already have: cap the best-effort
901
+ // logs lookup so a slow or stalled /logs can't make the CLI appear hung (#374).
902
+ const CONTAINER_LOGS_TIMEOUT_MS = 5000;
903
+ // #374: a container publish failure records only the neutral message + support
904
+ // ref on the deployment; the actionable build/push detail lives on the
905
+ // container-logs endpoint (already scrubbed server-side). Best-effort fetch a
906
+ // bounded tail so the deploy output carries the real reason inline. Any
907
+ // failure — including the timeout — returns "" so it can never mask or delay
908
+ // the original deploy failure.
909
+ async function containerFailureDetail(options, slug, deploymentId) {
910
+ try {
911
+ const result = await apiRequest(options, `/v1/sites/${encodeURIComponent(slug)}/logs`, {
912
+ method: "GET",
913
+ signal: AbortSignal.timeout(CONTAINER_LOGS_TIMEOUT_MS),
837
914
  });
838
- await expectOk(response, "upload container image");
839
- return;
915
+ // The logs route returns the app's *latest* container deployment; under a
916
+ // concurrent redeploy of the same slug that may be a different one, so only
917
+ // trust the tail when it's the deployment we just watched fail.
918
+ if (result.deployment?.id !== deploymentId) {
919
+ return "";
920
+ }
921
+ const source = result.logs && result.logs.trim() ? result.logs : result.reason ?? "";
922
+ return tailText(source, CONTAINER_FAILURE_TAIL_LINES, CONTAINER_FAILURE_TAIL_CHARS);
840
923
  }
841
- // Large images go up in parts so no single request exceeds the platform's
842
- // request-body limits.
843
- const totalParts = Math.ceil(bytes.byteLength / partBytes);
844
- let uploadId = "";
845
- const parts = [];
846
- for (let part = 1; part <= totalParts; part += 1) {
847
- const chunk = bytes.subarray((part - 1) * partBytes, Math.min(part * partBytes, bytes.byteLength));
848
- const query = uploadId ? `?part=${part}&uploadId=${encodeURIComponent(uploadId)}` : `?part=${part}`;
849
- const response = await fetchFn(`${upload.uploadUrl}${query}`, {
850
- method: upload.method,
851
- headers: upload.headers,
852
- body: arrayBufferFromBytes(chunk),
853
- });
854
- await expectOk(response, `upload container image part ${part}/${totalParts}`);
855
- const payload = (await response.json());
856
- if (!payload.uploadId || !payload.etag) {
857
- throw new Error(`Container image part ${part} did not return an upload receipt.`);
924
+ catch {
925
+ return "";
926
+ }
927
+ }
928
+ // Last `maxLines` lines, then clamped to `maxChars` (UTF-16 code units, so an
929
+ // approximate size cap that may cut mid-line — fine for a display tail) from the
930
+ // end. Returns "" for blank input.
931
+ function tailText(text, maxLines, maxChars) {
932
+ let trimmed = text.trim();
933
+ if (!trimmed) {
934
+ return "";
935
+ }
936
+ const lines = trimmed.split("\n");
937
+ if (lines.length > maxLines) {
938
+ trimmed = lines.slice(-maxLines).join("\n");
939
+ }
940
+ if (trimmed.length > maxChars) {
941
+ trimmed = trimmed.slice(-maxChars);
942
+ }
943
+ return trimmed;
944
+ }
945
+ // Turn undici's opaque `fetch failed` into something actionable by surfacing the
946
+ // underlying cause (e.g. EPIPE, UND_ERR_HEADERS_TIMEOUT).
947
+ function describeFetchError(error) {
948
+ if (!(error instanceof Error)) {
949
+ return String(error);
950
+ }
951
+ const cause = error.cause;
952
+ let causeText = "";
953
+ if (cause instanceof Error) {
954
+ const code = cause.code;
955
+ causeText = typeof code === "string" && code ? `${code}: ${cause.message}` : cause.message;
956
+ }
957
+ else if (cause && typeof cause === "object" && "code" in cause) {
958
+ causeText = String(cause.code);
959
+ }
960
+ else if (cause !== undefined && cause !== null) {
961
+ causeText = String(cause);
962
+ }
963
+ return causeText ? `${error.message} (${causeText})` : error.message;
964
+ }
965
+ // Release a successful response we don't need to read. Undici pins the
966
+ // connection until the body is consumed or cancelled, which would otherwise
967
+ // stall `Agent.close()` when the caller ignores the payload.
968
+ async function discardResponseBody(response) {
969
+ try {
970
+ await response.body?.cancel();
971
+ }
972
+ catch {
973
+ // Best effort: the socket is released regardless of a late cancel error.
974
+ }
975
+ }
976
+ // Send one upload request, retrying transient network failures and retryable
977
+ // HTTP statuses (408/429/5xx) with exponential backoff. Non-retryable statuses
978
+ // and exhausted retries throw with the cause, label, and byte size attached.
979
+ // Idempotency is the caller's responsibility: send the uploadId-less part 1
980
+ // with attempts=1 (a re-send starts a second multipart upload), and the
981
+ // non-idempotent "complete" with retryNetworkErrors=false (a re-send after a
982
+ // lost success 500s on the consumed uploadId).
983
+ export async function uploadRequestWithRetry(fetchFn, url, init, label, sizeBytes, options = CONTAINER_UPLOAD_RETRY) {
984
+ const { attempts, baseDelayMs, retryNetworkErrors = true } = options;
985
+ let lastError = "unknown error";
986
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
987
+ let response;
988
+ try {
989
+ response = await fetchFn(url, init);
858
990
  }
859
- uploadId = payload.uploadId;
860
- parts.push({ part, etag: payload.etag });
991
+ catch (error) {
992
+ lastError = describeFetchError(error);
993
+ if (!retryNetworkErrors) {
994
+ throw new Error(`Failed to ${label}: ${lastError}. The request may have completed on the server; ` +
995
+ `check the deployment status before retrying rather than re-running blindly. ` +
996
+ `(${formatBytesForCli(sizeBytes)})`);
997
+ }
998
+ if (attempt >= attempts) {
999
+ break;
1000
+ }
1001
+ await sleep(uploadBackoffDelayMs(baseDelayMs, attempt));
1002
+ continue;
1003
+ }
1004
+ if (response.ok) {
1005
+ return response;
1006
+ }
1007
+ const body = await safeReadResponseText(response);
1008
+ lastError = `${response.status} ${body}`.trim();
1009
+ if (attempt >= attempts || !isRetryableStatus(response.status)) {
1010
+ throw new Error(`Failed to ${label}: ${lastError} (${formatBytesForCli(sizeBytes)}, attempt ${attempt}/${attempts})`);
1011
+ }
1012
+ await sleep(uploadBackoffDelayMs(baseDelayMs, attempt));
861
1013
  }
862
- const completed = await fetchFn(upload.completeUrl, {
863
- method: "POST",
864
- headers: { ...upload.headers, "content-type": "application/json" },
865
- body: JSON.stringify({ uploadId, parts }),
1014
+ throw new Error(`Failed to ${label} after ${attempts} attempts (${formatBytesForCli(sizeBytes)}): ${lastError}`);
1015
+ }
1016
+ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = CONTAINER_UPLOAD_RETRY, onProgress) {
1017
+ // Back the body with the file on disk instead of reading it all into memory:
1018
+ // slices are lazy views, so peak memory stays flat regardless of image size,
1019
+ // and a retried attempt just re-reads the same blob.
1020
+ const image = await openAsBlob(imagePath);
1021
+ const totalBytes = image.size;
1022
+ if (totalBytes > upload.maxBytes) {
1023
+ throw new Error(`Container image is ${totalBytes} bytes; upload session allows ${upload.maxBytes} bytes.`);
1024
+ }
1025
+ // Single source of truth for the chunking decision: the start event's
1026
+ // totalParts and the actual upload branch below both derive from these, so
1027
+ // the advertised part count can't drift from the parts actually sent.
1028
+ // completeUrl is non-null exactly when the upload is multipart.
1029
+ const partBytes = upload.partBytes && upload.partBytes > 0 ? upload.partBytes : 0;
1030
+ const completeUrl = partBytes && upload.completeUrl && totalBytes > partBytes ? upload.completeUrl : null;
1031
+ const totalParts = completeUrl ? Math.ceil(totalBytes / partBytes) : 1;
1032
+ onProgress?.({ phase: "start", part: 0, totalParts, uploadedBytes: 0, totalBytes });
1033
+ // Disable undici's headers/body timeouts for the upload: on a slow link the
1034
+ // server sends no response headers until the whole body arrives, so the
1035
+ // default 300s timeouts abort a slow-but-live transfer. Keep a bounded
1036
+ // connect timeout so a dead host still fails fast. This Agent must be driven
1037
+ // by a matching-version fetch (the caller passes undici's own fetch in
1038
+ // production) — see the call site.
1039
+ const dispatcher = new Agent({
1040
+ headersTimeout: 0,
1041
+ bodyTimeout: 0,
1042
+ keepAliveTimeout: 60_000,
1043
+ connect: { timeout: 30_000 },
866
1044
  });
867
- await expectOk(completed, "complete container image upload");
1045
+ try {
1046
+ if (!completeUrl) {
1047
+ // A single PUT writes the whole object at a content-addressed key, so a
1048
+ // re-send is idempotent and safe to retry.
1049
+ const response = await uploadRequestWithRetry(fetchFn, upload.uploadUrl, { method: upload.method, headers: upload.headers, body: image, dispatcher }, "upload container image", totalBytes, retry);
1050
+ await discardResponseBody(response);
1051
+ onProgress?.({ phase: "part", part: 1, totalParts, uploadedBytes: totalBytes, totalBytes });
1052
+ onProgress?.({ phase: "done", part: 1, totalParts, uploadedBytes: totalBytes, totalBytes });
1053
+ return;
1054
+ }
1055
+ // Large images go up in parts so no single request exceeds the platform's
1056
+ // request-body limits.
1057
+ // A modern server issues the multipart uploadId up front (#391), so part 1
1058
+ // carries it from the start and is as retryable as parts 2..N. Fall back to
1059
+ // an empty id for older servers: there part 1 is uploadId-less, the server
1060
+ // starts a fresh session for it, and a blind retry would orphan the first —
1061
+ // so the guard below keeps part 1 single-attempt only in that legacy case.
1062
+ let uploadId = upload.uploadId ?? "";
1063
+ const parts = [];
1064
+ for (let part = 1; part <= totalParts; part += 1) {
1065
+ const chunk = image.slice((part - 1) * partBytes, Math.min(part * partBytes, totalBytes));
1066
+ const query = uploadId ? `?part=${part}&uploadId=${encodeURIComponent(uploadId)}` : `?part=${part}`;
1067
+ const partRetry = uploadId ? retry : { ...retry, attempts: 1 };
1068
+ 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);
1069
+ const payload = (await response.json());
1070
+ if (!payload.uploadId || !payload.etag) {
1071
+ throw new Error(`Container image part ${part} did not return an upload receipt.`);
1072
+ }
1073
+ uploadId = payload.uploadId;
1074
+ parts.push({ part, etag: payload.etag });
1075
+ onProgress?.({
1076
+ phase: "part",
1077
+ part,
1078
+ totalParts,
1079
+ uploadedBytes: Math.min(part * partBytes, totalBytes),
1080
+ totalBytes,
1081
+ });
1082
+ }
1083
+ // Completing consumes the uploadId server-side, so it is not idempotent: if
1084
+ // this succeeds but the response is lost, a blind re-send 500s on the gone
1085
+ // uploadId. Retry only explicit retryable statuses (server says "not done"),
1086
+ // never an ambiguous network error.
1087
+ const completed = await uploadRequestWithRetry(fetchFn, completeUrl, {
1088
+ method: "POST",
1089
+ headers: { ...upload.headers, "content-type": "application/json" },
1090
+ body: JSON.stringify({ uploadId, parts }),
1091
+ dispatcher,
1092
+ }, "complete container image upload", totalBytes, { ...retry, retryNetworkErrors: false });
1093
+ await discardResponseBody(completed);
1094
+ onProgress?.({ phase: "done", part: totalParts, totalParts, uploadedBytes: totalBytes, totalBytes });
1095
+ }
1096
+ finally {
1097
+ await dispatcher.close().catch(() => { });
1098
+ }
1099
+ }
1100
+ const UPLOAD_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1101
+ export function createUploadProgressReporter(stream = process.stderr) {
1102
+ const isTty = stream.isTTY === true;
1103
+ let timer;
1104
+ let frame = 0;
1105
+ let finished = false;
1106
+ let last;
1107
+ const label = (event) => {
1108
+ const total = formatBytesForCli(event.totalBytes);
1109
+ if (event.totalParts > 1) {
1110
+ const shown = Math.max(1, event.part);
1111
+ return `Uploading image… part ${shown}/${event.totalParts} · ${formatBytesForCli(event.uploadedBytes)} / ${total}`;
1112
+ }
1113
+ return `Uploading image… ${total}`;
1114
+ };
1115
+ const redraw = () => {
1116
+ if (!last)
1117
+ return;
1118
+ stream.write(`\r\x1b[K${UPLOAD_SPINNER_FRAMES[frame % UPLOAD_SPINNER_FRAMES.length]} ${label(last)}`);
1119
+ };
1120
+ const stopTimer = () => {
1121
+ if (timer) {
1122
+ clearInterval(timer);
1123
+ timer = undefined;
1124
+ }
1125
+ };
1126
+ const finalize = (line) => {
1127
+ if (finished)
1128
+ return;
1129
+ finished = true;
1130
+ stopTimer();
1131
+ if (isTty) {
1132
+ // Clear the in-place spinner line, then leave a clean final line behind.
1133
+ stream.write(`\r\x1b[K${line}\n`);
1134
+ }
1135
+ else {
1136
+ stream.write(`${line}\n`);
1137
+ }
1138
+ };
1139
+ const onProgress = (event) => {
1140
+ if (finished)
1141
+ return;
1142
+ last = event;
1143
+ if (event.phase === "done") {
1144
+ finalize(`✓ Uploaded image (${formatBytesForCli(event.totalBytes)})`);
1145
+ return;
1146
+ }
1147
+ if (isTty) {
1148
+ if (event.phase === "start") {
1149
+ // Animate a spinner so a single long part still visibly moves.
1150
+ redraw();
1151
+ timer = setInterval(() => {
1152
+ frame += 1;
1153
+ redraw();
1154
+ }, 120);
1155
+ if (typeof timer.unref === "function")
1156
+ timer.unref();
1157
+ }
1158
+ else {
1159
+ redraw();
1160
+ }
1161
+ return;
1162
+ }
1163
+ // Non-TTY: one line at start, and one per part boundary for multipart uploads.
1164
+ if (event.phase === "start") {
1165
+ stream.write(`Uploading image (${formatBytesForCli(event.totalBytes)})…\n`);
1166
+ }
1167
+ else if (event.totalParts > 1) {
1168
+ stream.write(`Uploaded part ${event.part}/${event.totalParts} (${formatBytesForCli(event.uploadedBytes)} / ${formatBytesForCli(event.totalBytes)})\n`);
1169
+ }
1170
+ };
1171
+ return {
1172
+ onProgress,
1173
+ // Structural cleanup for the failure path: no-op after done; otherwise stop
1174
+ // the timer and close the line. Skip the "✗" line when no progress was ever
1175
+ // shown (a pre-upload validation throw) — the real error prints right after,
1176
+ // and a failure label for an upload that never started would mislabel it.
1177
+ stop() {
1178
+ if (finished)
1179
+ return;
1180
+ if (last) {
1181
+ finalize("✗ Image upload failed");
1182
+ return;
1183
+ }
1184
+ finished = true;
1185
+ stopTimer();
1186
+ },
1187
+ };
868
1188
  }
869
1189
  function buildContainerImage(directory) {
870
1190
  const root = path.resolve(directory);
@@ -919,6 +1239,66 @@ async function expectOk(response, action) {
919
1239
  const text = await response.text();
920
1240
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
921
1241
  }
1242
+ // #374: Node's fetch rejects network-level failures (no HTTP response at all)
1243
+ // as a generic "fetch failed" TypeError and stashes the real reason on
1244
+ // `error.cause` (e.g. { code: "EPIPE" }). That code never reaches the user.
1245
+ // Pull it out so a failed deploy explains itself. This is the fetch-*rejection*
1246
+ // path only — expectOk already handles non-2xx HTTP responses, which are
1247
+ // resolved fetches and never surface here.
1248
+ function describeFetchCause(error) {
1249
+ if (!(error instanceof Error) || error.cause == null) {
1250
+ return null;
1251
+ }
1252
+ const cause = error.cause;
1253
+ if (typeof cause.code === "string" && cause.code) {
1254
+ return cause.code;
1255
+ }
1256
+ if (typeof cause.message === "string" && cause.message) {
1257
+ return cause.message;
1258
+ }
1259
+ return null;
1260
+ }
1261
+ // Short, platform-opaque hint for a network cause. Keyed on the Node/undici
1262
+ // error code; falls back to a generic connectivity nudge for anything else
1263
+ // (including bare cause messages).
1264
+ function fetchCauseHint(cause) {
1265
+ switch (cause) {
1266
+ case "EPIPE":
1267
+ case "ECONNRESET":
1268
+ return "connection reset — check your connection and retry; large images upload slowly";
1269
+ case "UND_ERR_HEADERS_TIMEOUT":
1270
+ case "UND_ERR_CONNECT_TIMEOUT":
1271
+ case "UND_ERR_BODY_TIMEOUT":
1272
+ case "ETIMEDOUT":
1273
+ return "the connection timed out — check your connection and retry";
1274
+ case "ENOTFOUND":
1275
+ case "EAI_AGAIN":
1276
+ return "could not resolve the host — check your connection and the API URL";
1277
+ case "ECONNREFUSED":
1278
+ return "the server refused the connection — check the API URL and retry";
1279
+ default:
1280
+ return "check your connection and retry";
1281
+ }
1282
+ }
1283
+ // Wrap the deploy flow's fetch so a network-level rejection carries its cause
1284
+ // code + hint instead of a bare "fetch failed". Only rejections are enriched;
1285
+ // resolved responses (including non-2xx, handled by expectOk) pass straight
1286
+ // through, and any non-network error is rethrown verbatim with its cause
1287
+ // preserved for debugging.
1288
+ function withFetchDiagnostics(fetchFn) {
1289
+ return async (input, init) => {
1290
+ try {
1291
+ return await fetchFn(input, init);
1292
+ }
1293
+ catch (error) {
1294
+ const cause = describeFetchCause(error);
1295
+ if (!cause) {
1296
+ throw error;
1297
+ }
1298
+ throw new Error(`Network request failed: ${cause} — ${fetchCauseHint(cause)}`, { cause: error });
1299
+ }
1300
+ };
1301
+ }
922
1302
  function normalizeApiUrl(value) {
923
1303
  return value.replace(/\/+$/g, "");
924
1304
  }
@@ -1455,7 +1835,13 @@ async function runLogsCommand(command) {
1455
1835
  if (command.lines !== undefined) {
1456
1836
  lines = lines.slice(-command.lines);
1457
1837
  }
1838
+ // The reason above is the platform's verdict; this block is the captured
1839
+ // failure detail — the container's own output when the placement could
1840
+ // obtain it (#403), else the build/push or health detail. Neutral label:
1841
+ // the server doesn't tell us which, so don't claim app output when it may
1842
+ // be platform output.
1458
1843
  console.log("");
1844
+ console.log("Failure log:");
1459
1845
  for (const line of lines) {
1460
1846
  console.log(line);
1461
1847
  }
package/package.json CHANGED
@@ -1,7 +1,13 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.26.0",
3
+ "version": "0.30.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
+ "homepage": "https://9pm.ai",
6
+ "keywords": [
7
+ "9pm",
8
+ "deploy",
9
+ "cli"
10
+ ],
5
11
  "type": "module",
6
12
  "license": "SEE LICENSE IN LICENSE.md",
7
13
  "publishConfig": {
@@ -21,19 +27,10 @@
21
27
  "README.md",
22
28
  "LICENSE.md"
23
29
  ],
24
- "scripts": {
25
- "generate": "node ../../scripts/generate-cli-package.mjs",
26
- "prebuild": "node ../../scripts/generate-cli-package.mjs",
27
- "build": "tsc -p tsconfig.json",
28
- "pretypecheck": "node ../../scripts/generate-cli-package.mjs",
29
- "typecheck": "tsc --noEmit -p tsconfig.json",
30
- "prepack": "npm run build"
31
- },
32
30
  "engines": {
33
- "node": ">=20"
31
+ "node": ">=20.18.1"
34
32
  },
35
- "devDependencies": {
36
- "@types/node": "^24.10.1",
37
- "typescript": "^5.9.3"
33
+ "dependencies": {
34
+ "undici": "^7.10.0"
38
35
  }
39
36
  }