gencow 0.1.193 → 0.1.195

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/bin/gencow.mjs CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  updateEnvLocalUrl,
39
39
  } from "../lib/cli-project-runtime.mjs";
40
40
  import { createCodegenCommand } from "../lib/codegen-command.mjs";
41
+ import { runCliCommand } from "../lib/cli-command-runner.mjs";
41
42
  import { createDoctorCommand } from "../lib/doctor-command.mjs";
42
43
  import { maybeCheckCliVersion } from "../lib/cli-version-check.mjs";
43
44
  import { updateComponentReadme } from "../lib/component-readme.mjs";
@@ -579,13 +580,10 @@ maybeCheckCliVersion({
579
580
  warnImpl: warn,
580
581
  });
581
582
 
582
- if (commands[cmd]) {
583
- Promise.resolve(commands[cmd](...args)).catch((e) => {
584
- error(e.message || String(e));
585
- process.exit(1);
586
- });
587
- } else {
588
- error(`Unknown command: ${cmd}`);
589
- commands.help();
590
- process.exit(1);
591
- }
583
+ process.exitCode = await runCliCommand({
584
+ args,
585
+ command: cmd,
586
+ commands,
587
+ errorImpl: error,
588
+ helpImpl: commands.help,
589
+ });
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
3
  import { resolve } from "path";
