ninepm 0.51.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 +31 -2
- package/dist/index.js +185 -19
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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>;
|
|
@@ -291,6 +307,17 @@ export interface UpdateNudgeOptions {
|
|
|
291
307
|
env?: NodeJS.ProcessEnv;
|
|
292
308
|
}
|
|
293
309
|
export declare function deployUpdateNudge(options?: UpdateNudgeOptions): Promise<string | null>;
|
|
310
|
+
export interface SkillFreshness {
|
|
311
|
+
status: "up_to_date" | "stale" | "not_installed" | "unknown";
|
|
312
|
+
installedStamp?: string;
|
|
313
|
+
publishedStamp?: string;
|
|
314
|
+
detail?: string;
|
|
315
|
+
}
|
|
316
|
+
export declare function checkSkillFreshness(options?: {
|
|
317
|
+
fetchFn?: FetchLike;
|
|
318
|
+
skillPath?: string;
|
|
319
|
+
timeoutMs?: number;
|
|
320
|
+
}): Promise<SkillFreshness>;
|
|
294
321
|
export interface ListSitesResult {
|
|
295
322
|
projects: Project[];
|
|
296
323
|
}
|
|
@@ -360,6 +387,8 @@ export interface DeviceAuthProgressReporter {
|
|
|
360
387
|
stop(): void;
|
|
361
388
|
}
|
|
362
389
|
export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
|
|
390
|
+
export declare function noteCliOutdatedAdvisory(response: Response, log?: (line: string) => void): void;
|
|
391
|
+
export declare function resetCliOutdatedAdvisoryForTests(): void;
|
|
363
392
|
type CliCommand = {
|
|
364
393
|
kind: "deploy";
|
|
365
394
|
options: DeployOptions;
|
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.
|
|
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
|
-
|
|
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
|
|
@@ -499,6 +546,7 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
499
546
|
method: upload.method,
|
|
500
547
|
headers: {
|
|
501
548
|
authorization: `Bearer ${options.token}`,
|
|
549
|
+
...cliVersionHeader(),
|
|
502
550
|
...upload.headers,
|
|
503
551
|
},
|
|
504
552
|
body: arrayBufferFromBytes(body),
|
|
@@ -856,8 +904,9 @@ async function doctor(options) {
|
|
|
856
904
|
catch (error) {
|
|
857
905
|
versionError = error instanceof Error ? error.message : String(error);
|
|
858
906
|
}
|
|
907
|
+
const skill = await checkSkillFreshness();
|
|
859
908
|
if (options.json) {
|
|
860
|
-
console.log(JSON.stringify({ apiUrl: normalizeApiUrl(options.apiUrl), credentials, account, accountError, version: versionCheck, versionError }, null, 2));
|
|
909
|
+
console.log(JSON.stringify({ apiUrl: normalizeApiUrl(options.apiUrl), credentials, account, accountError, version: versionCheck, versionError, skill }, null, 2));
|
|
861
910
|
return;
|
|
862
911
|
}
|
|
863
912
|
console.log("9pm.ai doctor");
|
|
@@ -898,6 +947,18 @@ async function doctor(options) {
|
|
|
898
947
|
else if (versionError) {
|
|
899
948
|
console.log(`CLI: ${CLI_VERSION} (latest unavailable: ${versionError})`);
|
|
900
949
|
}
|
|
950
|
+
if (skill.status === "up_to_date") {
|
|
951
|
+
console.log(`Skill: up to date (stamp ${skill.installedStamp})`);
|
|
952
|
+
}
|
|
953
|
+
else if (skill.status === "stale") {
|
|
954
|
+
console.log(`Skill: stale (installed ${skill.installedStamp ?? "no stamp"}, published ${skill.publishedStamp}). Refresh: ${SKILL_REFRESH_COMMAND}`);
|
|
955
|
+
}
|
|
956
|
+
else if (skill.status === "not_installed") {
|
|
957
|
+
console.log("Skill: not installed (expected at ~/.agents/skills/9pm/SKILL.md)");
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
console.log(`Skill: installed; freshness unknown (${skill.detail ?? "no detail"})`);
|
|
961
|
+
}
|
|
901
962
|
}
|
|
902
963
|
export async function getSiteAccess(options) {
|
|
903
964
|
return apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
|
|
@@ -1071,6 +1132,56 @@ export async function deployUpdateNudge(options = {}) {
|
|
|
1071
1132
|
}
|
|
1072
1133
|
return `Update available: 9pm CLI ${CLI_VERSION} -> ${latest}. Run: npm i -g ninepm@latest`;
|
|
1073
1134
|
}
|
|
1135
|
+
// Skill-freshness half of `9pm doctor` (#996): compare the installed skill's
|
|
1136
|
+
// stamp line against the public source copy — the same reference SKILL.md's
|
|
1137
|
+
// own "Keeping this skill fresh" section names, and the source the install
|
|
1138
|
+
// command actually pulls from (the platform-hosted copy tracks releases and
|
|
1139
|
+
// can lag it). Never throws: doctor is diagnostics, not a gate.
|
|
1140
|
+
const SKILL_INSTALL_PATH_SEGMENTS = [".agents", "skills", "9pm", "SKILL.md"];
|
|
1141
|
+
const SKILL_SOURCE_URL = "https://raw.githubusercontent.com/tmrw-realityos/9pm-skill/main/skills/9pm/SKILL.md";
|
|
1142
|
+
const SKILL_STAMP_PATTERN = /^Skill guide stamp:\s*(\S+)/m;
|
|
1143
|
+
const SKILL_REFRESH_COMMAND = "npx skills add tmrw-realityos/9pm-skill --skill 9pm -g";
|
|
1144
|
+
export async function checkSkillFreshness(options = {}) {
|
|
1145
|
+
const skillPath = options.skillPath ?? path.join(os.homedir(), ...SKILL_INSTALL_PATH_SEGMENTS);
|
|
1146
|
+
let installed;
|
|
1147
|
+
try {
|
|
1148
|
+
installed = readFileSync(skillPath, "utf8");
|
|
1149
|
+
}
|
|
1150
|
+
catch {
|
|
1151
|
+
return { status: "not_installed" };
|
|
1152
|
+
}
|
|
1153
|
+
const installedStamp = SKILL_STAMP_PATTERN.exec(installed)?.[1] ?? null;
|
|
1154
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
1155
|
+
const controller = new AbortController();
|
|
1156
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 3000);
|
|
1157
|
+
let publishedStamp = null;
|
|
1158
|
+
try {
|
|
1159
|
+
const response = await fetchFn(SKILL_SOURCE_URL, { signal: controller.signal });
|
|
1160
|
+
if (!response.ok) {
|
|
1161
|
+
throw new Error(`skill source returned ${response.status}`);
|
|
1162
|
+
}
|
|
1163
|
+
publishedStamp = SKILL_STAMP_PATTERN.exec(await response.text())?.[1] ?? null;
|
|
1164
|
+
}
|
|
1165
|
+
catch (error) {
|
|
1166
|
+
return {
|
|
1167
|
+
status: "unknown",
|
|
1168
|
+
...(installedStamp ? { installedStamp } : {}),
|
|
1169
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
finally {
|
|
1173
|
+
clearTimeout(timer);
|
|
1174
|
+
}
|
|
1175
|
+
if (!publishedStamp) {
|
|
1176
|
+
return { status: "unknown", ...(installedStamp ? { installedStamp } : {}), detail: "published copy has no stamp line" };
|
|
1177
|
+
}
|
|
1178
|
+
// An installed copy with no stamp predates the stamp convention, which is
|
|
1179
|
+
// itself proof it is behind.
|
|
1180
|
+
if (!installedStamp || installedStamp !== publishedStamp) {
|
|
1181
|
+
return { status: "stale", ...(installedStamp ? { installedStamp } : {}), publishedStamp };
|
|
1182
|
+
}
|
|
1183
|
+
return { status: "up_to_date", installedStamp, publishedStamp };
|
|
1184
|
+
}
|
|
1074
1185
|
// Best-effort read of the update-check cache; the caller judges freshness.
|
|
1075
1186
|
// checkedAtMs is null when the file is missing or malformed (so the next probe
|
|
1076
1187
|
// runs now), and latest is null when the last attempt failed to get an answer
|
|
@@ -1114,7 +1225,7 @@ async function deviceAuthFlow(options) {
|
|
|
1114
1225
|
// made here; an unpredictable store failure is caught after the poll instead.
|
|
1115
1226
|
const startResponse = await fetchFn(`${apiUrl}/v1/oauth/device/start`, {
|
|
1116
1227
|
method: "POST",
|
|
1117
|
-
headers: { "content-type": "application/json" },
|
|
1228
|
+
headers: { "content-type": "application/json", ...cliVersionHeader() },
|
|
1118
1229
|
body: JSON.stringify({ machineName: options.machineName ?? os.hostname(), purpose: options.purpose }),
|
|
1119
1230
|
});
|
|
1120
1231
|
// The control plane refuses a signup it knows can never be approved (#507) with
|
|
@@ -1180,7 +1291,7 @@ async function deviceAuthFlow(options) {
|
|
|
1180
1291
|
await sleep(intervalMs);
|
|
1181
1292
|
const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
|
|
1182
1293
|
method: "POST",
|
|
1183
|
-
headers: { "content-type": "application/json" },
|
|
1294
|
+
headers: { "content-type": "application/json", ...cliVersionHeader() },
|
|
1184
1295
|
body: JSON.stringify({ code: deviceCode }),
|
|
1185
1296
|
});
|
|
1186
1297
|
if (pollResponse.status === 400) {
|
|
@@ -1349,6 +1460,7 @@ export async function putManagedFile(options) {
|
|
|
1349
1460
|
headers: {
|
|
1350
1461
|
authorization: `Bearer ${options.token}`,
|
|
1351
1462
|
"content-type": "application/octet-stream",
|
|
1463
|
+
...cliVersionHeader(),
|
|
1352
1464
|
},
|
|
1353
1465
|
body: bytes,
|
|
1354
1466
|
});
|
|
@@ -1359,7 +1471,7 @@ export async function getManagedFile(options) {
|
|
|
1359
1471
|
const apiUrl = normalizeApiUrl(options.apiUrl);
|
|
1360
1472
|
const response = await fetchFn(`${apiUrl}${filesRoute(options)}/${encodeURIComponent(options.fileId)}`, {
|
|
1361
1473
|
method: "GET",
|
|
1362
|
-
headers: { authorization: `Bearer ${options.token}
|
|
1474
|
+
headers: { authorization: `Bearer ${options.token}`, ...cliVersionHeader() },
|
|
1363
1475
|
});
|
|
1364
1476
|
await expectOk(response, "download file");
|
|
1365
1477
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
@@ -1432,7 +1544,7 @@ async function finalizeDeployment(fetchFn, apiUrl, token, deploymentId) {
|
|
|
1432
1544
|
async function getDeployment(fetchFn, apiUrl, token, deploymentId) {
|
|
1433
1545
|
const response = await fetchFn(`${apiUrl}/v1/deployments/${encodeURIComponent(deploymentId)}`, {
|
|
1434
1546
|
method: "GET",
|
|
1435
|
-
headers: { authorization: `Bearer ${token}
|
|
1547
|
+
headers: { authorization: `Bearer ${token}`, ...cliVersionHeader() },
|
|
1436
1548
|
});
|
|
1437
1549
|
const payload = await expectJson(response, "get deployment");
|
|
1438
1550
|
return payload.deployment;
|
|
@@ -1869,20 +1981,73 @@ function jsonHeaders(token) {
|
|
|
1869
1981
|
return {
|
|
1870
1982
|
authorization: `Bearer ${token}`,
|
|
1871
1983
|
"content-type": "application/json",
|
|
1872
|
-
|
|
1984
|
+
...cliVersionHeader(),
|
|
1873
1985
|
};
|
|
1874
1986
|
}
|
|
1987
|
+
// Every control-plane request must carry the CLI's version (#996): the server's
|
|
1988
|
+
// version floor only judges requests that have it, so a hand-built header object
|
|
1989
|
+
// that omits it silently exempts that call from both the advisory and an
|
|
1990
|
+
// enforced floor. Spread this into any headers literal that bypasses
|
|
1991
|
+
// apiRequest/jsonHeaders.
|
|
1992
|
+
function cliVersionHeader() {
|
|
1993
|
+
return { "x-9pm-cli-version": CLI_VERSION };
|
|
1994
|
+
}
|
|
1875
1995
|
async function expectJson(response, action) {
|
|
1876
1996
|
await expectOk(response, action);
|
|
1877
1997
|
return (await response.json());
|
|
1878
1998
|
}
|
|
1879
1999
|
async function expectOk(response, action) {
|
|
2000
|
+
noteCliOutdatedAdvisory(response);
|
|
1880
2001
|
if (response.ok) {
|
|
1881
2002
|
return;
|
|
1882
2003
|
}
|
|
1883
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
|
+
}
|
|
1884
2016
|
throw new Error(`Failed to ${action}: ${response.status} ${text}`);
|
|
1885
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
|
+
}
|
|
2030
|
+
// Advisory half of the control plane's CLI floor (#996): when a response says
|
|
2031
|
+
// this version is below the platform's recommended minimum, say so once per
|
|
2032
|
+
// invocation, on stderr so command output (including --json) stays parseable.
|
|
2033
|
+
const CLI_OUTDATED_HEADER = "x-9pm-cli-outdated";
|
|
2034
|
+
let cliOutdatedAdvisoryPrinted = false;
|
|
2035
|
+
export function noteCliOutdatedAdvisory(response, log = (line) => console.error(line)) {
|
|
2036
|
+
if (cliOutdatedAdvisoryPrinted) {
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
const min = response.headers.get(CLI_OUTDATED_HEADER);
|
|
2040
|
+
if (!min) {
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
cliOutdatedAdvisoryPrinted = true;
|
|
2044
|
+
log(`Note: this CLI (${CLI_VERSION}) is older than the platform's recommended minimum (${min}). Update: npm i -g ninepm@latest`);
|
|
2045
|
+
}
|
|
2046
|
+
// Test seam: the once-per-invocation latch above is module state, which one
|
|
2047
|
+
// test process shares across cases.
|
|
2048
|
+
export function resetCliOutdatedAdvisoryForTests() {
|
|
2049
|
+
cliOutdatedAdvisoryPrinted = false;
|
|
2050
|
+
}
|
|
1886
2051
|
// Pull the control plane's own message out of its {error:{code,message}} envelope,
|
|
1887
2052
|
// for the few refusals whose message is written for the tester and is the whole
|
|
1888
2053
|
// point of the response (e.g. device signup being unavailable, #507). Dumping the
|
|
@@ -1890,15 +2055,9 @@ async function expectOk(response, action) {
|
|
|
1890
2055
|
// expectOk's text when the body isn't that shape, so this can never lose detail.
|
|
1891
2056
|
async function apiErrorMessage(response, action) {
|
|
1892
2057
|
const text = await response.text();
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
if (typeof message === "string" && message.trim()) {
|
|
1897
|
-
return message.trim();
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
catch {
|
|
1901
|
-
// 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();
|
|
1902
2061
|
}
|
|
1903
2062
|
return `Failed to ${action}: ${response.status} ${text}`;
|
|
1904
2063
|
}
|
|
@@ -3739,9 +3898,16 @@ async function main() {
|
|
|
3739
3898
|
// exists) so `9pm support --site <slug>` resolves and /v1/me/support can
|
|
3740
3899
|
// attach the failed deploy's id/status/error. Bare form when no slug is
|
|
3741
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
|
+
}
|
|
3742
3909
|
const slug = error instanceof DeployError ? error.slug : command.options.slug;
|
|
3743
3910
|
const siteFlag = slug ? ` --site ${quoteSiteArg(slug)}` : "";
|
|
3744
|
-
const base = error instanceof Error ? error.message : String(error);
|
|
3745
3911
|
throw new Error(`${base}\n\nStuck? Report it with context: 9pm support${siteFlag} -m "<what happened>"`);
|
|
3746
3912
|
}
|
|
3747
3913
|
const databaseLine = result.database ? `database ${result.database.bindingName}:${result.database.name}` : "no database";
|