ninepm 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -114,7 +114,7 @@ export interface CreatedServiceToken {
114
114
  clientSecret: string;
115
115
  }
116
116
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
117
- export declare const CLI_VERSION = "0.52.0";
117
+ export declare const CLI_VERSION = "0.53.0";
118
118
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
119
119
  export interface StoredCredentialEntry {
120
120
  token: string;
@@ -192,8 +192,24 @@ export declare function defaultNextWranglerConfig(appName: string): string;
192
192
  export declare function isOpenNextInstalled(directory: string): boolean;
193
193
  export declare class DeployError extends Error {
194
194
  readonly slug: string | undefined;
195
- constructor(message: string, slug: string | undefined);
195
+ readonly deliberate: boolean;
196
+ constructor(message: string, slug: string | undefined, deliberate?: boolean);
196
197
  }
198
+ /**
199
+ * A refusal the platform MEANT — an operator paused deploys, a queue is full —
200
+ * as opposed to something going wrong.
201
+ *
202
+ * The distinction earns its keep in two places: the server-written message is
203
+ * the entire response and prints alone rather than inside the raw JSON envelope,
204
+ * and the "Stuck? Report it" prompt is suppressed. There is nothing to report
205
+ * about a pause somebody chose, and inviting a ticket for every blocked deploy
206
+ * generates precisely the support volume that announcing the pause was meant to
207
+ * prevent (#1061).
208
+ */
209
+ export declare class DeliberateRefusalError extends Error {
210
+ constructor(message: string);
211
+ }
212
+ export declare function isDeliberateRefusal(error: unknown): boolean;
197
213
  export declare function deployDirectory(initialOptions: DeployOptions): Promise<DeployResult>;
198
214
  export type ApiReachability = "reachable" | "blocked";
199
215
  export declare function probeApiReachability(apiUrl: string, fetchFn: typeof fetch, timeoutMs?: number): Promise<ApiReachability>;
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
14
14
  import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
15
15
  import { buildFileManifest, readManifestFile } from "./manifest.js";
16
16
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
17
- export const CLI_VERSION = "0.52.0";
17
+ export const CLI_VERSION = "0.53.0";
18
18
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
19
19
  const TOKEN_SERVICE = "9pm";
20
20
  const TOKEN_ACCOUNT = "default";
@@ -184,12 +184,59 @@ const DOCKERFILE_STATIC_HINT = "Found a Dockerfile but no runtime was selected,
184
184
  // slug (server-normalized to "my-app"), so the raw name would not resolve.
185
185
  export class DeployError extends Error {
186
186
  slug;
187
- constructor(message, slug) {
187
+ // Whether the refusal underneath was a decision rather than a fault. Carried
188
+ // here because deployDirectory re-wraps everything it catches into a
189
+ // DeployError, which would otherwise erase the distinction before the command
190
+ // that needs it ever sees the error.
191
+ deliberate;
192
+ constructor(message, slug, deliberate = false) {
188
193
  super(message);
189
194
  this.name = "DeployError";
190
195
  this.slug = slug;
196
+ this.deliberate = deliberate;
191
197
  }
192
198
  }
199
+ /**
200
+ * A refusal the platform MEANT — an operator paused deploys, a queue is full —
201
+ * as opposed to something going wrong.
202
+ *
203
+ * The distinction earns its keep in two places: the server-written message is
204
+ * the entire response and prints alone rather than inside the raw JSON envelope,
205
+ * and the "Stuck? Report it" prompt is suppressed. There is nothing to report
206
+ * about a pause somebody chose, and inviting a ticket for every blocked deploy
207
+ * generates precisely the support volume that announcing the pause was meant to
208
+ * prevent (#1061).
209
+ */
210
+ export class DeliberateRefusalError extends Error {
211
+ constructor(message) {
212
+ super(message);
213
+ this.name = "DeliberateRefusalError";
214
+ }
215
+ }
216
+ /**
217
+ * Error codes whose message is written for the person reading it, by someone who
218
+ * decided to refuse them.
219
+ *
220
+ * Deliberately an allowlist of CODES rather than a rule about status. 503 is also
221
+ * what a genuinely broken dependency returns, and 426 is a general
222
+ * protocol-upgrade status an intermediary can raise on its own — treating either
223
+ * as intentional on the strength of the number would hide a real fault behind a
224
+ * calm sentence, and drop the support prompt exactly when it is wanted. A code
225
+ * has to be added here on purpose.
226
+ *
227
+ * `cli_version_outdated` is the control plane's enforced CLI floor (#996). It had
228
+ * its own status-only branch here until #1061, which is the shape this comment
229
+ * warns against: any 426, from anywhere, was treated as the floor.
230
+ */
231
+ const DELIBERATE_REFUSAL_CODES = new Set([
232
+ "deploys_frozen",
233
+ "cli_version_outdated",
234
+ ]);
235
+ export function isDeliberateRefusal(error) {
236
+ if (error instanceof DeliberateRefusalError)
237
+ return true;
238
+ return error instanceof DeployError && error.deliberate;
239
+ }
193
240
  // Shell-quote a value for a copy-pasteable command. Clean slugs pass through
194
241
  // unquoted; anything with shell-significant characters is single-quoted.
195
242
  function quoteSiteArg(value) {
@@ -231,7 +278,7 @@ export async function deployDirectory(initialOptions) {
231
278
  }
232
279
  if (error instanceof DeployError)
233
280
  throw error;
234
- throw new DeployError(error instanceof Error ? error.message : String(error), slugHolder.slug);
281
+ throw new DeployError(error instanceof Error ? error.message : String(error), slugHolder.slug, isDeliberateRefusal(error));
235
282
  }
236
283
  }
237
284
  // Cheap unauthenticated probe of the control-plane API before a deploy. The
@@ -1954,15 +2001,32 @@ async function expectOk(response, action) {
1954
2001
  if (response.ok) {
1955
2002
  return;
1956
2003
  }
1957
- // 426 is the control plane's enforced CLI floor (#996): the server-written
1958
- // message IS the response — surface it alone rather than burying it in the
1959
- // raw envelope the generic path prints.
1960
- if (response.status === 426) {
1961
- throw new Error(await apiErrorMessage(response, action));
1962
- }
1963
2004
  const text = await response.text();
2005
+ // A refusal somebody chose: print what they wrote, not the envelope it came
2006
+ // in. Both the code and the message have to be there — an allowlisted code
2007
+ // with no usable message falls through, so an empty reason can never turn
2008
+ // into an empty error. The raw form still applies to everything else, so no
2009
+ // detail is lost on the failures that genuinely are faults.
2010
+ const envelope = parseErrorEnvelope(text);
2011
+ const code = typeof envelope?.code === "string" ? envelope.code : "";
2012
+ const message = typeof envelope?.message === "string" ? envelope.message.trim() : "";
2013
+ if (code && message && DELIBERATE_REFUSAL_CODES.has(code)) {
2014
+ throw new DeliberateRefusalError(message);
2015
+ }
1964
2016
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
1965
2017
  }
2018
+ // Takes the already-consumed body text rather than the Response: a body can only
2019
+ // be read once, and expectOk needs the code and the message from the same read.
2020
+ function parseErrorEnvelope(text) {
2021
+ try {
2022
+ const parsed = JSON.parse(text);
2023
+ return parsed.error ?? null;
2024
+ }
2025
+ catch {
2026
+ // Not JSON (proxy error page, empty body).
2027
+ return null;
2028
+ }
2029
+ }
1966
2030
  // Advisory half of the control plane's CLI floor (#996): when a response says
1967
2031
  // this version is below the platform's recommended minimum, say so once per
1968
2032
  // invocation, on stderr so command output (including --json) stays parseable.
@@ -1991,15 +2055,9 @@ export function resetCliOutdatedAdvisoryForTests() {
1991
2055
  // expectOk's text when the body isn't that shape, so this can never lose detail.
1992
2056
  async function apiErrorMessage(response, action) {
1993
2057
  const text = await response.text();
1994
- try {
1995
- const parsed = JSON.parse(text);
1996
- const message = parsed.error?.message;
1997
- if (typeof message === "string" && message.trim()) {
1998
- return message.trim();
1999
- }
2000
- }
2001
- catch {
2002
- // Not JSON (proxy error page, empty body) — fall through to the raw form.
2058
+ const message = parseErrorEnvelope(text)?.message;
2059
+ if (typeof message === "string" && message.trim()) {
2060
+ return message.trim();
2003
2061
  }
2004
2062
  return `Failed to ${action}: ${response.status} ${text}`;
2005
2063
  }
@@ -3840,9 +3898,16 @@ async function main() {
3840
3898
  // exists) so `9pm support --site <slug>` resolves and /v1/me/support can
3841
3899
  // attach the failed deploy's id/status/error. Bare form when no slug is
3842
3900
  // known yet (failure before the project was created).
3901
+ const base = error instanceof Error ? error.message : String(error);
3902
+ // Nothing to report: the platform refused on purpose and said why.
3903
+ // Pointing the reader at support here would send them to open a ticket
3904
+ // about a decision we made, which is the opposite of what announcing it
3905
+ // was for (#1061).
3906
+ if (isDeliberateRefusal(error)) {
3907
+ throw new Error(base);
3908
+ }
3843
3909
  const slug = error instanceof DeployError ? error.slug : command.options.slug;
3844
3910
  const siteFlag = slug ? ` --site ${quoteSiteArg(slug)}` : "";
3845
- const base = error instanceof Error ? error.message : String(error);
3846
3911
  throw new Error(`${base}\n\nStuck? Report it with context: 9pm support${siteFlag} -m "<what happened>"`);
3847
3912
  }
3848
3913
  const databaseLine = result.database ? `database ${result.database.bindingName}:${result.database.name}` : "no database";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [