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 +13 -2
- package/dist/index.js +286 -42
- package/package.json +5 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
860
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|