ninepm 0.27.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 +17 -2
- package/dist/index.js +170 -28
- package/package.json +7 -13
package/dist/cli-types.d.ts
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface DeployOptions {
|
|
|
29
29
|
pollTimeoutMs?: number;
|
|
30
30
|
fetchFn?: FetchLike;
|
|
31
31
|
check?: boolean;
|
|
32
|
+
onUploadProgress?: UploadProgress;
|
|
32
33
|
}
|
|
33
34
|
export interface DeployResult {
|
|
34
35
|
project: Project;
|
|
@@ -48,6 +49,7 @@ export interface SiteAccessOptions extends ApiOptions {
|
|
|
48
49
|
}
|
|
49
50
|
export interface DeleteSiteOptions extends SiteAccessOptions {
|
|
50
51
|
confirmSlug: string;
|
|
52
|
+
keepRecord?: boolean;
|
|
51
53
|
}
|
|
52
54
|
export interface AccessState {
|
|
53
55
|
access: AppAccessSettings;
|
|
@@ -110,7 +112,7 @@ export interface CreatedServiceToken {
|
|
|
110
112
|
clientSecret: string;
|
|
111
113
|
}
|
|
112
114
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
113
|
-
export declare const CLI_VERSION = "0.
|
|
115
|
+
export declare const CLI_VERSION = "0.30.0";
|
|
114
116
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
115
117
|
export interface CredentialDiagnostics {
|
|
116
118
|
platform: NodeJS.Platform;
|
|
@@ -270,11 +272,24 @@ export interface UploadRetryOptions {
|
|
|
270
272
|
baseDelayMs: number;
|
|
271
273
|
retryNetworkErrors?: boolean;
|
|
272
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;
|
|
273
283
|
type ContainerUploadInit = RequestInit & {
|
|
274
284
|
dispatcher?: Agent;
|
|
275
285
|
};
|
|
276
286
|
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>;
|
|
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;
|
|
278
293
|
type CliCommand = {
|
|
279
294
|
kind: "deploy";
|
|
280
295
|
options: DeployOptions;
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
|
|
|
12
12
|
import { assetFileTooLargeMessage, oversizedAssets } from "./deploy-limits.js";
|
|
13
13
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
14
14
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
15
|
-
export const CLI_VERSION = "0.
|
|
15
|
+
export const CLI_VERSION = "0.30.0";
|
|
16
16
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
17
17
|
const TOKEN_SERVICE = "9pm";
|
|
18
18
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -197,10 +197,19 @@ export async function deployDirectory(initialOptions) {
|
|
|
197
197
|
}
|
|
198
198
|
catch (error) {
|
|
199
199
|
// If this invocation created the project (vs. redeploying an existing app)
|
|
200
|
-
// and the deploy then failed,
|
|
201
|
-
// deploy leaves no orphaned
|
|
202
|
-
// (#195).
|
|
203
|
-
|
|
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)) {
|
|
204
213
|
try {
|
|
205
214
|
await deleteSite({
|
|
206
215
|
apiUrl: initialOptions.apiUrl,
|
|
@@ -208,6 +217,7 @@ export async function deployDirectory(initialOptions) {
|
|
|
208
217
|
...(initialOptions.fetchFn ? { fetchFn: initialOptions.fetchFn } : {}),
|
|
209
218
|
site: slugHolder.slug,
|
|
210
219
|
confirmSlug: slugHolder.slug,
|
|
220
|
+
...(slugHolder.retain ? { keepRecord: true } : {}),
|
|
211
221
|
});
|
|
212
222
|
}
|
|
213
223
|
catch {
|
|
@@ -239,6 +249,7 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
239
249
|
const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, options.slug ? { ...projectInput, slug: options.slug } : projectInput);
|
|
240
250
|
slugHolder.slug = project.slug;
|
|
241
251
|
slugHolder.created = projectCreated;
|
|
252
|
+
slugHolder.neverActive = project.activeDeploymentId === null;
|
|
242
253
|
const database = options.withDatabase
|
|
243
254
|
? await createDatabase(fetchFn, apiUrl, options.token, project.id, {
|
|
244
255
|
name: options.databaseName ?? `${project.slug}-db`,
|
|
@@ -287,25 +298,44 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
287
298
|
// npm undici@7 Agent to Node's bundled fetch (undici 6.x on Node 20/22)
|
|
288
299
|
// crosses a major-version dispatch-handler boundary; keeping both on the
|
|
289
300
|
// installed undici avoids that mismatch. Tests still inject their own fetch.
|
|
290
|
-
|
|
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
|
+
}
|
|
291
316
|
const queued = await finalizeDeployment(fetchFn, apiUrl, options.token, created.deployment.id);
|
|
292
317
|
const finalized = await pollDeployment(fetchFn, apiUrl, options.token, queued.id, options.pollTimeoutMs ?? DEFAULT_CONTAINER_POLL_TIMEOUT_MS, queued);
|
|
293
318
|
if (finalized.status === "failed") {
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
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.
|
|
298
328
|
const parts = [deploymentFailureMessage(finalized)];
|
|
299
329
|
const detail = await containerFailureDetail({ apiUrl, token: options.token, fetchFn }, project.slug, finalized.id);
|
|
300
330
|
if (detail) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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.`);
|
|
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}`);
|
|
308
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.`);
|
|
309
339
|
throw new Error(parts.join("\n\n"));
|
|
310
340
|
}
|
|
311
341
|
return {
|
|
@@ -663,7 +693,7 @@ export async function listSites(options) {
|
|
|
663
693
|
export async function deleteSite(options) {
|
|
664
694
|
const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}`, {
|
|
665
695
|
method: "DELETE",
|
|
666
|
-
body: JSON.stringify({ confirmSlug: options.confirmSlug }),
|
|
696
|
+
body: JSON.stringify({ confirmSlug: options.confirmSlug, ...(options.keepRecord ? { keepRecord: true } : {}) }),
|
|
667
697
|
});
|
|
668
698
|
return payload.deleted;
|
|
669
699
|
}
|
|
@@ -983,7 +1013,7 @@ export async function uploadRequestWithRetry(fetchFn, url, init, label, sizeByte
|
|
|
983
1013
|
}
|
|
984
1014
|
throw new Error(`Failed to ${label} after ${attempts} attempts (${formatBytesForCli(sizeBytes)}): ${lastError}`);
|
|
985
1015
|
}
|
|
986
|
-
export async function uploadContainerImage(fetchFn, imagePath, upload, retry = CONTAINER_UPLOAD_RETRY) {
|
|
1016
|
+
export async function uploadContainerImage(fetchFn, imagePath, upload, retry = CONTAINER_UPLOAD_RETRY, onProgress) {
|
|
987
1017
|
// Back the body with the file on disk instead of reading it all into memory:
|
|
988
1018
|
// slices are lazy views, so peak memory stays flat regardless of image size,
|
|
989
1019
|
// and a retried attempt just re-reads the same blob.
|
|
@@ -992,6 +1022,14 @@ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = C
|
|
|
992
1022
|
if (totalBytes > upload.maxBytes) {
|
|
993
1023
|
throw new Error(`Container image is ${totalBytes} bytes; upload session allows ${upload.maxBytes} bytes.`);
|
|
994
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 });
|
|
995
1033
|
// Disable undici's headers/body timeouts for the upload: on a slow link the
|
|
996
1034
|
// server sends no response headers until the whole body arrives, so the
|
|
997
1035
|
// default 300s timeouts abort a slow-but-live transfer. Keep a bounded
|
|
@@ -1005,26 +1043,27 @@ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = C
|
|
|
1005
1043
|
connect: { timeout: 30_000 },
|
|
1006
1044
|
});
|
|
1007
1045
|
try {
|
|
1008
|
-
|
|
1009
|
-
if (!partBytes || !upload.completeUrl || totalBytes <= partBytes) {
|
|
1046
|
+
if (!completeUrl) {
|
|
1010
1047
|
// A single PUT writes the whole object at a content-addressed key, so a
|
|
1011
1048
|
// re-send is idempotent and safe to retry.
|
|
1012
1049
|
const response = await uploadRequestWithRetry(fetchFn, upload.uploadUrl, { method: upload.method, headers: upload.headers, body: image, dispatcher }, "upload container image", totalBytes, retry);
|
|
1013
1050
|
await discardResponseBody(response);
|
|
1051
|
+
onProgress?.({ phase: "part", part: 1, totalParts, uploadedBytes: totalBytes, totalBytes });
|
|
1052
|
+
onProgress?.({ phase: "done", part: 1, totalParts, uploadedBytes: totalBytes, totalBytes });
|
|
1014
1053
|
return;
|
|
1015
1054
|
}
|
|
1016
1055
|
// Large images go up in parts so no single request exceeds the platform's
|
|
1017
1056
|
// request-body limits.
|
|
1018
|
-
|
|
1019
|
-
|
|
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 ?? "";
|
|
1020
1063
|
const parts = [];
|
|
1021
1064
|
for (let part = 1; part <= totalParts; part += 1) {
|
|
1022
1065
|
const chunk = image.slice((part - 1) * partBytes, Math.min(part * partBytes, totalBytes));
|
|
1023
1066
|
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
1067
|
const partRetry = uploadId ? retry : { ...retry, attempts: 1 };
|
|
1029
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);
|
|
1030
1069
|
const payload = (await response.json());
|
|
@@ -1033,23 +1072,120 @@ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = C
|
|
|
1033
1072
|
}
|
|
1034
1073
|
uploadId = payload.uploadId;
|
|
1035
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
|
+
});
|
|
1036
1082
|
}
|
|
1037
1083
|
// Completing consumes the uploadId server-side, so it is not idempotent: if
|
|
1038
1084
|
// this succeeds but the response is lost, a blind re-send 500s on the gone
|
|
1039
1085
|
// uploadId. Retry only explicit retryable statuses (server says "not done"),
|
|
1040
1086
|
// never an ambiguous network error.
|
|
1041
|
-
const completed = await uploadRequestWithRetry(fetchFn,
|
|
1087
|
+
const completed = await uploadRequestWithRetry(fetchFn, completeUrl, {
|
|
1042
1088
|
method: "POST",
|
|
1043
1089
|
headers: { ...upload.headers, "content-type": "application/json" },
|
|
1044
1090
|
body: JSON.stringify({ uploadId, parts }),
|
|
1045
1091
|
dispatcher,
|
|
1046
1092
|
}, "complete container image upload", totalBytes, { ...retry, retryNetworkErrors: false });
|
|
1047
1093
|
await discardResponseBody(completed);
|
|
1094
|
+
onProgress?.({ phase: "done", part: totalParts, totalParts, uploadedBytes: totalBytes, totalBytes });
|
|
1048
1095
|
}
|
|
1049
1096
|
finally {
|
|
1050
1097
|
await dispatcher.close().catch(() => { });
|
|
1051
1098
|
}
|
|
1052
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
|
+
};
|
|
1188
|
+
}
|
|
1053
1189
|
function buildContainerImage(directory) {
|
|
1054
1190
|
const root = path.resolve(directory);
|
|
1055
1191
|
if (!existsSync(path.join(root, "Dockerfile"))) {
|
|
@@ -1699,7 +1835,13 @@ async function runLogsCommand(command) {
|
|
|
1699
1835
|
if (command.lines !== undefined) {
|
|
1700
1836
|
lines = lines.slice(-command.lines);
|
|
1701
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.
|
|
1702
1843
|
console.log("");
|
|
1844
|
+
console.log("Failure log:");
|
|
1703
1845
|
for (const line of lines) {
|
|
1704
1846
|
console.log(line);
|
|
1705
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,22 +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
31
|
"node": ">=20.18.1"
|
|
34
32
|
},
|
|
35
33
|
"dependencies": {
|
|
36
34
|
"undici": "^7.10.0"
|
|
37
|
-
},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"@types/node": "^24.10.1",
|
|
40
|
-
"typescript": "^5.9.3"
|
|
41
35
|
}
|
|
42
36
|
}
|