@apso/cli 0.19.0 → 0.21.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.
@@ -6,6 +6,18 @@ export default class Deploy extends BaseCommand {
6
6
  "no-wait": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
7
7
  yes: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
8
8
  "skip-migrate": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
9
+ "skip-push": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
+ repo: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
11
+ branch: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
9
12
  };
10
13
  run(): Promise<void>;
14
+ /**
15
+ * Push the local scaffold to the service's GitHub repo with the user's own
16
+ * git, and make sure the repo is connected to the service so the provisioner
17
+ * can clone it at deploy time. No code passes through Apso — we read the local
18
+ * git and push straight to GitHub.
19
+ */
20
+ private syncCodeToGithub;
21
+ /** Let the user pick one of their GitHub repos to deploy from. */
22
+ private promptRepoSelection;
11
23
  }
@@ -5,6 +5,8 @@ const core_1 = require("@oclif/core");
5
5
  const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
6
6
  const config_1 = require("../lib/config");
7
7
  const services_1 = require("../lib/api/services");
8
+ const upgrade_1 = require("../lib/upgrade");
9
+ const git = tslib_1.__importStar(require("../lib/git"));
8
10
  const apsorc_parser_1 = require("../lib/apsorc-parser");
9
11
  const sandbox_1 = require("../lib/migrate/sandbox");
10
12
  const spinner_1 = require("../lib/utils/spinner");
@@ -62,9 +64,14 @@ class Deploy extends base_command_1.default {
62
64
  this.log("No schema changes detected.");
63
65
  }
64
66
  }
67
+ // Sync local code to the connected GitHub repo (the deploy builds from it).
68
+ // Skip for redeploys of already-synced code.
69
+ if (!flags["skip-push"]) {
70
+ await this.syncCodeToGithub(service, flags.repo, flags.branch);
71
+ }
65
72
  // Determine source
66
- if (service.githubRepo) {
67
- this.log(`Deploying "${service.name}" from ${service.githubRepo}#${service.githubBranch || "main"}...`);
73
+ if (service.githubRepo || flags.repo) {
74
+ this.log(`Deploying "${service.name}" from ${service.githubRepo || flags.repo}#${flags.branch}...`);
68
75
  }
69
76
  else {
70
77
  this.log(`Deploying "${service.name}" via Apso Platform...`);
@@ -86,7 +93,7 @@ class Deploy extends base_command_1.default {
86
93
  }
87
94
  }
88
95
  // Trigger build
89
- const build = await services_1.buildApi.trigger(link.workspaceSlug, link.serviceSlug);
96
+ const build = await (0, upgrade_1.withUpgradeRetry)(() => services_1.buildApi.trigger(link.workspaceSlug, link.serviceSlug));
90
97
  this.log(`Build started: ${build.id}`);
91
98
  if (flags["no-wait"]) {
92
99
  this.log("Deploy triggered. Run 'apso status' to check progress.");
@@ -115,6 +122,65 @@ class Deploy extends base_command_1.default {
115
122
  this.exit(1);
116
123
  }
117
124
  }
125
+ /**
126
+ * Push the local scaffold to the service's GitHub repo with the user's own
127
+ * git, and make sure the repo is connected to the service so the provisioner
128
+ * can clone it at deploy time. No code passes through Apso — we read the local
129
+ * git and push straight to GitHub.
130
+ */
131
+ async syncCodeToGithub(service, repoFlag, branch) {
132
+ const cwd = process.cwd();
133
+ if (!git.gitAvailable()) {
134
+ this.error("git is required to deploy. Install git and try again.");
135
+ }
136
+ git.initRepo(cwd);
137
+ // Pick the repo: prefer what the local project is already wired to, then the
138
+ // service's connected repo, then --repo, then ask.
139
+ const conn = await services_1.githubApi.getConnection();
140
+ if (!conn.connected || !conn.connectionId) {
141
+ this.error("GitHub is not connected. Run 'apso github connect' first, then re-run deploy.");
142
+ }
143
+ let repoFullName = git.localRepoFullName(cwd) || service.githubRepo || repoFlag;
144
+ if (!repoFullName) {
145
+ repoFullName = await this.promptRepoSelection(conn.connectionId);
146
+ }
147
+ // Connect the repo to the service (records ServiceRepository) unless the
148
+ // service is already linked to this exact repo.
149
+ if (service.githubRepo !== repoFullName) {
150
+ this.log(`Connecting repository ${repoFullName}...`);
151
+ await services_1.githubApi.connectRepo(service.id, conn.connectionId, repoFullName);
152
+ }
153
+ // Point the local repo at it and push with the user's git credentials.
154
+ git.setOrigin(cwd, git.httpsUrlFor(repoFullName));
155
+ const committed = git.commitAll(cwd, "Deploy via Apso CLI");
156
+ this.log(committed
157
+ ? `Committed local changes; pushing to ${repoFullName}#${branch}...`
158
+ : `Pushing to ${repoFullName}#${branch}...`);
159
+ try {
160
+ git.pushHead(cwd, branch);
161
+ }
162
+ catch (error) {
163
+ const msg = error instanceof Error ? error.message : String(error);
164
+ this.error(`git push to ${repoFullName} failed. Ensure you have push access and your git credentials are set up.\n${msg}`);
165
+ }
166
+ }
167
+ /** Let the user pick one of their GitHub repos to deploy from. */
168
+ async promptRepoSelection(connectionId) {
169
+ const repos = await services_1.githubApi.listRepos("", { connectionId, pageSize: 100 });
170
+ if (!repos.data.length) {
171
+ this.error("No GitHub repositories available. Create one on GitHub (or pass --repo owner/name).");
172
+ }
173
+ const inquirer = await Promise.resolve().then(() => tslib_1.__importStar(require("inquirer")));
174
+ const { repo } = await inquirer.default.prompt([
175
+ {
176
+ type: "list",
177
+ name: "repo",
178
+ message: "Select a GitHub repository to deploy from:",
179
+ choices: repos.data.map((r) => ({ name: r.fullName, value: r.fullName })),
180
+ },
181
+ ]);
182
+ return repo;
183
+ }
118
184
  }
