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.
@@ -41,6 +41,7 @@ export interface ContainerImageUploadInstruction {
41
41
  expiresAt: string;
42
42
  maxBytes: number;
43
43
  partBytes?: number;
44
+ uploadId?: string;
44
45
  headers: Record<string, string>;
45
46
  }
46
47
  export interface AppDatabase {
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.27.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.27.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, roll the project back so a rejected first
201
- // deploy leaves no orphaned app holding the slug and a Sites quota slot
202
- // (#195). Best-effort: a cleanup failure must not mask the deploy error.
203
- if (slugHolder.created && slugHolder.slug) {
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
- await uploadContainerImage(options.fetchFn ?? undiciFetchLike, image.path, created.containerImageUpload);
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
- // #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.
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
- 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.`);
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
- const partBytes = upload.partBytes && upload.partBytes > 0 ? upload.partBytes : 0;
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
- const totalParts = Math.ceil(totalBytes / partBytes);
1019
- let uploadId = "";
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, upload.completeUrl, {
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.27.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
  }