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.
- package/dist/cli-types.d.ts +1 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +432 -46
- package/package.json +10 -13
package/dist/cli-types.d.ts
CHANGED
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;
|
|
@@ -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.
|
|
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.
|
|
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,
|
|
200
|
-
// deploy leaves no orphaned
|
|
201
|
-
// (#195).
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
839
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
860
|
-
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"typescript": "^5.9.3"
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"undici": "^7.10.0"
|
|
38
35
|
}
|
|
39
36
|
}
|