119
185
  Deploy.description = "Deploy the linked service";
120
186
  Deploy.examples = [
@@ -137,5 +203,16 @@ Deploy.flags = {
137
203
  description: "Skip local migration validation",
138
204
  default: false,
139
205
  }),
206
+ "skip-push": core_1.Flags.boolean({
207
+ description: "Skip uploading + pushing code (redeploy current code)",
208
+ default: false,
209
+ }),
210
+ repo: core_1.Flags.string({
211
+ description: "GitHub repo (owner/name) to connect when none is linked yet",
212
+ }),
213
+ branch: core_1.Flags.string({
214
+ description: "Branch to push to",
215
+ default: "main",
216
+ }),
140
217
  };
141
218
  exports.default = Deploy;
@@ -184,17 +184,36 @@ class Generate extends base_command_1.default {
184
184
  await (0, file_system_1.createFile)(fullPath, file.content);
185
185
  }
186
186
  generatedFileCount += indexFiles.length;
187
- // Format generated files (TypeScript only).
188
- // Run prettier directly against the generated output directory so we never
189
- // reformat hand-written code elsewhere in the project tree (the project's
190
- // `npm run format` script targets a broad glob like `{src,test}/**/*.ts`).
187
+ // Format generated files (TypeScript only) with Biome — bundled with the
188
+ // CLI and invoked directly, so it's a fast native pass (~ms) with no npx
189
+ // download and no dependence on the project having a formatter installed.
190
+ // Scoped to the autogen directory so we never touch hand-written code
191
+ // elsewhere in the project tree.
191
192
  if (language === "typescript" && !skipFormat && generatedFileCount > 0) {
192
193
  const formatStart = perf_hooks_1.performance.now();
193
194
  console.log("[apso] Formatting generated files...");
194
- const formatGlob = path.join(autogenPath, "**", "*.ts");
195
- await this.runCommand("npx", ["prettier", "--write", formatGlob], true);
196
- const formatTime = perf_hooks_1.performance.now() - formatStart;
197
- console.log(`[apso] Finished formatting in ${formatTime.toFixed(2)} ms`);
195
+ try {
196
+ const biomeBin = require.resolve("@biomejs/biome/bin/biome");
197
+ // Use the CLI's bundled Biome config: 2-space style (Biome defaults to
198
+ // tabs) and, critically, unsafeParameterDecoratorsEnabled so Biome can
199
+ // parse NestJS parameter decorators (@Body(), @ParsedRequest(), ...);
200
+ // without it Biome aborts with parse errors on the generated controllers.
201
+ const biomeConfigDir = path.join(__dirname, "..", "lib", "biome");
202
+ await this.runCommand("node", [
203
+ biomeBin,
204
+ "format",
205
+ "--write",
206
+ `--config-path=${biomeConfigDir}`,
207
+ autogenPath,
208
+ ], true);
209
+ const formatTime = perf_hooks_1.performance.now() - formatStart;
210
+ console.log(`[apso] Finished formatting in ${formatTime.toFixed(2)} ms`);
211
+ }
212
+ catch (error) {
213
+ // Formatting is best-effort — never fail generation over it.
214
+ const msg = error instanceof Error ? error.message : String(error);
215
+ console.warn(`[apso] Skipped formatting (${msg})`);
216
+ }
198
217
  }
