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 +4 -17
- package/dist/index.d.ts +13 -2
- package/dist/index.js +316 -63
- package/package.json +5 -2
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
|
|
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
|
|
47
|
+
Upgrade:
|
|
61
48
|
|
|
62
49
|
```sh
|
|
63
|
-
|
|
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
|
|
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)) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(`
|
|
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("
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
862
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
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}
|
|
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(
|
|
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
|
-
|
|
2502
|
-
|
|
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.
|
|
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",
|