3
4
  import {
@@ -24,6 +25,14 @@ import { updateEnvLocalUrl } from "./cli-project-runtime.mjs";
24
25
  // Covers the 60s runtime-observer restart grace plus multiple 5s reconciliation scans.
25
26
  const DEFAULT_DELETE_POLL_ATTEMPTS = 75;
26
27
  const DELETE_POLL_INTERVAL_MS = 1_000;
28
+ const DEFAULT_APP_CREATE_TIMEOUT_MS = 60_000;
29
+
30
+ function resolveAppCreateTimeoutMs(value) {
31
+ const parsed = Number(value);
32
+ return Number.isSafeInteger(parsed) && parsed >= 100 && parsed <= 600_000
33
+ ? parsed
34
+ : DEFAULT_APP_CREATE_TIMEOUT_MS;
35
+ }
27
36
 
28
37
  function appResponseStatus(response) {
29
38
  return Number.isSafeInteger(response?.status) ? `HTTP ${response.status}` : "unknown HTTP status";
@@ -105,23 +114,53 @@ async function confirmDelete(name) {
105
114
  }
106
115
 
107
116
  export function createAppCommand({
117
+ appCreateTimeoutMs = resolveAppCreateTimeoutMs(process.env.GENCOW_APP_CREATE_TIMEOUT_MS),
118
+ clearTimeoutImpl = clearTimeout,
119
+ confirmDeleteImpl = confirmDelete,
120
+ createAbortControllerImpl = () => new AbortController(),
121
+ createDeleteRequestIdImpl = () => `app_delete_request_${randomUUID()}`,
108
122
  cwdImpl = () => process.cwd(),
109
123
  loadConfig,
110
124
  errorImpl = error,
111
125
  infoImpl = info,
112
126
  loadCredsImpl = loadCreds,
113
127
  logImpl = log,
114
- processRef = process,
115
128
  requireCredsImpl = requireCreds,
116
129
  rpcMutationImpl = rpcMutation,
117
130
  rpcQueryImpl = rpcQuery,
118
131
  saveCredsImpl = saveCreds,
132
+ setTimeoutImpl = setTimeout,
119
133
  sleepImpl = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)),
120
134
  deletePollAttempts = DEFAULT_DELETE_POLL_ATTEMPTS,
121
135
  successImpl = success,
122
136
  updateEnvLocalUrlImpl = updateEnvLocalUrl,
123
137
  warnImpl = warn,
124
138
  }) {
139
+ const boundedAppCreateTimeoutMs = resolveAppCreateTimeoutMs(appCreateTimeoutMs);
140
+
141
+ async function requestAppCreate(creds, name) {
142
+ const controller = createAbortControllerImpl();
143
+ const timeoutError = new Error(
144
+ `App creation request timed out after ${boundedAppCreateTimeoutMs}ms. Please try again.`,
145
+ );
146
+ let timeoutHandle;
147
+ const timeoutPromise = new Promise((_resolve, reject) => {
148
+ timeoutHandle = setTimeoutImpl(() => {
149
+ controller.abort(timeoutError);
150
+ reject(timeoutError);
151
+ }, boundedAppCreateTimeoutMs);
152
+ });
153
+ try {
154
+ const request = (async () => {
155
+ const response = await rpcMutationImpl(creds, "apps.create", { name }, { signal: controller.signal });
156
+ return { response, data: await readJsonObjectResponse(response) };
157
+ })();
158
+ return await Promise.race([request, timeoutPromise]);
159
+ } finally {
160
+ clearTimeoutImpl(timeoutHandle);
161
+ }
162
+ }
163
+
125
164
  return async function app(subcmd, ...rest) {
126
165
  if (subcmd === "--help" || subcmd === "-h" || rest.includes("--help") || rest.includes("-h")) {
127
166
  logImpl(`\n${BOLD}${CYAN}gencow app${RESET} — App management\n`);
@@ -131,10 +170,10 @@ export function createAppCommand({
131
170
  logImpl(` ${CYAN}create${RESET} Create a new app`);
132
171
  logImpl(` ${CYAN}delete${RESET} Delete an app (confirmation required)`);
133
172
  logImpl(` ${CYAN}status${RESET} Show app status\n`);
134
- return;
173
+ return 0;
135
174
  }
136
175
 
137
- const creds = requireCredsImpl();
176
+ const creds = requireCredsImpl({ throwOnMissing: true });
138
177
 
139
178
  if (!subcmd || subcmd === "list") {
140
179
  logImpl(`\n${BOLD}${CYAN}Your Apps${RESET}\n`);
@@ -142,18 +181,16 @@ export function createAppCommand({
142
181
  const body = await res.json().catch(() => null);
143
182
  if (!res.ok) {
144
183
  errorImpl(appResponseError(body, invalidAppResponse("app list", res, "error")));
145
- processRef.exit(1);
146
- return;
184
+ return 1;
147
185
  }
148
186
  const apps = parseAppListResponse(body);
149
187
  if (!apps) {
150
188
  errorImpl(invalidAppResponse("app list", res, "success"));
151
- processRef.exit(1);
152
- return;
189
+ return 1;
153
190
  }
154
191
  if (!apps.length) {
155
192
  infoImpl("No apps yet. Run: gencow app create <name>");
156
- return;
193
+ return 0;
157
194
  }
158
195
 
159
196
  logImpl(` ${"NAME".padEnd(22)} ${"STATUS".padEnd(12)} ${"URL".padEnd(38)} DEPLOYED`);
@@ -167,29 +204,26 @@ export function createAppCommand({
167
204
  );
168
205
  }
169
206
  logImpl("");
170
- return;
207
+ return 0;
171
208
  }
172
209
 
173
210
  if (subcmd === "create") {
174
211
  const name = rest[0];
175
212
  if (!name) {
176
213
  errorImpl("Usage: gencow app create <name>");
177
- processRef.exit(1);
214
+ return 1;
178
215
  }
179
216
 
180
217
  logImpl(`\n${BOLD}${CYAN}Gencow App Create${RESET}\n`);
181
218
  infoImpl(`Creating app "${name}"...`);
182
- const res = await rpcMutationImpl(creds, "apps.create", { name });
183
- const data = await readJsonObjectResponse(res);
219
+ const { response: res, data } = await requestAppCreate(creds, name);
184
220
  if (!res.ok) {
185
221
  errorImpl(appResponseError(data, invalidAppResponse("app create", res, "error")));
186
- processRef.exit(1);
187
- return;
222
+ return 1;
188
223
  }
189
224
  if (!data) {
190
225
  errorImpl(invalidAppResponse("app create", res, "success"));
191
- processRef.exit(1);
192
- return;
226
+ return 1;
193
227
  }
194
228
 
195
229
  let createdApp;
@@ -197,8 +231,7 @@ export function createAppCommand({
197
231
  createdApp = resolveCreatedAppResponse(data, { platformUrl: creds.platformUrl });
198
232
  } catch (caught) {
199
233
  errorImpl(caught.message);
200
- processRef.exit(1);
201
- return;
234
+ return 1;
202
235
  }
203
236
  const { appId, appUrl } = createdApp;
204
237
 
@@ -259,7 +292,7 @@ ${dashboardLine}
259
292
 
260
293
  ${DIM}pnpm gencow dev — watch & auto-deploy to cloud + live logs${RESET}
261
294
  `);
262
- return;
295
+ return 0;
263
296
  }
264
297
 
265
298
  if (subcmd === "delete") {
@@ -270,20 +303,20 @@ ${dashboardLine}
270
303
  const name = rest.find((value) => !value.startsWith("-")) || loadCredsImpl()?.currentApp;
271
304
  if (!name || unsupportedOption) {
272
305
  errorImpl("Usage: gencow app delete <name> [--force]");
273
- processRef.exit(1);
274
- return;
306
+ return 1;
275
307
  }
276
308
 
277
309
  if (!force) {
278
- const confirmed = await confirmDelete(name);
310
+ const confirmed = await confirmDeleteImpl(name);
279
311
  if (!confirmed) {
280
312
  warnImpl("Cancelled — name did not match.");
281
- processRef.exit(0);
313
+ return 0;
282
314
  }
283
315
  }
284
316
 
285
317
  infoImpl(`Deleting app "${name}"...`);
286
- const delRes = await rpcMutationImpl(creds, "apps.delete", { name });
318
+ const requestId = createDeleteRequestIdImpl();
319
+ const delRes = await rpcMutationImpl(creds, "apps.delete", { name, requestId });
287
320
  let delData = await readJsonObjectResponse(delRes);
288
321
  if (!isExactAppDeleteResponse(delData, name)) {
289
322
  const operationResult = await pollAppDeleteOperation({
@@ -304,8 +337,7 @@ ${dashboardLine}
304
337
  invalidAppResponse("app delete", delRes, delRes.ok ? "success" : "error"),
305
338
  ),
306
339
  );
307
- processRef.exit(1);
308
- return;
340
+ return 1;
309
341
  }
310
342
 
311
343
  const currentCreds = loadCredsImpl();
@@ -314,15 +346,14 @@ ${dashboardLine}
314
346
  }
315
347
 
316
348
  successImpl(`App "${name}" deleted`);
317
- return;
349
+ return 0;
318
350
  }
319
351
 
320
352
  if (subcmd === "status") {
321
353
  const name = rest[0] || loadCredsImpl()?.currentApp;
322
354
  if (!name) {
323
355
  errorImpl("Usage: gencow app status <name>");
324
- processRef.exit(1);
325
- return;
356
+ return 1;
326
357
  }
327
358
  const res = await rpcQueryImpl(creds, "apps.get", { name });
328
359
  const decoded = await readJsonValueResponse(res);
@@ -333,24 +364,20 @@ ${dashboardLine}
333
364
  ? appNotFoundDiagnostic()
334
365
  : appResponseError(body, invalidAppResponse("app status", res, "error")),
335
366
  );
336
- processRef.exit(1);
337
- return;
367
+ return 1;
338
368
  }
339
369
  if (!decoded.parsed) {
340
370
  errorImpl(invalidAppResponse("app status", res, "success"));
341
- processRef.exit(1);
342
- return;
371
+ return 1;
343
372
  }
344
373
  const lookup = parseAppLookupResponse(body, name);
345
374
  if (lookup.kind === "not_found") {
346
375
  errorImpl(appNotFoundDiagnostic());
347
- processRef.exit(1);
348
- return;
376
+ return 1;
349
377
  }
350
378
  if (lookup.kind === "invalid_response") {
351
379
  errorImpl(invalidAppResponse("app status", res, "success"));
352
- processRef.exit(1);
353
- return;
380
+ return 1;
354
381
  }
355
382
  const data = lookup.app;
356
383
  logImpl(`\n ${BOLD}${data.name}${RESET}`);
@@ -377,10 +404,11 @@ ${dashboardLine}
377
404
  );
378
405
  }
379
406
  logImpl("");
380
- return;
407
+ return 0;
381
408
  }
382
409
 
383
410
  errorImpl(`Unknown app subcommand: ${subcmd}`);
384
411
  logImpl(` Usage: gencow app [list|create|delete|status]`);
412
+ return 1;
385
413
  };
386
414
  }
@@ -1,6 +1,19 @@
1
1
  export const APP_DELETE_OPERATION_PATTERN =
2
2
  /^app_delete_(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$/u;
3
3
 
4
+ const RETRY_CLASSES = new Set(["operator-action", "platform-action", "retryable-infra", "terminal-client"]);
5
+ const USER_ACTIONS = new Set([
6
+ "CONTACT_SUPPORT",
7
+ "EDIT_AND_CHECK",
8
+ "INSPECT_STATE",
9
+ "OPERATOR_ACTION",
10
+ "RETRY",
11
+ "UPGRADE_CLI",
12
+ "VIEW_DELETE_STATUS",
13
+ "VIEW_STATUS",
14
+ "WAIT",
15
+ ]);
16
+
4
17
  export function appResponseError(body, fallback) {
5
18
  if (!body || typeof body !== "object" || Array.isArray(body)) return fallback;
6
19
  const message = typeof body.error === "string" && body.error.trim() ? body.error.trim() : fallback;
@@ -20,5 +33,16 @@ export function appResponseError(body, fallback) {
20
33
  if (/^[a-z][a-z0-9_]{2,63}$/u.test(body.state ?? "")) {
21
34
  lines.push(`State: ${body.state}`);
22
35
  }
36
+ if (RETRY_CLASSES.has(body.retryClass) && USER_ACTIONS.has(body.userAction)) {
37
+ lines.push(`Retry class: ${body.retryClass}`);
38
+ lines.push(`Next action: ${body.userAction}`);
39
+ if (
40
+ Number.isSafeInteger(body.retryAfterMs) &&
41
+ body.retryAfterMs >= 0 &&
42
+ body.retryAfterMs <= 600_000
43
+ ) {
44
+ lines.push(`Retry after: ${body.retryAfterMs}ms`);
45
+ }
46
+ }
23
47
  return lines.join("\n");
24
48
  }
@@ -0,0 +1,82 @@
1
+ const SAFE_CORRELATION_ID = /^[A-Za-z0-9_-]{8,128}$/u;
2
+ const SAFE_PLATFORM_ERROR_DETAILS = new Map([
3
+ ["ACCOUNT_DELETED", "This account has been deleted."],
4
+ ["ACCOUNT_DELETION_PENDING", "This account is pending deletion."],
5
+ ["ACCOUNT_SUSPENDED", "This account is suspended."],
6
+ ["APP_RELEASE_CAPABILITY_UNAVAILABLE", "Release capability is temporarily unavailable."],
7
+ ["APP_RELEASE_POLICY_READ_UNAVAILABLE", "Release policy is temporarily unavailable."],
8
+ ]);
9
+
10
+ function safePlatformOrigin(value) {
11
+ try {
12
+ const url = new URL(value);
13
+ return url.protocol === "http:" || url.protocol === "https:" ? url.origin : null;
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function commandErrorMessage(caught) {
20
+ if (caught instanceof Error && (caught.message === "APP_RELEASE_AUTH_REQUIRED" || caught.status === 401)) {
21
+ const lines = ["Login expired or invalid for this Platform."];
22
+ const platformOrigin = safePlatformOrigin(caught.platformUrl);
23
+ if (platformOrigin) lines.push(`Platform: ${platformOrigin}`);
24
+ const credentialEnvName =
25
+ caught.credentialSource === "environment" &&
26
+ (caught.credentialEnvName === "GENCOW_TOKEN" || caught.credentialEnvName === "GENCOW_DEPLOY_TOKEN")
27
+ ? caught.credentialEnvName
28
+ : null;
29
+ if (credentialEnvName) {
30
+ lines.push(`Credential source: ${credentialEnvName}`);
31
+ lines.push(
32
+ `Action: replace or unset ${credentialEnvName}; environment credentials override interactive login.`,
33
+ );
34
+ } else {
35
+ lines.push("Run: npx gencow@latest login");
36
+ }
37
+ if (typeof caught.correlationId === "string" && SAFE_CORRELATION_ID.test(caught.correlationId)) {
38
+ lines.push(`Correlation ID: ${caught.correlationId}`);
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+ if (caught instanceof Error && caught.message) {
43
+ const platformOrigin = safePlatformOrigin(caught.platformUrl);
44
+ const correlationId =
45
+ typeof caught.correlationId === "string" && SAFE_CORRELATION_ID.test(caught.correlationId)
46
+ ? caught.correlationId
47
+ : null;
48
+ if (platformOrigin || correlationId || typeof caught.retryable === "boolean") {
49
+ const safeDetail = SAFE_PLATFORM_ERROR_DETAILS.get(caught.message);
50
+ const lines = [safeDetail ? `${caught.message}: ${safeDetail}` : caught.message];
51
+ if (platformOrigin) lines.push(`Platform: ${platformOrigin}`);
52
+ if (typeof caught.retryable === "boolean") lines.push(`Retryable: ${caught.retryable ? "yes" : "no"}`);
53
+ if (correlationId) lines.push(`Correlation ID: ${correlationId}`);
54
+ return lines.join("\n");
55
+ }
56
+ }
57
+ if (caught instanceof Error && caught.message) return caught.message;
58
+ return String(caught);
59
+ }
60
+
61
+ function normalizeCommandExitCode(exitCode) {
62
+ if (exitCode === undefined || exitCode === 0) return 0;
63
+ if (exitCode === 1) return 1;
64
+ throw new Error("CLI command returned an invalid exit code");
65
+ }
66
+
67
+ export async function runCliCommand({ args, command, commands, errorImpl, helpImpl }) {
68
+ const handler = Object.hasOwn(commands, command) ? commands[command] : undefined;
69
+ if (typeof handler !== "function") {
70
+ errorImpl(`Unknown command: ${command}`);
71
+ helpImpl();
72
+ return 1;
73
+ }
74
+
75
+ try {
76
+ const exitCode = await Reflect.apply(handler, commands, args);
77
+ return normalizeCommandExitCode(exitCode);
78
+ } catch (caught) {
79
+ errorImpl(commandErrorMessage(caught));
80
+ return 1;
81
+ }
82
+ }
@@ -468,10 +468,7 @@ export function createDeployCommand({
468
468
  const declaredStaticClients = Array.isArray(workspaceConfig?.clients)
469
469
  ? workspaceConfig.clients.filter((client) => client?.static?.dir)
470
470
  : [];
471
- const clientSelection = selectClient(
472
- declaredStaticClients,
473
- cliSelection?.client ?? null,
474
- );
471
+ const clientSelection = selectClient(declaredStaticClients, cliSelection?.client ?? null);
475
472
  if (clientSelection.error) {
476
473
  errorImpl(clientSelection.error);
477
474
  exitImpl(1);
@@ -541,7 +538,9 @@ export function createDeployCommand({
541
538
  resolvePathImpl,
542
539
  }) || displayName;
543
540
  const backendDeployRuntime =
544
- backendProjectRoot === cwd ? deployPackageRuntime : createDeployPackageRuntimeForCwd(() => backendProjectRoot);
541
+ backendProjectRoot === cwd
542
+ ? deployPackageRuntime
543
+ : createDeployPackageRuntimeForCwd(() => backendProjectRoot);
545
544
 
546
545
  if (parsed.existingBundleMode && !appId) {
547
546
  errorImpl("--existing-bundle requires an existing app target.");
@@ -607,7 +606,7 @@ export function createDeployCommand({
607
606
  }
608
607
 
609
608
  if (parsed.staticDeploy) {
610
- return runStaticDeployFlow({
609
+ await runStaticDeployFlow({
611
610
  appId,
612
611
  backendRoot: backendProjectRoot,
613
612
  creds,
@@ -659,6 +658,7 @@ export function createDeployCommand({
659
658
  })
660
659
  : undefined,
661
660
  });
661
+ return 0;
662
662
  }
663
663
 
664
664
  logImpl(`\n${BOLD}${CYAN}Gencow Deploy${RESET}\n`);
@@ -27,24 +27,40 @@ export function clearCreds() {
27
27
  }
28
28
 
29
29
  export function resolveCredsFromSources({ env = process.env, loadCredsImpl = loadCreds } = {}) {
30
- const envToken = env.GENCOW_TOKEN || env.GENCOW_DEPLOY_TOKEN;
30
+ const credentialEnvName = env.GENCOW_TOKEN
31
+ ? "GENCOW_TOKEN"
32
+ : env.GENCOW_DEPLOY_TOKEN
33
+ ? "GENCOW_DEPLOY_TOKEN"
34
+ : null;
35
+ const envToken = credentialEnvName ? env[credentialEnvName] : null;
31
36
  if (envToken) {
32
37
  const platformUrl = env.GENCOW_PLATFORM_URL || "https://gencow.app";
33
- return { apiKey: envToken, platformUrl: validatePlatformUrl(platformUrl) };
38
+ return {
39
+ apiKey: envToken,
40
+ platformUrl: validatePlatformUrl(platformUrl),
41
+ credentialSource: "environment",
42
+ credentialEnvName,
43
+ };
34
44
  }
35
45
 
36
46
  const creds = loadCredsImpl();
37
47
  if (!creds?.apiKey) return null;
38
48
  const platformUrl = env.GENCOW_PLATFORM_URL || creds.platformUrl;
39
49
  if (platformUrl) {
40
- return { ...creds, platformUrl: validatePlatformUrl(platformUrl) };
50
+ return { ...creds, platformUrl: validatePlatformUrl(platformUrl), credentialSource: "stored" };
41
51
  }
42
- return creds;
52
+ return { ...creds, credentialSource: "stored" };
53
+ }
54
+
55
+ export function formatPlatformCredentialsRequiredMessage({ isCi = Boolean(process.env.CI) } = {}) {
56
+ return isCi
57
+ ? "CI environment detected but no credentials found. Set GENCOW_TOKEN in your CI/CD settings."
58
+ : "Not logged in. Run: gencow login";
43
59
  }
44
60
 
45
61
  export class PlatformCredentialsRequiredError extends Error {
46
- constructor() {
47
- super("Platform credentials are required");
62
+ constructor(message = "Platform credentials are required") {
63
+ super(message);
48
64
  this.name = "PlatformCredentialsRequiredError";
49
65
  }
50
66
  }
@@ -52,7 +68,9 @@ export class PlatformCredentialsRequiredError extends Error {
52
68
  export function requireCreds({ throwOnMissing = false } = {}) {
53
69
  const creds = resolveCredsFromSources();
54
70
  if (!creds?.apiKey) {
55
- if (throwOnMissing) throw new PlatformCredentialsRequiredError();
71
+ if (throwOnMissing) {
72
+ throw new PlatformCredentialsRequiredError(formatPlatformCredentialsRequiredMessage());
73
+ }
56
74
  if (process.env.CI) {
57
75
  error("CI environment detected but no credentials found.");
58
76
  info(`Set ${CYAN}GENCOW_TOKEN${RESET} secret in your CI/CD settings.`);
@@ -86,10 +104,11 @@ export async function rpcQuery(creds, queryName, args = {}) {
86
104
  });
87
105
  }
88
106
 
89
- export async function rpcMutation(creds, mutationName, args = {}) {
107
+ export async function rpcMutation(creds, mutationName, args = {}, requestOptions = {}) {
90
108
  return platformFetch(creds, "/api/mutation", {
109
+ ...requestOptions,
91
110
  method: "POST",
92
- headers: { "Content-Type": "application/json" },
111
+ headers: { ...requestOptions.headers, "Content-Type": "application/json" },
93
112
  body: JSON.stringify({ name: mutationName, args }),
94
113
  });
95
114
  }
@@ -8,22 +8,87 @@ function releaseRequestId(explicit) {
8
8
  return explicit || `release_${randomUUID()}`;
9
9
  }
10
10
 
11
- async function responseError(response, fallback) {
12
- const body = await response.json().catch(() => ({}));
13
- const error = new Error(body.code || fallback);
14
- error.detail = body.error || response.statusText;
11
+ const SAFE_CORRELATION_ID = /^[A-Za-z0-9_-]{8,128}$/u;
12
+ const PUBLIC_RELEASE_ERROR_CODES = new Set([
13
+ "ACCOUNT_DELETED",
14
+ "ACCOUNT_DELETION_PENDING",
15
+ "ACCOUNT_SUSPENDED",
16
+ "APP_RELEASE_APP_NOT_FOUND",
17
+ "APP_RELEASE_ATTEMPT_NOT_FOUND",
18
+ "APP_RELEASE_ATTEMPT_TERMINAL",
19
+ "APP_RELEASE_AUTH_REQUIRED",
20
+ "APP_RELEASE_CAPABILITY_UNAVAILABLE",
21
+ "APP_RELEASE_COMPONENT_CONFLICT",
22
+ "APP_RELEASE_COMPONENT_INCOMPLETE",
23
+ "APP_RELEASE_COMPONENT_UNEXPECTED",
24
+ "APP_RELEASE_ENVIRONMENT_INVALID",
25
+ "APP_RELEASE_ENVIRONMENT_MISMATCH",
26
+ "APP_RELEASE_ENV_KEYS_MISSING",
27
+ "APP_RELEASE_IDEMPOTENCY_CONFLICT",
28
+ "APP_RELEASE_METADATA_INVALID",
29
+ "APP_RELEASE_POLICY_READ_UNAVAILABLE",
30
+ "APP_RELEASE_SOURCE_CONFLICT",
31
+ "APP_RELEASE_SOURCE_INVALID",
32
+ "APP_RELEASE_STATE_CONFLICT",
33
+ "APP_RELEASE_STORAGE_QUOTA_EXCEEDED",
34
+ "APP_RELEASE_VERSIONS_DISABLED",
35
+ "APP_SERVING_ROUTE_COMMIT_BLOCKED",
36
+ "APP_SERVING_ROUTE_COMMIT_PENDING",
37
+ "RELEASE_SOURCE_COMPONENT_MISMATCH",
38
+ ]);
39
+
40
+ function safePlatformOrigin(value) {
41
+ try {
42
+ const url = new URL(value);
43
+ return url.protocol === "http:" || url.protocol === "https:" ? url.origin : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function throwResponseError(response, fallback, body, creds = {}) {
50
+ const authRequired = response.status === 401;
51
+ const responseCode = PUBLIC_RELEASE_ERROR_CODES.has(body?.code) ? body.code : fallback;
52
+ const error = new Error(authRequired ? "APP_RELEASE_AUTH_REQUIRED" : responseCode);
15
53
  error.status = response.status;
54
+ const platformOrigin = safePlatformOrigin(creds.platformUrl);
55
+ if (platformOrigin) error.platformUrl = platformOrigin;
56
+ if (creds.credentialSource === "environment" || creds.credentialSource === "stored") {
57
+ error.credentialSource = creds.credentialSource;
58
+ }
59
+ if (creds.credentialEnvName === "GENCOW_TOKEN" || creds.credentialEnvName === "GENCOW_DEPLOY_TOKEN") {
60
+ error.credentialEnvName = creds.credentialEnvName;
61
+ }
62
+ if (typeof body.correlationId === "string" && SAFE_CORRELATION_ID.test(body.correlationId)) {
63
+ error.correlationId = body.correlationId;
64
+ }
65
+ error.retryable = authRequired
66
+ ? false
67
+ : typeof body.retryable === "boolean"
68
+ ? body.retryable
69
+ : response.status >= 500;
16
70
  throw error;
17
71
  }
18
72
 
73
+ async function responseError(response, fallback, creds, parsedBody) {
74
+ const body = parsedBody ?? ((await response.json().catch(() => ({}))) || {});
75
+ throwResponseError(response, fallback, body, creds);
76
+ }
77
+
19
78
  async function releaseCapability({ creds, appId, environment, platformFetchImpl }) {
20
79
  const response = await platformFetchImpl(
21
80
  creds,
22
81
  `/platform/apps/${appId}/release-capability?environment=${environment}`,
23
82
  { method: "GET" },
24
83
  );
25
- if (response.status === 404 || response.status === 405) return null;
26
- if (!response.ok) await responseError(response, "APP_RELEASE_CAPABILITY_FAILED");
84
+ if (response.status === 404 || response.status === 405) {
85
+ const body = (await response.json().catch(() => ({}))) || {};
86
+ if (typeof body?.code !== "string" || body.code.length === 0) return null;
87
+ await responseError(response, "APP_RELEASE_CAPABILITY_FAILED", creds, body);
88
+ }
89
+ if (!response.ok) {
90
+ await responseError(response, "APP_RELEASE_CAPABILITY_FAILED", creds);
91
+ }
27
92
  const body = await response.json().catch(() => ({}));
28
93
  if (body?.protocol !== "app-release-v1" || typeof body?.enabled !== "boolean") {
29
94
  throw new Error("APP_RELEASE_CAPABILITY_RESPONSE_INVALID");
@@ -66,7 +131,9 @@ export async function prepareReleaseAttempt({
66
131
  },
67
132
  body: sourcePackage.bundleBuffer,
68
133
  });
69
- if (!response.ok) await responseError(response, "APP_RELEASE_SOURCE_UPLOAD_FAILED");
134
+ if (!response.ok) {
135
+ await responseError(response, "APP_RELEASE_SOURCE_UPLOAD_FAILED", creds);
136
+ }
70
137
  const data = await response.json();
71
138
  if (!data.attemptId || data.state !== "source_stored") {
72
139
  throw new Error("APP_RELEASE_SOURCE_RESPONSE_INVALID");
@@ -97,14 +164,19 @@ export async function finalizeReleaseAttempt({
97
164
  body: JSON.stringify({ environment }),
98
165
  },
99
166
  );
100
- if (!response.ok) await responseError(response, "APP_RELEASE_FINALIZE_FAILED");
167
+ if (!response.ok) {
168
+ await responseError(response, "APP_RELEASE_FINALIZE_FAILED", creds);
169
+ }
101
170
  const data = await response.json();
102
171
  if (!data.release?.id || !Number.isSafeInteger(data.release.version)) {
103
172
  throw new Error("APP_RELEASE_FINALIZE_RESPONSE_INVALID");
104
173
  }
105
174
  return data.release;
106
175
  } catch (error) {
107
- const retryable = typeof error?.status !== "number" || error.status >= 500;
176
+ const retryable =
177
+ typeof error?.retryable === "boolean"
178
+ ? error.retryable
179
+ : typeof error?.status !== "number" || error.status >= 500;
108
180
  if (!retryable || attempt >= retryDelaysMs.length) throw error;
109
181
  await delayImpl(retryDelaysMs[attempt]);
110
182
  }
@@ -129,6 +201,8 @@ export async function failReleaseAttempt({
129
201
  body: JSON.stringify({ environment, failureStage, failureCode }),
130
202
  },
131
203
  );
132
- if (!response.ok) await responseError(response, "APP_RELEASE_FAIL_RECORD_FAILED");
204
+ if (!response.ok) {
205
+ await responseError(response, "APP_RELEASE_FAIL_RECORD_FAILED", creds);
206
+ }
133
207
  return await response.json();
134
208
  }