199
218
  else if (skipFormat) {
200
219
  console.log("[apso] Skipping formatting (--skip-format flag set)");
@@ -0,0 +1,19 @@
1
+ import BaseCommand from "../../lib/base-command";
2
+ /**
3
+ * `apso github connect` — walk the user through connecting their GitHub
4
+ * account so the CLI can push generated code to a real repository.
5
+ *
6
+ * The GitHub OAuth handshake is a browser+cookie flow on the app, so we open
7
+ * the app's authorize URL (where the user is signed in) and poll the platform
8
+ * until the connection appears — the same pattern as `apso login`.
9
+ */
10
+ export default class GithubConnect extends BaseCommand {
11
+ static description: string;
12
+ static examples: string[];
13
+ static flags: {
14
+ help: import("@oclif/core/lib/interfaces").BooleanFlag<void>;
15
+ "workspace-id": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
16
+ timeout: import("@oclif/core/lib/interfaces").OptionFlag<number, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
17
+ };
18
+ run(): Promise<void>;
19
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const core_1 = require("@oclif/core");
5
+ const child_process_1 = require("child_process");
6
+ const base_command_1 = tslib_1.__importDefault(require("../../lib/base-command"));
7
+ const config_1 = require("../../lib/config");
8
+ const services_1 = require("../../lib/api/services");
9
+ /**
10
+ * Open a URL in the default browser (best-effort).
11
+ */
12
+ function openBrowser(url) {
13
+ return new Promise((resolve) => {
14
+ const platform = process.platform;
15
+ const command = platform === "darwin"
16
+ ? `open "${url}"`
17
+ : platform === "win32"
18
+ ? `start "" "${url}"`
19
+ : `xdg-open "${url}"`;
20
+ (0, child_process_1.exec)(command, () => resolve());
21
+ });
22
+ }
23
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
+ /**
25
+ * `apso github connect` — walk the user through connecting their GitHub
26
+ * account so the CLI can push generated code to a real repository.
27
+ *
28
+ * The GitHub OAuth handshake is a browser+cookie flow on the app, so we open
29
+ * the app's authorize URL (where the user is signed in) and poll the platform
30
+ * until the connection appears — the same pattern as `apso login`.
31
+ */
32
+ class GithubConnect extends base_command_1.default {
33
+ async run() {
34
+ var _a;
35
+ const { flags } = await this.parse(GithubConnect);
36
+ if (!config_1.credentials.isValid()) {
37
+ this.error("Not authenticated. Run 'apso login' first.");
38
+ }
39
+ const workspaceId = flags["workspace-id"] ||
40
+ process.env.APSO_WORKSPACE_ID ||
41
+ ((_a = config_1.projectLink.read()) === null || _a === void 0 ? void 0 : _a.workspaceId);
42
+ if (!workspaceId) {
43
+ this.error("No workspace. Run 'apso link' first, or pass --workspace-id.");
44
+ }
45
+ // Already connected?
46
+ const existing = await services_1.githubApi.getConnection();
47
+ if (existing.connected) {
48
+ this.log(`GitHub is already connected${existing.login ? ` as ${existing.login}` : ""}.`);
49
+ return;
50
+ }
51
+ const config = config_1.globalConfig.read();
52
+ const authorizeUrl = `${config.webUrl}/api/github/authorize?workspaceId=${workspaceId}`;
53
+ this.log("Opening GitHub authorization in your browser...");
54
+ this.log("");
55
+ this.log("If the browser doesn't open, visit this URL:");
56
+ this.log(` ${authorizeUrl}`);
57
+ this.log("");
58
+ this.log("You must be signed in to the Apso app in your browser to authorize.");
59
+ this.log("");
60
+ await openBrowser(authorizeUrl);
61
+ this.log("Waiting for the GitHub connection to complete...");
62
+ const deadline = Date.now() + flags.timeout * 1000;
63
+ /* eslint-disable no-await-in-loop */
64
+ while (Date.now() < deadline) {
65
+ await sleep(3000);
66
+ try {
67
+ const conn = await services_1.githubApi.getConnection();
68
+ if (conn.connected) {
69
+ this.log("");
70
+ this.log(`Connected to GitHub${conn.login ? ` as ${conn.login}` : ""}.`);
71
+ this.log("Next: run 'apso deploy' to push your code and deploy.");
72
+ return;
73
+ }
74
+ }
75
+ catch {
76
+ // transient; keep polling
77
+ }
78
+ }
79
+ /* eslint-enable no-await-in-loop */
80
+ this.error("Timed out waiting for the GitHub connection. Re-run 'apso github connect' to try again.");
81
+ }
82
+ }
83
+ GithubConnect.description = "Connect a GitHub account to push generated code to a repo";
84
+ GithubConnect.examples = ["$ apso github connect"];
85
+ GithubConnect.flags = {
86
+ help: core_1.Flags.help({ char: "h" }),
87
+ "workspace-id": core_1.Flags.string({
88
+ description: "Workspace id to connect (defaults to the linked project)",
89
+ }),
90
+ timeout: core_1.Flags.integer({
91
+ description: "Seconds to wait for the connection",
92
+ default: 300,
93
+ }),
94
+ };
95
+ exports.default = GithubConnect;
@@ -9,6 +9,7 @@ const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
9
9
  const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
10
10
  const config_1 = require("../lib/config");
11
11
  const services_1 = require("../lib/api/services");
12
+ const upgrade_1 = require("../lib/upgrade");
12
13
  const template_1 = require("../lib/utils/template");
13
14
  class Init extends base_command_1.default {
14
15
  async run() {
@@ -175,9 +176,10 @@ class Init extends base_command_1.default {
175
176
  ]);
176
177
  // Create service on platform
177
178
  this.log(`Creating service "${projectName}" in workspace "${workspace.name}"...`);
178
- const service = await services_1.servicesApi.create(workspace.slug, {
179
- name: projectName,
180
- });
179
+ const serviceName = projectName;
180
+ const service = await (0, upgrade_1.withUpgradeRetry)(() => services_1.servicesApi.create(workspace.slug, {
181
+ name: serviceName,
182
+ }));
181
183
  // Clone template
182
184
  (0, template_1.cloneTemplate)(projectPath, language, this.log.bind(this));
183
185
  (0, template_1.initGitRepo)(projectPath);
@@ -8,6 +8,7 @@ const inquirer_1 = tslib_1.__importDefault(require("inquirer"));
8
8
  const base_command_1 = tslib_1.__importDefault(require("../lib/base-command"));
9
9
  const config_1 = require("../lib/config");
10
10
  const services_1 = require("../lib/api/services");
11
+ const upgrade_1 = require("../lib/upgrade");
11
12
  class Link extends base_command_1.default {
12
13
  async run() {
13
14
  const { flags } = await this.parse(Link);
@@ -96,9 +97,9 @@ class Link extends base_command_1.default {
96
97
  },
97
98
  ]);
98
99
  this.log(`Creating service "${serviceName}"...`);
99
- service = await services_1.servicesApi.create(workspace.slug, {
100
+ service = await (0, upgrade_1.withUpgradeRetry)(() => services_1.servicesApi.create(workspace.slug, {
100
101
  name: serviceName,
101
- });
102
+ }));
102
103
  }
103
104
  }
104
105
  // Write link
@@ -0,0 +1,7 @@
1
+ import { Hook } from "@oclif/core";
2
+ /**
3
+ * Runs before any command. Initializes PostHog + Sentry and records that a
4
+ * command was invoked. Best-effort: never blocks or fails the command.
5
+ */
6
+ declare const hook: Hook<"init">;
7
+ export default hook;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const telemetry_1 = require("../../lib/telemetry/telemetry");
4
+ /**
5
+ * Runs before any command. Initializes PostHog + Sentry and records that a
6
+ * command was invoked. Best-effort: never blocks or fails the command.
7
+ */
8
+ const hook = async function (opts) {
9
+ (0, telemetry_1.initTelemetry)();
10
+ if (opts.id) {
11
+ (0, telemetry_1.track)("cli_command_started", { command: opts.id });
12
+ }
13
+ };
14
+ exports.default = hook;
@@ -0,0 +1,8 @@
1
+ import { Hook } from "@oclif/core";
2
+ /**
3
+ * Runs after a command completes successfully. Records completion + duration,
4
+ * then flushes both clients before the short-lived CLI process exits (posthog
5
+ * batches, so an explicit flush is required or events are lost).
6
+ */
7
+ declare const hook: Hook<"postrun">;
8
+ export default hook;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const telemetry_1 = require("../../lib/telemetry/telemetry");
4
+ /**
5
+ * Runs after a command completes successfully. Records completion + duration,
6
+ * then flushes both clients before the short-lived CLI process exits (posthog
7
+ * batches, so an explicit flush is required or events are lost).
8
+ */
9
+ const hook = async function (opts) {
10
+ var _a;
11
+ (0, telemetry_1.track)("cli_command_completed", {
12
+ command: (_a = opts.Command) === null || _a === void 0 ? void 0 : _a.id,
13
+ duration_ms: (0, telemetry_1.elapsedMs)(),
14
+ success: true,
15
+ });
16
+ await (0, telemetry_1.shutdownTelemetry)();
17
+ };
18
+ exports.default = hook;
@@ -15,11 +15,20 @@ import { ApiRequestOptions } from "./types";
15
15
  export declare class ApiClientError extends Error {
16
16
  statusCode: number;
17
17
  details?: Record<string, unknown> | undefined;
18
+ /** Machine code from the server (e.g. SERVICE_LIMIT_REACHED), when present. */
19
+ code?: string;
20
+ /** Relative URL to send the user to resolve the error (e.g. an upgrade page). */
21
+ upgradeUrl?: string;
18
22
  constructor(message: string, statusCode: number, details?: Record<string, unknown> | undefined);
19
23
  /**
20
24
  * Check if error is due to authentication failure
21
25
  */
22
26
  isAuthError(): boolean;
27
+ /**
28
+ * Check if error is an entitlement/plan limit that the user can resolve by
29
+ * upgrading (e.g. the free-tier service or deploy limit).
30
+ */
31
+ isLimitError(): boolean;
23
32
  /**
24
33
  * Check if error is a rate limit error
25
34
  */
@@ -40,6 +40,16 @@ class ApiClientError extends Error {
40
40
  isAuthError() {
41
41
  return this.statusCode === 401;
42
42
  }
43
+ /**
44
+ * Check if error is an entitlement/plan limit that the user can resolve by
45
+ * upgrading (e.g. the free-tier service or deploy limit).
46
+ */
47
+ isLimitError() {
48
+ return (this.statusCode === 403 &&
49
+ (this.code === "SERVICE_LIMIT_REACHED" ||
50
+ this.code === "LIMIT_REACHED" ||
51
+ Boolean(this.upgradeUrl)));
52
+ }
43
53
  /**
44
54
  * Check if error is a rate limit error
45
55
  */
@@ -82,6 +92,40 @@ function sleep(ms) {
82
92
  setTimeout(resolve, ms);
83
93
  });
84
94
  }
95
+ /**
96
+ * Pull an entitlement/limit code + upgrade URL out of an error body. Handles
97
+ * both a flat body ({ code, upgradeUrl, message }) and the BFF's wrapped shape
98
+ * where the backend error is a JSON string in `.error`.
99
+ */
100
+ function extractLimitInfo(errorData) {
101
+ const candidates = [];
102
+ if (errorData && typeof errorData === "object") {
103
+ const obj = errorData;
104
+ candidates.push(obj);
105
+ if (typeof obj.error === "string") {
106
+ try {
107
+ const parsed = JSON.parse(obj.error);
108
+ if (parsed && typeof parsed === "object")
109
+ candidates.push(parsed);
110
+ }
111
+ catch {
112
+ /* not JSON — ignore */
113
+ }
114
+ }
115
+ }
116
+ for (const c of candidates) {
117
+ const code = typeof c.code === "string" ? c.code : undefined;
118
+ const upgradeUrl = typeof c.upgradeUrl === "string" ? c.upgradeUrl : undefined;
119
+ if (code || upgradeUrl) {
120
+ return {
121
+ code,
122
+ upgradeUrl,
123
+ message: typeof c.message === "string" ? c.message : undefined,
124
+ };
125
+ }
126
+ }
127
+ return {};
128
+ }
85
129
  /**
86
130
  * Calculate backoff delay with jitter
87
131
  */
@@ -148,6 +192,7 @@ async function refreshAccessToken() {
148
192
  */
149
193
  /* eslint-disable no-await-in-loop */
150
194
  async function apiRequest(path, options = {}) {
195
+ var _a;
151
196
  const { method = "GET", body, params, headers = {}, timeout = DEFAULT_TIMEOUT, skipAuth = false, } = options;
152
197
  const config = config_1.globalConfig.read();
153
198
  const url = buildUrl(config.apiUrl, path, params);
@@ -175,6 +220,16 @@ async function apiRequest(path, options = {}) {
175
220
  if (freshCreds) {
176
221
  requestHeaders.Authorization = `Bearer ${freshCreds.tokens.accessToken}`;
177
222
  }
223
+ // The BFF's workspace-scoped routes read the active workspace from an
224
+ // X-Workspace-Id header for Bearer clients (browsers use a signed cookie).
225
+ // Resolve the active workspace id from, in order: an explicit header the
226
+ // caller passed, the APSO_WORKSPACE_ID env override, or the linked project.
227
+ if (!requestHeaders["X-Workspace-Id"]) {
228
+ const activeWorkspaceId = process.env.APSO_WORKSPACE_ID || ((_a = config_1.projectLink.read()) === null || _a === void 0 ? void 0 : _a.workspaceId);
229
+ if (activeWorkspaceId) {
230
+ requestHeaders["X-Workspace-Id"] = String(activeWorkspaceId);
231
+ }
232
+ }
178
233
  }
179
234
  // Make request with retries
180
235
  let lastError = null;
@@ -235,7 +290,11 @@ async function apiRequest(path, options = {}) {
235
290
  continue;
236
291
  }
237
292
  // Client errors - don't retry
238
- throw new ApiClientError(errorData.message || "Request failed", response.status, errorData.details);
293
+ const limit = extractLimitInfo(errorData);
294
+ const clientError = new ApiClientError(limit.message || errorData.message || "Request failed", response.status, errorData.details);
295
+ clientError.code = limit.code;
296
+ clientError.upgradeUrl = limit.upgradeUrl;
297
+ throw clientError;
239
298
  }
240
299
  catch (error) {
241
300
  if (error instanceof ApiClientError) {