ninepm 0.26.0 → 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/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.26.0";
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.26.0";
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)) {
@@ -279,11 +282,31 @@ async function runDeploy(initialOptions, slugHolder) {
279
282
  if (!created.containerImageUpload) {
280
283
  throw new Error("Container deployment did not return an image upload session.");
281
284
  }
282
- 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);
283
291
  const queued = await finalizeDeployment(fetchFn, apiUrl, options.token, created.deployment.id);
284
292
  const finalized = await pollDeployment(fetchFn, apiUrl, options.token, queued.id, options.pollTimeoutMs ?? DEFAULT_CONTAINER_POLL_TIMEOUT_MS, queued);
285
293
  if (finalized.status === "failed") {
286
- 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"));
287
310
  }
288
311
  return {
289
312
  project,
@@ -823,48 +846,209 @@ function deploymentFailureMessage(deployment) {
823
846
  const error = deployment.metadata["error"];
824
847
  return typeof error === "string" && error.trim() ? error.trim() : "Deployment failed.";
825
848
  }
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.`);
830
- }
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),
837
- });
838
- await expectOk(response, "upload container image");
839
- 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();
840
863
  }
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),
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),
853
884
  });
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.`);
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 "";
858
890
  }
859
- uploadId = payload.uploadId;
860
- 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);
861
893
  }
862
- const completed = await fetchFn(upload.completeUrl, {
863
- method: "POST",
864
- headers: { ...upload.headers, "content-type": "application/json" },
865
- 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 },
866
1006
  });
867
- 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
+ }
868
1052
  }
869
1053
  function buildContainerImage(directory) {
870
1054
  const root = path.resolve(directory);
@@ -919,6 +1103,66 @@ async function expectOk(response, action) {
919
1103
  const text = await response.text();
920
1104
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
921
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
+ }
922
1166
  function normalizeApiUrl(value) {
923
1167
  return value.replace(/\/+$/g, "");
924
1168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.26.0",
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",