percher 0.4.2 → 0.5.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.
@@ -103988,8 +103988,46 @@ function date4(params) {
103988
103988
 
103989
103989
  // ../../node_modules/.bun/zod@4.3.6/node_modules/zod/v4/classic/external.js
103990
103990
  config(en_default());
103991
- // ../toml/dist/schema.js
103991
+ // ../shared/dist/app-name.js
103992
103992
  var APP_NAME_RE = /^[a-z][a-z0-9-]{2,39}$/;
103993
+ // ../shared/dist/limits.js
103994
+ var MAX_TARBALL_BYTES = 500 * 1024 * 1024;
103995
+ // ../shared/dist/logs.js
103996
+ var MAX_LOG_SEARCH_LENGTH = 256;
103997
+ // ../shared/dist/platform-env.js
103998
+ var PERCHER_INJECTED_ENV_KEYS = new Set([
103999
+ "NODE_ENV",
104000
+ "PORT",
104001
+ "DATABASE_URL",
104002
+ "POCKETBASE_URL",
104003
+ "POCKETBASE_ADMIN_EMAIL",
104004
+ "POCKETBASE_ADMIN_PASSWORD",
104005
+ "PERCHER_APP_NAME",
104006
+ "PERCHER_APP_URL",
104007
+ "HTTP_PROXY",
104008
+ "HTTPS_PROXY",
104009
+ "NO_PROXY",
104010
+ "NODE_USE_ENV_PROXY"
104011
+ ]);
104012
+ var PROXY_LOWERCASE_ALIASES = new Set([
104013
+ "http_proxy",
104014
+ "https_proxy",
104015
+ "no_proxy"
104016
+ ]);
104017
+ // ../shared/dist/runtime.js
104018
+ var DEFAULT_NODE_VERSION_MAJOR = "24";
104019
+ var DEFAULT_NODE_VERSION_LABEL = `${DEFAULT_NODE_VERSION_MAJOR} LTS`;
104020
+ // ../shared/dist/timeouts.js
104021
+ var TIMEOUTS = {
104022
+ workerBuildSubprocess: 10 * 60000,
104023
+ apiToWorkerBuildFetch: 11 * 60000,
104024
+ clientPublishPoll: 12 * 60000,
104025
+ workerHealthcheck: 5000,
104026
+ watchdogList: 15000,
104027
+ mcpWaitDeployDefault: 30000,
104028
+ mcpWaitDeployMax: 12 * 60000
104029
+ };
104030
+ // ../toml/dist/schema.js
103993
104031
  var appSchema = exports_external.object({
103994
104032
  name: exports_external.string().regex(APP_NAME_RE, "must be 3–40 chars, lowercase, start with a letter, [a-z0-9-] only"),
103995
104033
  runtime: exports_external.enum(["node", "bun", "python", "static", "docker"]),
@@ -104030,6 +104068,7 @@ var buildSchema = exports_external.object({
104030
104068
  var webSchema = exports_external.object({
104031
104069
  port: exports_external.number().int().min(1024).max(65535),
104032
104070
  health: exports_external.string().optional(),
104071
+ command: exports_external.string().min(1).max(1000).optional(),
104033
104072
  password: exports_external.boolean().optional(),
104034
104073
  spa: exports_external.boolean().optional()
104035
104074
  }).strict();
@@ -104205,6 +104244,15 @@ var cronSchema = exports_external.object({
104205
104244
  command: exports_external.string().min(1),
104206
104245
  description: exports_external.string().max(200).optional()
104207
104246
  }).strict();
104247
+ var cronsSchema = exports_external.record(exports_external.string(), cronSchema).superRefine((jobs, ctx) => {
104248
+ if (Object.hasOwn(jobs, "")) {
104249
+ ctx.addIssue({
104250
+ code: exports_external.ZodIssueCode.custom,
104251
+ path: [""],
104252
+ message: "cron job name must not be empty"
104253
+ });
104254
+ }
104255
+ });
104208
104256
  var addonsSchema = exports_external.object({
104209
104257
  pocketbase: exports_external.object({ enabled: exports_external.boolean().optional() }).strict().optional(),
104210
104258
  cron: exports_external.object({ enabled: exports_external.boolean().optional() }).strict().optional(),
@@ -104229,7 +104277,7 @@ var percherConfigSchema = exports_external.object({
104229
104277
  resources: resourcesSchema.optional(),
104230
104278
  dev: devSchema.optional(),
104231
104279
  required_env: requiredEnvSchema.optional(),
104232
- crons: exports_external.record(exports_external.string(), cronSchema).optional(),
104280
+ crons: cronsSchema.optional(),
104233
104281
  addons: addonsSchema.optional(),
104234
104282
  checks: exports_external.record(exports_external.string(), checkSchema).optional()
104235
104283
  }).strict();
@@ -104900,6 +104948,20 @@ var billingUpgradeInputSchema = exports_external.object({
104900
104948
  plan: exports_external.enum(["starter", "maker", "pro"])
104901
104949
  });
104902
104950
  var billingPortalInputSchema = exports_external.object({});
104951
+ // ../core/dist/commands/cache.js
104952
+ var cacheResetInputSchema = exports_external.object({
104953
+ app: exports_external.string().optional()
104954
+ });
104955
+ async function cacheReset(ctx, input = {}) {
104956
+ const name = input.app ?? readPercherTomlAppName(ctx.cwd);
104957
+ if (!name) {
104958
+ throw new PercherCoreError("No app specified and no percher.toml found", {
104959
+ code: "env.no-app",
104960
+ hint: "Pass an app name or run from a directory with percher.toml."
104961
+ });
104962
+ }
104963
+ return ctx.client.apps.resetCache(name);
104964
+ }
104903
104965
  // ../core/dist/commands/create.js
104904
104966
  import { join as join6, resolve as resolve4 } from "node:path";
104905
104967
 
@@ -104959,9 +105021,8 @@ function scaffoldTemplate(input) {
104959
105021
  }
104960
105022
 
104961
105023
  // ../core/dist/commands/create.js
104962
- var APP_NAME_RE2 = /^[a-z][a-z0-9-]{2,39}$/;
104963
105024
  var createInputSchema = exports_external.object({
104964
- name: exports_external.string().regex(APP_NAME_RE2, "must be 3-40 chars, lowercase, start with a letter, [a-z0-9-] only"),
105025
+ name: exports_external.string().regex(APP_NAME_RE, "must be 3-40 chars, lowercase, start with a letter, [a-z0-9-] only"),
104965
105026
  template: exports_external.string().min(1, "Template is required"),
104966
105027
  directory: exports_external.string().optional(),
104967
105028
  force: exports_external.boolean().optional()
@@ -105016,6 +105077,7 @@ var dashboardInputSchema = exports_external.object({
105016
105077
  });
105017
105078
  var DEFAULT_BASE = "https://percher.app";
105018
105079
  var TAB_SHAPE = /^[a-z][a-z0-9-]{0,31}$/;
105080
+ var SETTINGS_ANCHOR_TABS = new Set(["github", "env", "domains", "crash-diagnosis"]);
105019
105081
  async function dashboard(ctx, input = {}) {
105020
105082
  const name = input.app ?? readPercherTomlAppName(ctx.cwd);
105021
105083
  if (!name) {
@@ -105032,7 +105094,8 @@ async function dashboard(ctx, input = {}) {
105032
105094
  });
105033
105095
  }
105034
105096
  const base = ctx.env.PERCHER_DASHBOARD_URL ?? DEFAULT_BASE;
105035
- const url2 = `${base}/apps/${encodeURIComponent(name)}?tab=${tab}`;
105097
+ const appBase = `${base}/apps/${encodeURIComponent(name)}`;
105098
+ const url2 = SETTINGS_ANCHOR_TABS.has(tab) ? `${appBase}/settings#${tab}` : `${appBase}?tab=${tab}`;
105036
105099
  await ctx.openUrl(url2);
105037
105100
  return url2;
105038
105101
  }
@@ -105124,6 +105187,28 @@ async function deleteApp(ctx, input) {
105124
105187
  }
105125
105188
  await ctx.client.apps.delete(name);
105126
105189
  }
105190
+ // ../client/dist/abortable-sleep.js
105191
+ function abortableSleep(ms, signal) {
105192
+ return new Promise((resolve6) => {
105193
+ if (!signal) {
105194
+ setTimeout(resolve6, ms);
105195
+ return;
105196
+ }
105197
+ if (signal.aborted) {
105198
+ resolve6();
105199
+ return;
105200
+ }
105201
+ const onAbort = () => {
105202
+ clearTimeout(t);
105203
+ resolve6();
105204
+ };
105205
+ const t = setTimeout(() => {
105206
+ signal.removeEventListener("abort", onAbort);
105207
+ resolve6();
105208
+ }, ms);
105209
+ signal.addEventListener("abort", onAbort, { once: true });
105210
+ });
105211
+ }
105127
105212
  // ../client/dist/build-env-prefixes.js
105128
105213
  var AUTO_FORWARD_PREFIXES = [
105129
105214
  "NEXT_PUBLIC_",
@@ -105196,6 +105281,21 @@ class PercherApiError extends Error {
105196
105281
  this.extra = options.extra;
105197
105282
  }
105198
105283
  }
105284
+
105285
+ class NetworkError extends Error {
105286
+ constructor(message, options) {
105287
+ super(`network error: ${message}`, options);
105288
+ this.name = "NetworkError";
105289
+ }
105290
+ }
105291
+ var TRANSIENT_NETWORK_MESSAGE = /network error|fetch failed|ECONNRESET|ENOTFOUND|ETIMEDOUT/i;
105292
+ function isTransientNetworkError(err) {
105293
+ if (err instanceof NetworkError)
105294
+ return true;
105295
+ if (err instanceof PercherApiError)
105296
+ return false;
105297
+ return err instanceof Error && TRANSIENT_NETWORK_MESSAGE.test(err.message);
105298
+ }
105199
105299
  var RESTORE_JOB_STATUSES_FOR_ERROR = [
105200
105300
  "preparing",
105201
105301
  "quiescing",
@@ -105244,6 +105344,11 @@ function isSunsetGateBody(body) {
105244
105344
  }
105245
105345
 
105246
105346
  // ../client/dist/http.js
105347
+ var BODY_SNIPPET_MAX = 120;
105348
+ function bodySnippet(body) {
105349
+ return body.length <= BODY_SNIPPET_MAX ? body : `${body.slice(0, BODY_SNIPPET_MAX)}…`;
105350
+ }
105351
+
105247
105352
  class Http {
105248
105353
  apiUrl;
105249
105354
  token;
@@ -105257,8 +105362,8 @@ class Http {
105257
105362
  this.clientVersion = options.clientVersion;
105258
105363
  this.sessionId = options.sessionId;
105259
105364
  }
105260
- get(path) {
105261
- return this.request("GET", path);
105365
+ get(path, opts) {
105366
+ return this.request("GET", path, undefined, opts?.signal);
105262
105367
  }
105263
105368
  post(path, body) {
105264
105369
  return this.request("POST", path, body);
@@ -105296,7 +105401,7 @@ class Http {
105296
105401
  try {
105297
105402
  res = await this.fetchImpl(`${this.apiUrl}${path}`, { method: "GET", headers });
105298
105403
  } catch (err) {
105299
- throw new Error(`network error: ${err.message}`, { cause: err });
105404
+ throw new NetworkError(err.message, { cause: err });
105300
105405
  }
105301
105406
  if (!res.ok)
105302
105407
  throw await this.parseError(res);
@@ -105322,15 +105427,15 @@ class Http {
105322
105427
  try {
105323
105428
  res = await this.fetchImpl(`${this.apiUrl}${path}`, init);
105324
105429
  } catch (err) {
105325
- throw new Error(`network error: ${err.message}`, { cause: err });
105430
+ throw new NetworkError(err.message, { cause: err });
105326
105431
  }
105327
105432
  if (!res.ok)
105328
105433
  throw await this.parseError(res);
105329
105434
  return await res.json();
105330
105435
  }
105331
- async request(method, path, body) {
105436
+ async request(method, path, body, signal) {
105332
105437
  const headers = this.buildHeaders();
105333
- const init = { method, headers };
105438
+ const init = { method, headers, signal };
105334
105439
  if (body !== undefined) {
105335
105440
  headers["Content-Type"] = "application/json";
105336
105441
  init.body = JSON.stringify(body);
@@ -105339,7 +105444,7 @@ class Http {
105339
105444
  try {
105340
105445
  res = await this.fetchImpl(`${this.apiUrl}${path}`, init);
105341
105446
  } catch (err) {
105342
- throw new Error(`network error: ${err.message}`, { cause: err });
105447
+ throw new NetworkError(err.message, { cause: err });
105343
105448
  }
105344
105449
  if (!res.ok)
105345
105450
  throw await this.parseError(res);
@@ -105350,7 +105455,10 @@ class Http {
105350
105455
  return await res.json();
105351
105456
  if (contentType.startsWith("text/"))
105352
105457
  return await res.text();
105353
- return;
105458
+ const raw = await res.text();
105459
+ if (raw.length === 0)
105460
+ return;
105461
+ throw new PercherApiError(`unexpected content-type "${contentType || "none"}" on ${method} ${path}: ${bodySnippet(raw)}`, { status: res.status, code: "UNEXPECTED_CONTENT_TYPE" });
105354
105462
  }
105355
105463
  buildHeaders() {
105356
105464
  const headers = {
@@ -105451,11 +105559,12 @@ class AlertsResource {
105451
105559
  }
105452
105560
  }
105453
105561
 
105454
- // ../client/dist/resources/apps.js
105562
+ // ../client/dist/resources/apps-types.js
105455
105563
  function isDeployAlreadyInProgress(res) {
105456
105564
  return res.status === "already_in_progress";
105457
105565
  }
105458
105566
 
105567
+ // ../client/dist/resources/apps.js
105459
105568
  class AppsResource {
105460
105569
  http;
105461
105570
  constructor(http) {
@@ -105470,8 +105579,8 @@ class AppsResource {
105470
105579
  listLight() {
105471
105580
  return this.http.get("/apps?light=1");
105472
105581
  }
105473
- get(id) {
105474
- return this.http.get(`/apps/${encodeURIComponent(id)}`);
105582
+ get(id, opts) {
105583
+ return this.http.get(`/apps/${encodeURIComponent(id)}`, opts);
105475
105584
  }
105476
105585
  async deploy(id, input) {
105477
105586
  const params = new URLSearchParams;
@@ -105510,6 +105619,12 @@ class AppsResource {
105510
105619
  enabled
105511
105620
  });
105512
105621
  }
105622
+ setPreviewBranch(appRef, branch) {
105623
+ return this.http.put(`/apps/${encodeURIComponent(appRef)}/preview-branch`, { branch });
105624
+ }
105625
+ unsetPreviewBranch(appRef) {
105626
+ return this.http.deleteJson(`/apps/${encodeURIComponent(appRef)}/preview-branch`);
105627
+ }
105513
105628
  setDescription(appRef, description) {
105514
105629
  return this.http.patch(`/apps/${encodeURIComponent(appRef)}/description`, {
105515
105630
  description
@@ -105548,8 +105663,8 @@ class AppsResource {
105548
105663
  discardPreview(appRef, deployId) {
105549
105664
  return this.http.post(`/apps/${encodeURIComponent(appRef)}/deploys/${encodeURIComponent(deployId)}/discard`, {});
105550
105665
  }
105551
- getDeployment(appRef, deployId) {
105552
- return this.http.get(`/apps/${encodeURIComponent(appRef)}/deploys/${encodeURIComponent(deployId)}`);
105666
+ getDeployment(appRef, deployId, opts) {
105667
+ return this.http.get(`/apps/${encodeURIComponent(appRef)}/deploys/${encodeURIComponent(deployId)}`, opts);
105553
105668
  }
105554
105669
  listDeploys(appRef, opts) {
105555
105670
  const params = new URLSearchParams;
@@ -105558,32 +105673,36 @@ class AppsResource {
105558
105673
  if (opts?.limit)
105559
105674
  params.set("limit", String(opts.limit));
105560
105675
  const qs = params.toString();
105561
- return this.http.get(`/apps/${encodeURIComponent(appRef)}/deploys${qs ? `?${qs}` : ""}`);
105676
+ return this.http.get(`/apps/${encodeURIComponent(appRef)}/deploys${qs ? `?${qs}` : ""}`, opts?.signal ? { signal: opts.signal } : undefined);
105562
105677
  }
105563
- async getBuildLog(appRef, deployId) {
105678
+ async getBuildLog(appRef, deployId, opts) {
105564
105679
  const path = `/apps/${encodeURIComponent(appRef)}/deploys/${encodeURIComponent(deployId)}/build-log`;
105565
105680
  let attempt = 0;
105566
105681
  const maxRetries = 5;
105567
105682
  while (true) {
105568
105683
  try {
105569
- const res = await this.http.get(path);
105684
+ const res = await this.http.get(path, opts);
105570
105685
  if (typeof res === "string")
105571
105686
  return res;
105572
105687
  return res?.buildLog ?? "";
105573
105688
  } catch (err) {
105574
105689
  attempt++;
105575
- const msg = err.message ?? String(err);
105576
- const isNetwork = msg.startsWith("network error:");
105577
- if (!isNetwork || attempt >= maxRetries)
105690
+ if (opts?.signal?.aborted || !isTransientNetworkError(err) || attempt >= maxRetries) {
105578
105691
  throw err;
105692
+ }
105579
105693
  const backoffMs = 1000 * 2 ** (attempt - 1);
105580
- await new Promise((r) => setTimeout(r, backoffMs));
105694
+ await abortableSleep(backoffMs, opts?.signal);
105695
+ if (opts?.signal?.aborted)
105696
+ throw err;
105581
105697
  }
105582
105698
  }
105583
105699
  }
105584
105700
  unsuspend(appRef) {
105585
105701
  return this.http.post(`/apps/${encodeURIComponent(appRef)}/unsuspend`, {});
105586
105702
  }
105703
+ resetCache(appRef) {
105704
+ return this.http.post(`/apps/${encodeURIComponent(appRef)}/cache/reset`, {});
105705
+ }
105587
105706
  delete(id) {
105588
105707
  return this.http.delete(`/apps/${encodeURIComponent(id)}`);
105589
105708
  }
@@ -106003,6 +106122,9 @@ class SecurityResource {
106003
106122
  overview() {
106004
106123
  return this.http.get("/admin/security/overview");
106005
106124
  }
106125
+ platformBaseOverview() {
106126
+ return this.http.get("/admin/security/platform-base");
106127
+ }
106006
106128
  rescanAllAsAdmin() {
106007
106129
  return this.http.post("/admin/security/rescan", {});
106008
106130
  }
@@ -106192,17 +106314,6 @@ async function deploysInspect(ctx, input) {
106192
106314
  import { existsSync as existsSync7 } from "node:fs";
106193
106315
  import { join as join10 } from "node:path";
106194
106316
 
106195
- // ../shared/dist/timeouts.js
106196
- var TIMEOUTS = {
106197
- workerBuildSubprocess: 10 * 60000,
106198
- apiToWorkerBuildFetch: 11 * 60000,
106199
- clientPublishPoll: 12 * 60000,
106200
- workerHealthcheck: 5000,
106201
- watchdogList: 15000,
106202
- mcpWaitDeployDefault: 30000,
106203
- mcpWaitDeployMax: 12 * 60000
106204
- };
106205
-
106206
106317
  // ../core/dist/tarball.js
106207
106318
  var import_ignore = __toESM(require_ignore(), 1);
106208
106319
  import { createHash as createHash2 } from "node:crypto";
@@ -107159,15 +107270,17 @@ function trimMessage(raw) {
107159
107270
  function tomlPathFor(cwd) {
107160
107271
  return join11(cwd, "percher.toml");
107161
107272
  }
107162
- async function buildFailureError(ctx, app, deployment) {
107273
+ async function buildFailureError(ctx, app, deployment, signal) {
107163
107274
  let buildLogTail;
107164
107275
  let buildLogFetchFailed = false;
107165
107276
  let fullLog = "";
107166
107277
  try {
107167
- fullLog = await ctx.client.apps.getBuildLog(app.id, deployment.id);
107168
- if (!fullLog) {
107169
- await new Promise((r) => setTimeout(r, 1500));
107170
- fullLog = await ctx.client.apps.getBuildLog(app.id, deployment.id);
107278
+ fullLog = await ctx.client.apps.getBuildLog(app.id, deployment.id, { signal });
107279
+ if (!fullLog && !signal?.aborted) {
107280
+ await abortableSleep(1500, signal);
107281
+ if (!signal?.aborted) {
107282
+ fullLog = await ctx.client.apps.getBuildLog(app.id, deployment.id, { signal });
107283
+ }
107171
107284
  }
107172
107285
  if (fullLog) {
107173
107286
  const lines = fullLog.split(`
@@ -107213,7 +107326,7 @@ async function buildFailureError(ctx, app, deployment) {
107213
107326
  }
107214
107327
  async function classifyDeploymentFailure(opts) {
107215
107328
  const { ctx, app, deployment, publishInput } = opts;
107216
- const error48 = await buildFailureError(ctx, app, deployment);
107329
+ const error48 = await buildFailureError(ctx, app, deployment, opts.signal);
107217
107330
  const recovery = error48.errorClass ? recoveryFromErrorClass(error48.errorClass, {
107218
107331
  missingEnvVars: error48.missingEnvVars,
107219
107332
  deployId: deployment.id,
@@ -107259,6 +107372,100 @@ var waitForDeployInputSchema = exports_external.object({
107259
107372
  timeoutSeconds: exports_external.number().int().min(1).max(TIMEOUTS.mcpWaitDeployMax / 1000).optional().describe(`Maximum wall-clock seconds to wait before returning still_running (default ${TIMEOUTS.mcpWaitDeployDefault / 1000}, max ${TIMEOUTS.mcpWaitDeployMax / 1000}). Keeping this short is the point — the agent can resume in another short call without holding a long-lived connection.`),
107260
107373
  pollIntervalSeconds: exports_external.number().int().min(1).max(30).optional().describe("Seconds between polls (default 2).")
107261
107374
  });
107375
+ async function waitForDeploy(ctx, input, opts) {
107376
+ const timeoutSeconds = input.timeoutSeconds ?? TIMEOUTS.mcpWaitDeployDefault / 1000;
107377
+ const pollIntervalSeconds = input.pollIntervalSeconds ?? 2;
107378
+ const startedAt = Date.now();
107379
+ const deadlineMs = startedAt + timeoutSeconds * 1000;
107380
+ const controller = new AbortController;
107381
+ const abortTimer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
107382
+ const onExternalAbort = () => controller.abort();
107383
+ opts?.signal?.addEventListener("abort", onExternalAbort, { once: true });
107384
+ if (opts?.signal?.aborted)
107385
+ controller.abort();
107386
+ try {
107387
+ let app;
107388
+ let initial;
107389
+ try {
107390
+ [app, initial] = await Promise.all([
107391
+ ctx.client.apps.get(input.app, { signal: controller.signal }),
107392
+ ctx.client.apps.getDeployment(input.app, input.deployId, { signal: controller.signal })
107393
+ ]);
107394
+ } catch (err) {
107395
+ if (controller.signal.aborted) {
107396
+ throw new PercherCoreError(`wait-for-deploy timed out after ${timeoutSeconds}s before the first status fetch completed`, {
107397
+ code: "wait.timeout",
107398
+ hint: "The API is slow or unreachable. Retry with a larger timeout, or run percher doctor."
107399
+ });
107400
+ }
107401
+ throw err;
107402
+ }
107403
+ let deployment = initial;
107404
+ while (!isTerminal(deployment.status)) {
107405
+ if (controller.signal.aborted || Date.now() >= deadlineMs) {
107406
+ return buildStillRunningResult(app, deployment, startedAt);
107407
+ }
107408
+ const sleepMs = Math.min(pollIntervalSeconds * 1000, deadlineMs - Date.now());
107409
+ await abortableSleep(sleepMs, controller.signal);
107410
+ if (controller.signal.aborted || Date.now() >= deadlineMs) {
107411
+ return buildStillRunningResult(app, deployment, startedAt);
107412
+ }
107413
+ try {
107414
+ deployment = await ctx.client.apps.getDeployment(input.app, input.deployId, {
107415
+ signal: controller.signal
107416
+ });
107417
+ } catch (err) {
107418
+ if (controller.signal.aborted) {
107419
+ return buildStillRunningResult(app, deployment, startedAt, "status fetch exceeded the wait budget");
107420
+ }
107421
+ if (isTransientNetworkError(err)) {
107422
+ const msg = err.message ?? String(err);
107423
+ return buildStillRunningResult(app, deployment, startedAt, msg);
107424
+ }
107425
+ throw err;
107426
+ }
107427
+ }
107428
+ if (deployment.status === "failed") {
107429
+ const { error: error48, recovery, summary } = await classifyDeploymentFailure({
107430
+ ctx,
107431
+ app,
107432
+ deployment,
107433
+ signal: opts?.signal
107434
+ });
107435
+ return {
107436
+ status: "failed",
107437
+ app: { id: app.id, name: app.name, url: app.url },
107438
+ deployment,
107439
+ elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
107440
+ totalElapsedSeconds: secondsSinceCreated(deployment),
107441
+ error: error48,
107442
+ recovery,
107443
+ summary
107444
+ };
107445
+ }
107446
+ if (deployment.status === "replaced") {
107447
+ return await buildReplacedResult({ ctx, app, deployment, startedAt, signal: opts?.signal });
107448
+ }
107449
+ const url2 = deployment.previewUrl ?? deployment.url ?? app.url;
107450
+ return {
107451
+ status: "live",
107452
+ app: { id: app.id, name: app.name, url: app.url },
107453
+ deployment,
107454
+ url: url2,
107455
+ elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
107456
+ totalElapsedSeconds: secondsSinceCreated(deployment),
107457
+ recovery: RECOVERY_NONE,
107458
+ summary: `${app.name} deploy ${deployment.id} is live → ${url2}`
107459
+ };
107460
+ } finally {
107461
+ clearTimeout(abortTimer);
107462
+ controller.abort();
107463
+ opts?.signal?.removeEventListener("abort", onExternalAbort);
107464
+ }
107465
+ }
107466
+ function isTerminal(status) {
107467
+ return status === "live" || status === "failed" || status === "replaced";
107468
+ }
107262
107469
  async function resolveReplaced(opts) {
107263
107470
  const { ctx, app, replacedDeployment } = opts;
107264
107471
  const deployType = replacedDeployment.type ?? "live";
@@ -107266,7 +107473,8 @@ async function resolveReplaced(opts) {
107266
107473
  try {
107267
107474
  const deploys = await ctx.client.apps.listDeploys(app.name, {
107268
107475
  limit: 5,
107269
- type: deployType
107476
+ type: deployType,
107477
+ signal: opts.signal
107270
107478
  });
107271
107479
  latest = deploys.find((d) => d.id !== replacedDeployment.id);
107272
107480
  } catch {
@@ -107313,6 +107521,46 @@ async function resolveReplaced(opts) {
107313
107521
  summary: `${app.name} deploy ${replacedDeployment.id} was replaced; the latest deploy ${latest.id} is ${latest.status}. Surface to user.`
107314
107522
  };
107315
107523
  }
107524
+ async function buildReplacedResult(opts) {
107525
+ const { ctx, app, deployment, startedAt } = opts;
107526
+ const resolved = await resolveReplaced({
107527
+ ctx,
107528
+ app,
107529
+ replacedDeployment: deployment,
107530
+ signal: opts.signal
107531
+ });
107532
+ return {
107533
+ status: "replaced",
107534
+ app: { id: app.id, name: app.name, url: app.url },
107535
+ deployment,
107536
+ url: resolved.url,
107537
+ elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
107538
+ totalElapsedSeconds: secondsSinceCreated(deployment),
107539
+ recovery: resolved.recovery,
107540
+ summary: resolved.summary
107541
+ };
107542
+ }
107543
+ function buildStillRunningResult(app, deployment, startedAt, networkHint) {
107544
+ return {
107545
+ status: "still_running",
107546
+ app: { id: app.id, name: app.name, url: app.url },
107547
+ deployment,
107548
+ elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
107549
+ totalElapsedSeconds: secondsSinceCreated(deployment),
107550
+ recovery: recoveryWait({
107551
+ app: app.name,
107552
+ deployId: deployment.id,
107553
+ reasonCode: deployment.status === "queued" ? "deploy_queued" : deployment.status === "building" ? "deploy_building" : deployment.status === "deploying" ? "deploy_deploying" : "deploy_timeout"
107554
+ }),
107555
+ summary: networkHint ? `${app.name} deploy ${deployment.id} still ${deployment.status} (transient: ${networkHint}) — call percher_wait_for_deploy again.` : `${app.name} deploy ${deployment.id} still ${deployment.status} — call percher_wait_for_deploy again.`
107556
+ };
107557
+ }
107558
+ function secondsSinceCreated(deployment) {
107559
+ const t = Date.parse(deployment.createdAt);
107560
+ if (Number.isNaN(t))
107561
+ return 0;
107562
+ return Math.max(0, Math.round((Date.now() - t) / 1000));
107563
+ }
107316
107564
 
107317
107565
  // ../core/dist/commands/doctor.js
107318
107566
  var doctorInputSchema = exports_external.object({
@@ -107440,17 +107688,19 @@ async function doctor(ctx, input = {}) {
107440
107688
  message: diag.container.running ? "Running" : diag.container.state === "not-found" ? "No container found" : `State: ${diag.container.state}`
107441
107689
  });
107442
107690
  if (diag.containerHealth) {
107691
+ const latency = diag.containerHealth.latencyMs != null ? ` (${diag.containerHealth.latencyMs}ms)` : "";
107443
107692
  checks3.push({
107444
107693
  name: "Container health",
107445
107694
  status: diag.containerHealth.healthy ? "pass" : "fail",
107446
- message: diag.containerHealth.healthy ? `${diag.containerHealth.path} OK (${diag.containerHealth.latencyMs}ms)` : `${diag.containerHealth.path} — not responding`
107695
+ message: diag.containerHealth.healthy ? `${diag.containerHealth.path} OK${latency}` : `${diag.containerHealth.path} — not responding`
107447
107696
  });
107448
107697
  }
107449
107698
  if (diag.publicRoute) {
107699
+ const latency = diag.publicRoute.latencyMs != null ? ` (${diag.publicRoute.latencyMs}ms)` : "";
107450
107700
  checks3.push({
107451
107701
  name: "Public route",
107452
107702
  status: diag.publicRoute.healthy ? "pass" : "fail",
107453
- message: diag.publicRoute.healthy ? `${diag.publicRoute.url} (${diag.publicRoute.latencyMs}ms)` : `${diag.publicRoute.url} — not responding`
107703
+ message: diag.publicRoute.healthy ? `${diag.publicRoute.url}${latency}` : `${diag.publicRoute.url} — not responding`
107454
107704
  });
107455
107705
  }
107456
107706
  if (diag.pocketbase) {
@@ -107636,11 +107886,53 @@ function summarize(checks3, vctx = {}) {
107636
107886
  summary: verdict.summary
107637
107887
  };
107638
107888
  }
107639
- function deriveVerdict(checks3, vctx) {
107889
+ function buildVerdictRuleContext(checks3, vctx) {
107640
107890
  const findCheck = (name) => checks3.find((c) => c.name === name);
107641
- const auth = findCheck("Auth token");
107642
- if (auth?.status === "fail") {
107891
+ const toml = findCheck("percher.toml");
107892
+ const verdictChecks = vctx.appProvided && toml?.status === "fail" ? checks3.filter((c) => c.name !== "percher.toml") : checks3;
107893
+ const containerDown = vctx.containerRunning === false && vctx.containerState !== undefined && vctx.containerState !== "not-found" || vctx.containerHealthy === false;
107894
+ return { checks: checks3, vctx, findCheck, verdictChecks, containerDown };
107895
+ }
107896
+ function runtimeCrashExplanation({ vctx, findCheck }) {
107897
+ const containerCheck = findCheck("Container");
107898
+ const healthCheck = findCheck("Container health");
107899
+ const lastCrashCheck = findCheck("Last crash");
107900
+ const explanationBits = [];
107901
+ if (vctx.appStatus === "crashed")
107902
+ explanationBits.push("App is in crashed state.");
107903
+ if (containerCheck?.status === "fail")
107904
+ explanationBits.push(containerCheck.message);
107905
+ if (healthCheck?.status === "fail")
107906
+ explanationBits.push(healthCheck.message);
107907
+ if (lastCrashCheck)
107908
+ explanationBits.push(lastCrashCheck.message);
107909
+ return explanationBits.length > 0 ? explanationBits.join(" ") : "Runtime is not responding — container or health check is failing.";
107910
+ }
107911
+ var FALLBACK_RULE = {
107912
+ id: "unknown-fallback",
107913
+ when: () => true,
107914
+ verdict: ({ verdictChecks }) => {
107915
+ const firstFailed = verdictChecks.find((c) => c.status === "fail");
107643
107916
  return {
107917
+ status: "needs_action",
107918
+ diagnosis: firstFailed ? {
107919
+ title: firstFailed.name,
107920
+ explanation: firstFailed.message,
107921
+ reasonCode: "unknown"
107922
+ } : undefined,
107923
+ recovery: recoveryAsk({
107924
+ prompt: firstFailed ? `Doctor flagged a problem: ${firstFailed.name} — ${firstFailed.message}. Review the checks list and surface the failing item to the user.` : "Doctor reported one or more issues. Review the checks list and surface the failing items to the user.",
107925
+ reasonCode: "unknown"
107926
+ }),
107927
+ summary: firstFailed ? `${firstFailed.name} failed: ${firstFailed.message}` : "Doctor reported issues — review the checks list."
107928
+ };
107929
+ }
107930
+ };
107931
+ var VERDICT_RULES = [
107932
+ {
107933
+ id: "auth-missing",
107934
+ when: ({ findCheck }) => findCheck("Auth token")?.status === "fail",
107935
+ verdict: () => ({
107644
107936
  status: "blocked",
107645
107937
  diagnosis: {
107646
107938
  title: "Authentication required",
@@ -107650,173 +107942,215 @@ function deriveVerdict(checks3, vctx) {
107650
107942
  },
107651
107943
  recovery: recoveryLogin({ reasonCode: "auth_required" }),
107652
107944
  summary: "Login required: run `percher login` (or set PERCHER_TOKEN)."
107653
- };
107654
- }
107655
- const api2 = findCheck("API reachability");
107656
- if (api2?.status === "fail") {
107657
- return {
107658
- status: "blocked",
107659
- diagnosis: {
107660
- title: "Percher API is unreachable",
107661
- explanation: api2.message,
107662
- reasonCode: "infra_unavailable",
107663
- phase: "infra"
107664
- },
107665
- recovery: recoveryAsk({
107666
- prompt: `Percher API is unreachable (${api2.message}). This is usually a transient network issue — wait a moment and try again, or check status.percher.app.`,
107667
- reasonCode: "infra_unavailable",
107668
- retryable: true
107669
- }),
107670
- summary: "Cannot reach the Percher API — try again in a moment."
107671
- };
107672
- }
107673
- const toml = findCheck("percher.toml");
107674
- if (toml?.status === "fail" && !vctx.appProvided) {
107675
- return {
107676
- status: "blocked",
107677
- diagnosis: {
107678
- title: "Invalid percher.toml",
107679
- explanation: toml.message,
107680
- reasonCode: "config_invalid",
107681
- phase: "config"
107682
- },
107683
- recovery: recoveryFixConfig({
107684
- problems: [
107685
- {
107686
- file: "percher.toml",
107687
- message: toml.message
107688
- }
107689
- ],
107690
- reasonCode: "config_invalid"
107691
- }),
107692
- summary: "percher.toml is invalid — fix the issues and re-run."
107693
- };
107694
- }
107695
- const app = findCheck("App");
107696
- if (app?.status === "skip") {
107697
- return {
107698
- status: "blocked",
107699
- diagnosis: {
107700
- title: "No app specified",
107701
- explanation: app.message,
107702
- reasonCode: "config_missing",
107703
- phase: "config"
107704
- },
107705
- recovery: recoveryAsk({
107706
- prompt: `${app.message} — pass \`--app <name>\` or run \`percher init\` in a project directory to generate a percher.toml.`,
107707
- reasonCode: "config_missing"
107708
- }),
107709
- summary: "No percher.toml found and no --app supplied — pass --app or run percher init."
107710
- };
107711
- }
107712
- const inTransitionalDeploy = !!vctx.lastDeployStatus && TRANSITIONAL_DEPLOY_STATUSES.has(vctx.lastDeployStatus);
107713
- const inTransitionalApp = !!vctx.appStatus && TRANSITIONAL_APP_STATUSES.has(vctx.appStatus);
107714
- if (inTransitionalDeploy || inTransitionalApp) {
107715
- const reasonCode = vctx.lastDeployStatus === "queued" ? "deploy_queued" : vctx.lastDeployStatus === "building" ? "deploy_building" : vctx.lastDeployStatus === "deploying" ? "deploy_deploying" : "deploy_queued";
107716
- const what = inTransitionalApp ? `App ${vctx.appName ?? ""} is provisioning` : `Last deploy is ${vctx.lastDeployStatus}`;
107717
- const recovery = vctx.lastDeployId && vctx.appName ? recoveryWait({
107718
- app: vctx.appName,
107719
- deployId: vctx.lastDeployId,
107720
- reasonCode
107721
- }) : recoveryAsk({
107722
- prompt: `${what.trim()} for ${vctx.appName ?? "this app"}. Wait for it to finish before retrying — there's no deployId to track yet.`,
107723
- reasonCode
107724
- });
107725
- return {
107726
- status: "in_progress",
107727
- diagnosis: {
107728
- title: what.trim(),
107729
- explanation: vctx.lastDeployId ? `${what.trim()} (deploy ${vctx.lastDeployId}). Wait for it to finish before retrying.` : `${what.trim()}. Wait for it to finish before retrying.`,
107730
- reasonCode,
107731
- phase: "deploy"
107732
- },
107733
- recovery,
107734
- summary: vctx.lastDeployId ? `${what.trim()} — wait for deploy ${vctx.lastDeployId}.` : `${what.trim()} — wait a moment and re-run.`
107735
- };
107736
- }
107737
- const verdictChecks = vctx.appProvided && toml?.status === "fail" ? checks3.filter((c) => c.name !== "percher.toml") : checks3;
107738
- if (vctx.replacedResolution) {
107739
- const r = vctx.replacedResolution;
107740
- const isResolvedLive = r.recovery.nextAction === "none" && !!r.url;
107741
- if (isResolvedLive) {
107945
+ })
107946
+ },
107947
+ {
107948
+ id: "api-unreachable",
107949
+ when: ({ findCheck }) => findCheck("API reachability")?.status === "fail",
107950
+ verdict: ({ findCheck }) => {
107951
+ const message = findCheck("API reachability")?.message ?? "";
107742
107952
  return {
107743
- status: "ok",
107744
- recovery: { ...r.recovery, reasonCode: "replaced_by_newer" },
107745
- summary: r.summary
107953
+ status: "blocked",
107954
+ diagnosis: {
107955
+ title: "Percher API is unreachable",
107956
+ explanation: message,
107957
+ reasonCode: "infra_unavailable",
107958
+ phase: "infra"
107959
+ },
107960
+ recovery: recoveryAsk({
107961
+ prompt: `Percher API is unreachable (${message}). This is usually a transient network issue — wait a moment and try again, or check status.percher.app.`,
107962
+ reasonCode: "infra_unavailable",
107963
+ retryable: true
107964
+ }),
107965
+ summary: "Cannot reach the Percher API — try again in a moment."
107746
107966
  };
107747
107967
  }
107748
- const status = r.recovery.nextAction === "wait_deploy" ? "in_progress" : "needs_action";
107749
- return {
107750
- status,
107751
- diagnosis: {
107752
- title: "Last deploy was replaced",
107753
- explanation: r.summary,
107754
- reasonCode: "replaced_by_newer",
107755
- phase: "deploy"
107756
- },
107757
- recovery: { ...r.recovery, reasonCode: "replaced_by_newer" },
107758
- summary: r.summary
107759
- };
107760
- }
107761
- if (vctx.lastDeployStatus === "failed") {
107762
- if (vctx.inputMode === "deploy") {
107763
- const cls = vctx.buildLogClassification;
107764
- if (cls && cls.errorClass === "missing_env" && cls.missingEnvVars.length > 0) {
107765
- return {
107766
- status: "needs_action",
107767
- diagnosis: {
107768
- title: cls.title,
107769
- explanation: cls.explanation,
107770
- reasonCode: "missing_env",
107771
- phase: "build"
107772
- },
107773
- recovery: recoveryEnv({
107774
- app: vctx.appName,
107775
- keys: cls.missingEnvVars
107776
- }),
107777
- summary: vctx.appName ? `Build failed — missing env vars on ${vctx.appName}: ${cls.missingEnvVars.join(", ")}.` : `Build failed — missing env vars: ${cls.missingEnvVars.join(", ")}.`
107778
- };
107779
- }
107780
- const fileProblems = (vctx.buildProblems ?? []).filter((p) => p.file).map(buildProblemToRecoveryProblem);
107781
- if (fileProblems.length > 0) {
107782
- return {
107783
- status: "needs_action",
107784
- diagnosis: {
107785
- title: cls?.title ?? "Build failed with file-located problems",
107786
- explanation: cls?.explanation ?? `Build extracted ${fileProblems.length} structured problem${fileProblems.length === 1 ? "" : "s"} with file locations. Patch the files directly.`,
107787
- reasonCode: "build_failed",
107788
- phase: "build"
107789
- },
107790
- recovery: recoveryFixProblems({
107791
- problems: fileProblems,
107792
- reasonCode: "build_failed"
107793
- }),
107794
- summary: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} failed — ${fileProblems.length} structured problem${fileProblems.length === 1 ? "" : "s"} to patch.` : `Build failed — ${fileProblems.length} structured problem${fileProblems.length === 1 ? "" : "s"} to patch.`
107795
- };
107796
- }
107968
+ },
107969
+ {
107970
+ id: "toml-invalid",
107971
+ when: ({ vctx, findCheck }) => findCheck("percher.toml")?.status === "fail" && !vctx.appProvided,
107972
+ verdict: ({ findCheck }) => {
107973
+ const message = findCheck("percher.toml")?.message ?? "";
107797
107974
  return {
107798
- status: "needs_action",
107975
+ status: "blocked",
107799
107976
  diagnosis: {
107800
- title: cls?.title ?? "Last deploy failed",
107801
- explanation: cls ? `${cls.explanation} Doctor couldn't extract a structured fix; fall back to the build log.` : vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} terminated in failed state. Doctor couldn't classify the cause from the build log; inspect it directly.` : "The most recent deploy ended in failed state. Doctor couldn't classify the cause; inspect the build log.",
107802
- reasonCode: "build_failed",
107803
- phase: "build"
107977
+ title: "Invalid percher.toml",
107978
+ explanation: message,
107979
+ reasonCode: "config_invalid",
107980
+ phase: "config"
107804
107981
  },
107805
- recovery: recoveryInspectBuildLog({
107806
- deployId: vctx.lastDeployId,
107807
- app: vctx.appName,
107808
- reasonCode: "build_failed"
107982
+ recovery: recoveryFixConfig({
107983
+ problems: [
107984
+ {
107985
+ file: "percher.toml",
107986
+ message
107987
+ }
107988
+ ],
107989
+ reasonCode: "config_invalid"
107809
107990
  }),
107810
- summary: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} failed — inspect the build log.` : "Last deploy failed — inspect the latest failed deploy's build log."
107991
+ summary: "percher.toml is invalid — fix the issues and re-run."
107811
107992
  };
107812
107993
  }
107813
- return {
107814
- status: "needs_action",
107815
- diagnosis: {
107816
- title: "Last deploy failed",
107817
- explanation: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} terminated in failed state. Inspect the build log to find the cause.` : "The most recent deploy ended in failed state. Inspect the build log to find the cause.",
107818
- reasonCode: "build_failed",
107819
- phase: "build"
107994
+ },
107995
+ {
107996
+ id: "app-unresolved",
107997
+ when: ({ findCheck }) => findCheck("App")?.status === "skip",
107998
+ verdict: ({ findCheck }) => {
107999
+ const message = findCheck("App")?.message ?? "";
108000
+ return {
108001
+ status: "blocked",
108002
+ diagnosis: {
108003
+ title: "No app specified",
108004
+ explanation: message,
108005
+ reasonCode: "config_missing",
108006
+ phase: "config"
108007
+ },
108008
+ recovery: recoveryAsk({
108009
+ prompt: `${message} — pass \`--app <name>\` or run \`percher init\` in a project directory to generate a percher.toml.`,
108010
+ reasonCode: "config_missing"
108011
+ }),
108012
+ summary: "No percher.toml found and no --app supplied — pass --app or run percher init."
108013
+ };
108014
+ }
108015
+ },
108016
+ {
108017
+ id: "deploy-transitional",
108018
+ when: ({ vctx }) => !!vctx.lastDeployStatus && TRANSITIONAL_DEPLOY_STATUSES.has(vctx.lastDeployStatus) || !!vctx.appStatus && TRANSITIONAL_APP_STATUSES.has(vctx.appStatus),
108019
+ verdict: ({ vctx }) => {
108020
+ const inTransitionalApp = !!vctx.appStatus && TRANSITIONAL_APP_STATUSES.has(vctx.appStatus);
108021
+ const reasonCode = vctx.lastDeployStatus === "queued" ? "deploy_queued" : vctx.lastDeployStatus === "building" ? "deploy_building" : vctx.lastDeployStatus === "deploying" ? "deploy_deploying" : "deploy_queued";
108022
+ const what = inTransitionalApp ? `App ${vctx.appName ?? ""} is provisioning` : `Last deploy is ${vctx.lastDeployStatus}`;
108023
+ const recovery = vctx.lastDeployId && vctx.appName ? recoveryWait({
108024
+ app: vctx.appName,
108025
+ deployId: vctx.lastDeployId,
108026
+ reasonCode
108027
+ }) : recoveryAsk({
108028
+ prompt: `${what.trim()} for ${vctx.appName ?? "this app"}. Wait for it to finish before retrying — there's no deployId to track yet.`,
108029
+ reasonCode
108030
+ });
108031
+ return {
108032
+ status: "in_progress",
108033
+ diagnosis: {
108034
+ title: what.trim(),
108035
+ explanation: vctx.lastDeployId ? `${what.trim()} (deploy ${vctx.lastDeployId}). Wait for it to finish before retrying.` : `${what.trim()}. Wait for it to finish before retrying.`,
108036
+ reasonCode,
108037
+ phase: "deploy"
108038
+ },
108039
+ recovery,
108040
+ summary: vctx.lastDeployId ? `${what.trim()} — wait for deploy ${vctx.lastDeployId}.` : `${what.trim()} — wait a moment and re-run.`
108041
+ };
108042
+ }
108043
+ },
108044
+ {
108045
+ id: "deploy-replaced",
108046
+ when: ({ vctx }) => !!vctx.replacedResolution,
108047
+ verdict: (ctx) => {
108048
+ const r = ctx.vctx.replacedResolution;
108049
+ if (!r)
108050
+ return FALLBACK_RULE.verdict(ctx);
108051
+ const isResolvedLive = r.recovery.nextAction === "none" && !!r.url;
108052
+ if (isResolvedLive) {
108053
+ return {
108054
+ status: "ok",
108055
+ recovery: { ...r.recovery, reasonCode: "replaced_by_newer" },
108056
+ summary: r.summary
108057
+ };
108058
+ }
108059
+ const status = r.recovery.nextAction === "wait_deploy" ? "in_progress" : "needs_action";
108060
+ return {
108061
+ status,
108062
+ diagnosis: {
108063
+ title: "Last deploy was replaced",
108064
+ explanation: r.summary,
108065
+ reasonCode: "replaced_by_newer",
108066
+ phase: "deploy"
108067
+ },
108068
+ recovery: { ...r.recovery, reasonCode: "replaced_by_newer" },
108069
+ summary: r.summary
108070
+ };
108071
+ }
108072
+ },
108073
+ {
108074
+ id: "deploy-failed-missing-env",
108075
+ when: ({ vctx }) => {
108076
+ const cls = vctx.buildLogClassification;
108077
+ return vctx.lastDeployStatus === "failed" && vctx.inputMode === "deploy" && !!cls && cls.errorClass === "missing_env" && cls.missingEnvVars.length > 0;
108078
+ },
108079
+ verdict: (ctx) => {
108080
+ const { vctx } = ctx;
108081
+ const cls = vctx.buildLogClassification;
108082
+ if (!cls)
108083
+ return FALLBACK_RULE.verdict(ctx);
108084
+ return {
108085
+ status: "needs_action",
108086
+ diagnosis: {
108087
+ title: cls.title,
108088
+ explanation: cls.explanation,
108089
+ reasonCode: "missing_env",
108090
+ phase: "build"
108091
+ },
108092
+ recovery: recoveryEnv({
108093
+ app: vctx.appName,
108094
+ keys: cls.missingEnvVars
108095
+ }),
108096
+ summary: vctx.appName ? `Build failed — missing env vars on ${vctx.appName}: ${cls.missingEnvVars.join(", ")}.` : `Build failed — missing env vars: ${cls.missingEnvVars.join(", ")}.`
108097
+ };
108098
+ }
108099
+ },
108100
+ {
108101
+ id: "deploy-failed-file-problems",
108102
+ when: ({ vctx }) => vctx.lastDeployStatus === "failed" && vctx.inputMode === "deploy" && (vctx.buildProblems ?? []).filter((p) => p.file).length > 0,
108103
+ verdict: ({ vctx }) => {
108104
+ const cls = vctx.buildLogClassification;
108105
+ const fileProblems = (vctx.buildProblems ?? []).filter((p) => p.file).map(buildProblemToRecoveryProblem);
108106
+ return {
108107
+ status: "needs_action",
108108
+ diagnosis: {
108109
+ title: cls?.title ?? "Build failed with file-located problems",
108110
+ explanation: cls?.explanation ?? `Build extracted ${fileProblems.length} structured problem${fileProblems.length === 1 ? "" : "s"} with file locations. Patch the files directly.`,
108111
+ reasonCode: "build_failed",
108112
+ phase: "build"
108113
+ },
108114
+ recovery: recoveryFixProblems({
108115
+ problems: fileProblems,
108116
+ reasonCode: "build_failed"
108117
+ }),
108118
+ summary: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} failed — ${fileProblems.length} structured problem${fileProblems.length === 1 ? "" : "s"} to patch.` : `Build failed — ${fileProblems.length} structured problem${fileProblems.length === 1 ? "" : "s"} to patch.`
108119
+ };
108120
+ }
108121
+ },
108122
+ {
108123
+ id: "deploy-failed-inspect-log",
108124
+ when: ({ vctx }) => vctx.lastDeployStatus === "failed" && vctx.inputMode === "deploy",
108125
+ verdict: ({ vctx }) => {
108126
+ const cls = vctx.buildLogClassification;
108127
+ return {
108128
+ status: "needs_action",
108129
+ diagnosis: {
108130
+ title: cls?.title ?? "Last deploy failed",
108131
+ explanation: cls ? `${cls.explanation} Doctor couldn't extract a structured fix; fall back to the build log.` : vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} terminated in failed state. Doctor couldn't classify the cause from the build log; inspect it directly.` : "The most recent deploy ended in failed state. Doctor couldn't classify the cause; inspect the build log.",
108132
+ reasonCode: "build_failed",
108133
+ phase: "build"
108134
+ },
108135
+ recovery: recoveryInspectBuildLog({
108136
+ deployId: vctx.lastDeployId,
108137
+ app: vctx.appName,
108138
+ reasonCode: "build_failed"
108139
+ }),
108140
+ summary: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} failed — inspect the build log.` : "Last deploy failed — inspect the latest failed deploy's build log."
108141
+ };
108142
+ }
108143
+ },
108144
+ {
108145
+ id: "deploy-failed-dispatch",
108146
+ when: ({ vctx }) => vctx.lastDeployStatus === "failed",
108147
+ verdict: ({ vctx }) => ({
108148
+ status: "needs_action",
108149
+ diagnosis: {
108150
+ title: "Last deploy failed",
108151
+ explanation: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} terminated in failed state. Inspect the build log to find the cause.` : "The most recent deploy ended in failed state. Inspect the build log to find the cause.",
108152
+ reasonCode: "build_failed",
108153
+ phase: "build"
107820
108154
  },
107821
108155
  recovery: {
107822
108156
  retryable: false,
@@ -107830,10 +108164,12 @@ function deriveVerdict(checks3, vctx) {
107830
108164
  reasonCode: "build_failed"
107831
108165
  },
107832
108166
  summary: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} failed — call percher_doctor with mode='deploy'.` : "Last deploy failed — call percher_doctor with mode='deploy'."
107833
- };
107834
- }
107835
- if (vctx.appStatus === "suspended") {
107836
- return {
108167
+ })
108168
+ },
108169
+ {
108170
+ id: "app-suspended",
108171
+ when: ({ vctx }) => vctx.appStatus === "suspended",
108172
+ verdict: ({ vctx }) => ({
107837
108173
  status: "needs_action",
107838
108174
  diagnosis: {
107839
108175
  title: "App is suspended",
@@ -107846,33 +108182,27 @@ function deriveVerdict(checks3, vctx) {
107846
108182
  prompt: vctx.appName ? `${vctx.appName} is suspended. Resume the app from the dashboard (or call the unsuspend API) before retrying.` : "This app is suspended. Resume it from the dashboard before retrying."
107847
108183
  }),
107848
108184
  summary: vctx.appName ? `${vctx.appName} is suspended — resume the app before retrying.` : "App is suspended — resume it before retrying."
107849
- };
107850
- }
107851
- if (verdictChecks.every((c) => c.status === "pass" || c.status === "skip" || c.status === "warn")) {
107852
- const passing = verdictChecks.filter((c) => c.status === "pass").length;
107853
- const noun = passing === 1 ? "check" : "checks";
107854
- return {
107855
- status: "ok",
107856
- recovery: recoveryNone({ reasonCode: "none" }),
107857
- summary: vctx.appName ? `All ${passing} ${noun} passed for ${vctx.appName}.` : `All ${passing} ${noun} passed.`
107858
- };
107859
- }
107860
- const containerDown = vctx.containerRunning === false && vctx.containerState !== undefined && vctx.containerState !== "not-found" || vctx.containerHealthy === false;
107861
- if (vctx.appStatus === "crashed" || containerDown) {
107862
- const containerCheck = findCheck("Container");
107863
- const healthCheck = findCheck("Container health");
107864
- const lastCrashCheck = findCheck("Last crash");
107865
- const explanationBits = [];
107866
- if (vctx.appStatus === "crashed")
107867
- explanationBits.push("App is in crashed state.");
107868
- if (containerCheck?.status === "fail")
107869
- explanationBits.push(containerCheck.message);
107870
- if (healthCheck?.status === "fail")
107871
- explanationBits.push(healthCheck.message);
107872
- if (lastCrashCheck)
107873
- explanationBits.push(lastCrashCheck.message);
107874
- const explanation = explanationBits.length > 0 ? explanationBits.join(" ") : "Runtime is not responding — container or health check is failing.";
107875
- if (vctx.inputMode === "runtime") {
108185
+ })
108186
+ },
108187
+ {
108188
+ id: "all-healthy",
108189
+ when: ({ verdictChecks }) => verdictChecks.every((c) => c.status === "pass" || c.status === "skip" || c.status === "warn"),
108190
+ verdict: ({ vctx, verdictChecks }) => {
108191
+ const passing = verdictChecks.filter((c) => c.status === "pass").length;
108192
+ const noun = passing === 1 ? "check" : "checks";
108193
+ return {
108194
+ status: "ok",
108195
+ recovery: recoveryNone({ reasonCode: "none" }),
108196
+ summary: vctx.appName ? `All ${passing} ${noun} passed for ${vctx.appName}.` : `All ${passing} ${noun} passed.`
108197
+ };
108198
+ }
108199
+ },
108200
+ {
108201
+ id: "runtime-crashed-report",
108202
+ when: ({ vctx, containerDown }) => (vctx.appStatus === "crashed" || containerDown) && vctx.inputMode === "runtime",
108203
+ verdict: (ctx) => {
108204
+ const { vctx } = ctx;
108205
+ const explanation = runtimeCrashExplanation(ctx);
107876
108206
  const cr = vctx.crashReport;
107877
108207
  const hasAnalysis = cr && cr.analysisStatus === "completed";
107878
108208
  const promptParts = [];
@@ -107909,58 +108239,62 @@ ${cr.logs.join(`
107909
108239
  summary: vctx.appName ? hasAnalysis ? `${vctx.appName} crashed — ${cr.suggestion ?? cr.explanation ?? "surface the crash details to the user."}` : `${vctx.appName} runtime is unhealthy — surface the crash details to the user.` : "Runtime is unhealthy — surface the crash details to the user."
107910
108240
  };
107911
108241
  }
107912
- return {
107913
- status: "needs_action",
107914
- diagnosis: {
107915
- title: vctx.appStatus === "crashed" ? "App crashed" : "Runtime not responding",
107916
- explanation,
107917
- reasonCode: "runtime_crashed",
107918
- phase: "runtime"
107919
- },
107920
- recovery: {
107921
- retryable: false,
107922
- nextAction: "run_doctor",
107923
- suggestedTool: "percher_doctor",
107924
- args: { app: vctx.appName, mode: "runtime" },
107925
- reasonCode: "runtime_crashed"
107926
- },
107927
- summary: vctx.appName ? `${vctx.appName} runtime needs investigation — call percher_doctor with mode='runtime'.` : "Runtime needs investigation — call percher_doctor with mode='runtime'."
107928
- };
107929
- }
107930
- if (vctx.publicRouteHealthy === false) {
107931
- const routeCheck = findCheck("Public route");
107932
- return {
107933
- status: "needs_action",
107934
- diagnosis: {
107935
- title: "Public route is not responding",
107936
- explanation: routeCheck?.message ?? "Container looks healthy but the public URL isn't responding — route reconcile usually self-heals.",
107937
- reasonCode: "infra_transient",
107938
- phase: "infra"
107939
- },
107940
- recovery: {
107941
- retryable: true,
107942
- nextAction: "retry",
107943
- suggestedTool: "percher_doctor",
107944
- args: { app: vctx.appName },
107945
- reasonCode: "infra_transient"
107946
- },
107947
- summary: vctx.appName ? `${vctx.appName} public route is failing — likely transient, re-run doctor in a moment.` : "Public route is failing — likely transient, re-run doctor in a moment."
107948
- };
107949
- }
107950
- const firstFailed = verdictChecks.find((c) => c.status === "fail");
107951
- return {
107952
- status: "needs_action",
107953
- diagnosis: firstFailed ? {
107954
- title: firstFailed.name,
107955
- explanation: firstFailed.message,
107956
- reasonCode: "unknown"
107957
- } : undefined,
107958
- recovery: recoveryAsk({
107959
- prompt: firstFailed ? `Doctor flagged a problem: ${firstFailed.name} — ${firstFailed.message}. Review the checks list and surface the failing item to the user.` : "Doctor reported one or more issues. Review the checks list and surface the failing items to the user.",
107960
- reasonCode: "unknown"
107961
- }),
107962
- summary: firstFailed ? `${firstFailed.name} failed: ${firstFailed.message}` : "Doctor reported issues — review the checks list."
107963
- };
108242
+ },
108243
+ {
108244
+ id: "runtime-crashed-dispatch",
108245
+ when: ({ vctx, containerDown }) => vctx.appStatus === "crashed" || containerDown,
108246
+ verdict: (ctx) => {
108247
+ const { vctx } = ctx;
108248
+ const explanation = runtimeCrashExplanation(ctx);
108249
+ return {
108250
+ status: "needs_action",
108251
+ diagnosis: {
108252
+ title: vctx.appStatus === "crashed" ? "App crashed" : "Runtime not responding",
108253
+ explanation,
108254
+ reasonCode: "runtime_crashed",
108255
+ phase: "runtime"
108256
+ },
108257
+ recovery: {
108258
+ retryable: false,
108259
+ nextAction: "run_doctor",
108260
+ suggestedTool: "percher_doctor",
108261
+ args: { app: vctx.appName, mode: "runtime" },
108262
+ reasonCode: "runtime_crashed"
108263
+ },
108264
+ summary: vctx.appName ? `${vctx.appName} runtime needs investigation — call percher_doctor with mode='runtime'.` : "Runtime needs investigation — call percher_doctor with mode='runtime'."
108265
+ };
108266
+ }
108267
+ },
108268
+ {
108269
+ id: "public-route-unhealthy",
108270
+ when: ({ vctx }) => vctx.publicRouteHealthy === false,
108271
+ verdict: ({ vctx, findCheck }) => {
108272
+ const routeCheck = findCheck("Public route");
108273
+ return {
108274
+ status: "needs_action",
108275
+ diagnosis: {
108276
+ title: "Public route is not responding",
108277
+ explanation: routeCheck?.message ?? "Container looks healthy but the public URL isn't responding — route reconcile usually self-heals.",
108278
+ reasonCode: "infra_transient",
108279
+ phase: "infra"
108280
+ },
108281
+ recovery: {
108282
+ retryable: true,
108283
+ nextAction: "retry",
108284
+ suggestedTool: "percher_doctor",
108285
+ args: { app: vctx.appName },
108286
+ reasonCode: "infra_transient"
108287
+ },
108288
+ summary: vctx.appName ? `${vctx.appName} public route is failing — likely transient, re-run doctor in a moment.` : "Public route is failing — likely transient, re-run doctor in a moment."
108289
+ };
108290
+ }
108291
+ },
108292
+ FALLBACK_RULE
108293
+ ];
108294
+ function deriveVerdict(checks3, vctx) {
108295
+ const ctx = buildVerdictRuleContext(checks3, vctx);
108296
+ const rule = VERDICT_RULES.find((r) => r.when(ctx)) ?? FALLBACK_RULE;
108297
+ return rule.verdict(ctx);
107964
108298
  }
107965
108299
  // ../core/dist/commands/domains.js
107966
108300
  var domainAddInputSchema = exports_external.object({
@@ -108994,31 +109328,6 @@ async function login(ctx, input = {}) {
108994
109328
  code: "login.expired"
108995
109329
  });
108996
109330
  }
108997
- // ../shared/dist/logs.js
108998
- var MAX_LOG_SEARCH_LENGTH = 256;
108999
- // ../shared/dist/platform-env.js
109000
- var PERCHER_INJECTED_ENV_KEYS = new Set([
109001
- "NODE_ENV",
109002
- "PORT",
109003
- "DATABASE_URL",
109004
- "POCKETBASE_URL",
109005
- "POCKETBASE_ADMIN_EMAIL",
109006
- "POCKETBASE_ADMIN_PASSWORD",
109007
- "PERCHER_APP_NAME",
109008
- "PERCHER_APP_URL",
109009
- "HTTP_PROXY",
109010
- "HTTPS_PROXY",
109011
- "NO_PROXY",
109012
- "NODE_USE_ENV_PROXY"
109013
- ]);
109014
- var PROXY_LOWERCASE_ALIASES = new Set([
109015
- "http_proxy",
109016
- "https_proxy",
109017
- "no_proxy"
109018
- ]);
109019
- // ../shared/dist/runtime.js
109020
- var DEFAULT_NODE_VERSION_MAJOR = "24";
109021
- var DEFAULT_NODE_VERSION_LABEL = `${DEFAULT_NODE_VERSION_MAJOR} LTS`;
109022
109331
  // ../core/dist/commands/logs.js
109023
109332
  var logsInputSchema = exports_external.object({
109024
109333
  app: exports_external.string().optional(),
@@ -110661,6 +110970,45 @@ async function open(ctx, input = {}) {
110661
110970
  await ctx.openUrl(app.url);
110662
110971
  return app.url;
110663
110972
  }
110973
+ // ../core/dist/commands/preview-branch.js
110974
+ var previewBranchSetInputSchema = exports_external.object({
110975
+ app: exports_external.string().optional(),
110976
+ branch: exports_external.string().min(1, "Branch is required")
110977
+ });
110978
+ var previewBranchInputSchema = exports_external.object({
110979
+ app: exports_external.string().optional()
110980
+ });
110981
+ function resolveApp3(ctx, app) {
110982
+ const name = app ?? readPercherTomlAppName(ctx.cwd);
110983
+ if (!name) {
110984
+ throw new PercherCoreError("No app specified and no percher.toml found", {
110985
+ code: "env.no-app",
110986
+ hint: "Pass an app name or run from a directory with percher.toml."
110987
+ });
110988
+ }
110989
+ return name;
110990
+ }
110991
+ async function previewBranchSet(ctx, input) {
110992
+ const appRef = resolveApp3(ctx, input.app);
110993
+ return ctx.client.apps.setPreviewBranch(appRef, input.branch);
110994
+ }
110995
+ async function previewBranchUnset(ctx, input = {}) {
110996
+ const appRef = resolveApp3(ctx, input.app);
110997
+ return ctx.client.apps.unsetPreviewBranch(appRef);
110998
+ }
110999
+ var setPreviewBranchToolInputSchema = exports_external.object({
111000
+ app: exports_external.string().optional(),
111001
+ branch: exports_external.string().nullable().describe("Branch to pin as the preview address; pass null to clear it.")
111002
+ });
111003
+ async function previewBranchShow(ctx, input = {}) {
111004
+ const appRef = resolveApp3(ctx, input.app);
111005
+ const app = await ctx.client.apps.get(appRef);
111006
+ return {
111007
+ previewBranch: app.previewBranch ?? null,
111008
+ previewUrl: app.previewUrl ?? null,
111009
+ status: app.previewStatus ?? "disabled"
111010
+ };
111011
+ }
110664
111012
  // ../core/dist/commands/publish.js
110665
111013
  import { existsSync as existsSync14 } from "node:fs";
110666
111014
  import { join as join20 } from "node:path";
@@ -110796,10 +111144,10 @@ async function pollDeployment(opts) {
110796
111144
  polled = true;
110797
111145
  } catch (err) {
110798
111146
  attempt++;
110799
- const msg = err.message ?? String(err);
110800
- const isNetwork = msg.startsWith("network error:");
111147
+ const isNetwork = isTransientNetworkError(err);
110801
111148
  if (!isNetwork || attempt >= maxRetries) {
110802
111149
  if (isNetwork) {
111150
+ const msg = err.message ?? String(err);
110803
111151
  throw new Error(`${msg}
110804
111152
 
110805
111153
  The deploy may already be live — your network connection dropped mid-poll. Check with: percher deploys list ${opts.appName}`, { cause: err });
@@ -110961,7 +111309,7 @@ function classifyDeployRetry(err, attempt) {
110961
111309
  category: "non_retryable"
110962
111310
  };
110963
111311
  }
110964
- if (err instanceof Error && /network error|fetch failed|ECONNRESET|ENOTFOUND|ETIMEDOUT/i.test(err.message)) {
111312
+ if (isTransientNetworkError(err)) {
110965
111313
  const delay = NETWORK_BACKOFF_MS[idx];
110966
111314
  if (delay === undefined) {
110967
111315
  return {
@@ -111096,6 +111444,12 @@ function* walkDir(dir, base) {
111096
111444
  }
111097
111445
 
111098
111446
  // ../core/dist/commands/publish-api-error.js
111447
+ var DEPLOY_GATE_CODES = new Set([
111448
+ "RETRY_LIMIT_REACHED",
111449
+ "REQUIRED_ENV_MISSING",
111450
+ "ENV_KEY_UNDECLARED",
111451
+ "DAILY_QUOTA_EXCEEDED"
111452
+ ]);
111099
111453
  function classifyPublishApiError(err, input) {
111100
111454
  if (!(err instanceof PercherApiError))
111101
111455
  return null;
@@ -111120,6 +111474,18 @@ function classifyPublishApiError(err, input) {
111120
111474
  const auth = matchAuthError(err);
111121
111475
  if (auth)
111122
111476
  return buildResult(auth, input);
111477
+ const retryLimit = matchRetryLimitReached(err, input);
111478
+ if (retryLimit)
111479
+ return buildResult(retryLimit, input);
111480
+ const requiredEnv = matchRequiredEnvMissing(err, input);
111481
+ if (requiredEnv)
111482
+ return buildResult(requiredEnv, input);
111483
+ const undeclaredEnv = matchEnvKeyUndeclared(err, input);
111484
+ if (undeclaredEnv)
111485
+ return buildResult(undeclaredEnv, input);
111486
+ const dailyQuota = matchDailyQuotaExceeded(err, input);
111487
+ if (dailyQuota)
111488
+ return buildResult(dailyQuota, input);
111123
111489
  const clientError = matchGenericClientError(err);
111124
111490
  if (clientError)
111125
111491
  return buildResult(clientError, input);
@@ -111128,12 +111494,17 @@ function classifyPublishApiError(err, input) {
111128
111494
  function buildResult(c, input) {
111129
111495
  return {
111130
111496
  status: "failed",
111497
+ ...input.app !== undefined ? { app: input.app } : {},
111131
111498
  fileCount: input.bundle.fileCount,
111132
111499
  bytes: input.bundle.bytes,
111133
111500
  error: {
111134
111501
  title: c.title,
111135
111502
  explanation: c.explanation,
111136
- suggestion: c.suggestion
111503
+ suggestion: c.suggestion,
111504
+ ...c.errorClass !== undefined ? { errorClass: c.errorClass } : {},
111505
+ ...c.phase !== undefined ? { phase: c.phase } : {},
111506
+ ...c.missingEnvVars !== undefined ? { missingEnvVars: c.missingEnvVars } : {},
111507
+ ...c.relevantFiles !== undefined ? { relevantFiles: c.relevantFiles } : {}
111137
111508
  },
111138
111509
  recovery: c.recovery,
111139
111510
  summary: c.summary,
@@ -111152,9 +111523,9 @@ function matchPlanLimit(err) {
111152
111523
  return {
111153
111524
  title: "Plan limit reached",
111154
111525
  explanation: err.message,
111155
- suggestion: "Upgrade your plan at https://percher.app/settings, or delete unused apps with `percher delete <name>`.",
111526
+ suggestion: "Upgrade your plan at https://percher.app/account/billing, or delete unused apps with `percher delete <name>`.",
111156
111527
  recovery: recoveryAsk({
111157
- prompt: `${err.message}. Upgrade at https://percher.app/settings, or delete an unused app with \`percher delete <name>\`.`,
111528
+ prompt: `${err.message}. Upgrade at https://percher.app/account/billing, or delete an unused app with \`percher delete <name>\`.`,
111158
111529
  options: ["upgrade", "delete unused app", "cancel"],
111159
111530
  reasonCode: "quota_exceeded"
111160
111531
  }),
@@ -111172,7 +111543,7 @@ function matchRateLimit(err) {
111172
111543
  return {
111173
111544
  title: "Rate limit hit",
111174
111545
  explanation: err.message,
111175
- suggestion: "Wait a few minutes and try again. If you need a higher cap, upgrade at https://percher.app/settings.",
111546
+ suggestion: "Wait a few minutes and try again. If you need a higher cap, upgrade at https://percher.app/account/billing.",
111176
111547
  recovery: recoveryAsk({
111177
111548
  prompt: `Rate limit: ${err.message}. Wait a few minutes then retry, or upgrade your plan.`,
111178
111549
  options: ["wait and retry", "upgrade", "cancel"],
@@ -111190,7 +111561,7 @@ function matchConcurrentLimit(err) {
111190
111561
  explanation: err.message,
111191
111562
  suggestion: "Wait for one of your in-flight deploys to finish, or upgrade your plan to raise the concurrency cap.",
111192
111563
  recovery: recoveryAsk({
111193
- prompt: `${err.message} Wait for one to finish, or upgrade at https://percher.app/settings.`,
111564
+ prompt: `${err.message} Wait for one to finish, or upgrade at https://percher.app/account/billing.`,
111194
111565
  options: ["wait", "upgrade", "cancel"],
111195
111566
  reasonCode: "deploy_already_in_progress"
111196
111567
  }),
@@ -111265,7 +111636,7 @@ function matchEmailNotVerified(err) {
111265
111636
  return {
111266
111637
  title: "Email not verified",
111267
111638
  explanation: err.message,
111268
- suggestion: "Open the verification link in your inbox, or visit https://percher.app/settings to resend the email. Re-publish after verifying.",
111639
+ suggestion: "Open the verification link in your inbox, or visit https://percher.app/account to resend the email. Re-publish after verifying.",
111269
111640
  recovery: recoveryAsk({
111270
111641
  prompt: `${err.message} Verify the email, then retry publish.`,
111271
111642
  options: ["check inbox", "open settings"],
@@ -111299,6 +111670,94 @@ function matchAuthError(err) {
111299
111670
  summary: `Permission denied (${err.status} ${err.code || "—"}): ${err.message}`
111300
111671
  };
111301
111672
  }
111673
+ function matchRetryLimitReached(err, input) {
111674
+ if (err.code !== "RETRY_LIMIT_REACHED" || input.app === undefined)
111675
+ return null;
111676
+ const appName = input.app.name;
111677
+ const extra = err.extra ?? {};
111678
+ const resetAtStr = extra.resetAt ?? "";
111679
+ const resetAtForPrompt = resetAtStr || "the cooldown window expires";
111680
+ return {
111681
+ title: "Retry limit reached",
111682
+ explanation: "Percher already auto-retried once after an `infra_unavailable` failure for this app within the last 10 minutes. We won't auto-retry again — the next attempt should be a deliberate user decision.",
111683
+ suggestion: `Wait until ${resetAtForPrompt}, then re-run \`percher publish\`. If the failure persists past that, the underlying issue is probably not transient — run \`percher doctor\` to diagnose.`,
111684
+ recovery: recoveryAsk({
111685
+ prompt: `Percher already retried once after an infrastructure failure for ${appName}. Wait until ${resetAtForPrompt} or surface to the user.`,
111686
+ options: ["wait before retrying", "inspect status"],
111687
+ reasonCode: "retry_limit_reached",
111688
+ retryable: false
111689
+ }),
111690
+ summary: `Retry limit reached for ${appName}${resetAtStr ? ` — wait until ${resetAtStr}` : ""}.`
111691
+ };
111692
+ }
111693
+ function matchRequiredEnvMissing(err, input) {
111694
+ if (err.code !== "REQUIRED_ENV_MISSING" || input.app === undefined)
111695
+ return null;
111696
+ const extra = err.extra ?? {};
111697
+ const keys = extra.keys ?? [];
111698
+ const source = extra.source ?? "contract";
111699
+ const sourceText = source === "contract" ? "declared in your [env].required" : source === "discovered" ? "learned from a previous build failure" : "both declared and learned from a previous build failure";
111700
+ return {
111701
+ title: "Required env keys not set",
111702
+ explanation: `${keys.length} env ${keys.length === 1 ? "key is" : "keys are"} ${sourceText} but missing from the env store: ${keys.join(", ")}.`,
111703
+ suggestion: `Run \`bunx percher env set ${keys[0] ?? "KEY"}=value\` (and similarly for the rest) and re-publish.`,
111704
+ errorClass: "missing_env",
111705
+ phase: "config",
111706
+ missingEnvVars: keys,
111707
+ recovery: recoveryEnv({
111708
+ app: input.app.name,
111709
+ keys,
111710
+ reasonCode: "missing_env"
111711
+ }),
111712
+ summary: `Missing required env ${keys.length === 1 ? "key" : "keys"}: ${keys.join(", ")}.`
111713
+ };
111714
+ }
111715
+ function matchEnvKeyUndeclared(err, input) {
111716
+ if (err.code !== "ENV_KEY_UNDECLARED" || input.app === undefined)
111717
+ return null;
111718
+ const extra = err.extra ?? {};
111719
+ const apiProblems = extra.problems ?? [];
111720
+ const problems = apiProblems.map((p) => ({
111721
+ file: p.file,
111722
+ line: p.line,
111723
+ message: `Source references env key '${p.key}' which is not declared in [env]. Add it to [env].required, [env].optional, or [env].ignore in percher.toml. Context: ${p.context ?? "(unavailable)"}`
111724
+ }));
111725
+ const uniqueKeys = [...new Set(apiProblems.map((p) => p.key))];
111726
+ return {
111727
+ title: "Undeclared env key",
111728
+ explanation: `Source references ${uniqueKeys.length} env ${uniqueKeys.length === 1 ? "key" : "keys"} that aren't classified in percher.toml's [env] table: ${uniqueKeys.join(", ")}.`,
111729
+ suggestion: "Add each key to [env].required (must exist before deploy), [env].optional (may be referenced), or [env].ignore (intentionally unset). See https://percher.app/docs/env for the contract.",
111730
+ errorClass: "config_invalid",
111731
+ phase: "config",
111732
+ relevantFiles: ["percher.toml"],
111733
+ recovery: recoveryFixConfig({
111734
+ problems,
111735
+ reasonCode: "env_key_undeclared"
111736
+ }),
111737
+ summary: `${uniqueKeys.length} undeclared env ${uniqueKeys.length === 1 ? "key" : "keys"} in source: ${uniqueKeys.join(", ")}.`
111738
+ };
111739
+ }
111740
+ function matchDailyQuotaExceeded(err, input) {
111741
+ if (err.code !== "DAILY_QUOTA_EXCEEDED" || input.app === undefined)
111742
+ return null;
111743
+ const extra = err.extra ?? {};
111744
+ const kind = extra.kind ?? "live";
111745
+ const used = extra.used ?? 0;
111746
+ const limit = extra.limit ?? 0;
111747
+ const resetAt = extra.resetAt ?? "";
111748
+ const otherKind = kind === "live" ? "preview" : "live";
111749
+ return {
111750
+ title: "Daily deploy quota reached",
111751
+ explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
111752
+ suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/account/billing to raise this cap.`,
111753
+ recovery: recoveryAsk({
111754
+ prompt: `Daily ${kind}-deploy quota reached (${used}/${limit}). Counter resets at ${resetAt}. Wait until then, deploy a ${otherKind} instead, or upgrade at https://percher.app/account/billing.`,
111755
+ options: ["wait", `deploy ${otherKind}`, "upgrade"],
111756
+ reasonCode: "quota_exceeded"
111757
+ }),
111758
+ summary: `Daily ${kind}-deploy quota reached (${used}/${limit}). Resets ${resetAt}.`
111759
+ };
111760
+ }
111302
111761
  function matchGenericClientError(err) {
111303
111762
  if (err.status < 400 || err.status >= 500)
111304
111763
  return null;
@@ -111362,13 +111821,12 @@ var publishInputSchema = exports_external.object({
111362
111821
  noCache: exports_external.boolean().optional().describe("Skip the image-cache lookup and force a fresh build. Use this when you suspect the cached image doesn't reflect your latest source — the new build's resulting image still gets cached, so future deploys benefit."),
111363
111822
  waitForLive: exports_external.boolean().optional().describe("If true (default), publish blocks until the deploy is live or failed. If false, publish returns as soon as the deploy is queued and the agent can resume with percher_wait_for_deploy. Recommended for AI agents — gives back control quickly with a deployId so they can decide between waiting, asking the user, or working on something else.")
111364
111823
  });
111365
- var MAX_BYTES = 500 * 1024 * 1024;
111366
111824
  var SUSPICIOUS_BYTES = 50 * 1024 * 1024;
111367
111825
  var SUSPICIOUS_FILES = 1e4;
111368
111826
  function tomlPathFor2(cwd) {
111369
111827
  return join20(cwd, "percher.toml");
111370
111828
  }
111371
- async function buildReplacedResult(opts) {
111829
+ async function buildReplacedResult2(opts) {
111372
111830
  const { ctx, app, deployment, tarball, cwd } = opts;
111373
111831
  const resolved = await resolveReplaced({
111374
111832
  ctx,
@@ -111406,33 +111864,6 @@ function buildAlreadyInProgressResult(opts) {
111406
111864
  bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111407
111865
  };
111408
111866
  }
111409
- function buildRetryLimitReachedResult(opts) {
111410
- const { err, app, tarball, cwd } = opts;
111411
- const extra = err.extra ?? {};
111412
- const resetAtStr = extra.resetAt ?? "";
111413
- const resetAtForPrompt = resetAtStr || "the cooldown window expires";
111414
- const prompt = `Percher already retried once after an infrastructure failure for ${app.name}. Wait until ${resetAtForPrompt} or surface to the user.`;
111415
- return {
111416
- status: "failed",
111417
- app,
111418
- fileCount: tarball.fileCount,
111419
- bytes: tarball.bytes,
111420
- error: {
111421
- title: "Retry limit reached",
111422
- explanation: "Percher already auto-retried once after an `infra_unavailable` failure for this app within the last 10 minutes. We won't auto-retry again — the next attempt should be a deliberate user decision.",
111423
- suggestion: `Wait until ${resetAtForPrompt}, then re-run \`percher publish\`. If the failure persists past that, the underlying issue is probably not transient — run \`percher doctor\` to diagnose.`
111424
- },
111425
- recovery: recoveryAsk({
111426
- prompt,
111427
- options: ["wait before retrying", "inspect status"],
111428
- reasonCode: "retry_limit_reached",
111429
- retryable: false
111430
- }),
111431
- summary: `Retry limit reached for ${app.name}${resetAtStr ? ` — wait until ${resetAtStr}` : ""}.`,
111432
- configPath: tomlPathFor2(cwd),
111433
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111434
- };
111435
- }
111436
111867
  function buildLiveSummary(opts) {
111437
111868
  const cacheTag = opts.cacheHit ? "cached, " : "";
111438
111869
  const fwSuffix = opts.framework ? ` (${opts.framework}, ${cacheTag}${opts.totalSeconds}s)` : ` (${cacheTag}${opts.totalSeconds}s)`;
@@ -111555,7 +111986,7 @@ ${bullets}`,
111555
111986
  ctx.status("[2/4] Packaging files...");
111556
111987
  const tarball = await createTarball({ cwd: ctx.cwd, config: config2 });
111557
111988
  const packageMs = Date.now() - t0;
111558
- if (tarball.bytes > MAX_BYTES) {
111989
+ if (tarball.bytes > MAX_TARBALL_BYTES) {
111559
111990
  return {
111560
111991
  status: "failed",
111561
111992
  fileCount: tarball.fileCount,
@@ -111650,12 +112081,26 @@ ${keyList}
111650
112081
  ctx.status(`Build queue wait: ~${preflight.queueWaitEstimateSec}s — your build is queued behind ${preflight.queueDepth} other(s).`);
111651
112082
  }
111652
112083
  } catch {}
112084
+ return executeDeploy({
112085
+ ctx,
112086
+ config: config2,
112087
+ input,
112088
+ app,
112089
+ firstDeploy,
112090
+ tarball,
112091
+ t0,
112092
+ packageMs,
112093
+ missingBuildEnvKeys
112094
+ });
112095
+ }
112096
+ async function executeDeploy(opts) {
112097
+ const { ctx, config: config2, input, app, firstDeploy, tarball, t0, packageMs, missingBuildEnvKeys } = opts;
111653
112098
  const uploadStart = Date.now();
111654
112099
  ctx.status("[3/4] Uploading...");
111655
112100
  const tarballBytes = new Uint8Array(await new Response(tarball.stream).arrayBuffer());
111656
112101
  let cacheReusedDeployment = null;
111657
112102
  const contentHash = tarball.contentHash;
111658
- if (!input.noCache) {
112103
+ if (!input.noCache && !input.preview) {
111659
112104
  try {
111660
112105
  const probe = await ctx.client.platform.cacheProbe({
111661
112106
  appId: app.id,
@@ -111701,99 +112146,14 @@ ${keyList}
111701
112146
  }
111702
112147
  deployment = deployResponse;
111703
112148
  } catch (err) {
111704
- if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
111705
- return buildRetryLimitReachedResult({
111706
- err,
111707
- app,
111708
- tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
111709
- cwd: ctx.cwd
111710
- });
111711
- }
111712
- if (err instanceof PercherApiError && err.code === "REQUIRED_ENV_MISSING") {
111713
- const extra = err.extra ?? {};
111714
- const keys = extra.keys ?? [];
111715
- const source = extra.source ?? "contract";
111716
- const sourceText = source === "contract" ? "declared in your [env].required" : source === "discovered" ? "learned from a previous build failure" : "both declared and learned from a previous build failure";
111717
- return {
111718
- status: "failed",
111719
- app,
111720
- fileCount: tarball.fileCount,
111721
- bytes: tarball.bytes,
111722
- error: {
111723
- title: "Required env keys not set",
111724
- explanation: `${keys.length} env ${keys.length === 1 ? "key is" : "keys are"} ${sourceText} but missing from the env store: ${keys.join(", ")}.`,
111725
- suggestion: `Run \`bunx percher env set ${keys[0] ?? "KEY"}=value\` (and similarly for the rest) and re-publish.`,
111726
- errorClass: "missing_env",
111727
- phase: "config",
111728
- missingEnvVars: keys
111729
- },
111730
- recovery: recoveryEnv({
111731
- app: app.name,
111732
- keys,
111733
- reasonCode: "missing_env"
111734
- }),
111735
- summary: `Missing required env ${keys.length === 1 ? "key" : "keys"}: ${keys.join(", ")}.`,
111736
- configPath: tomlPathFor2(ctx.cwd),
111737
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111738
- };
111739
- }
111740
- if (err instanceof PercherApiError && err.code === "ENV_KEY_UNDECLARED") {
111741
- const extra = err.extra ?? {};
111742
- const apiProblems = extra.problems ?? [];
111743
- const problems = apiProblems.map((p) => ({
111744
- file: p.file,
111745
- line: p.line,
111746
- message: `Source references env key '${p.key}' which is not declared in [env]. Add it to [env].required, [env].optional, or [env].ignore in percher.toml. Context: ${p.context ?? "(unavailable)"}`
111747
- }));
111748
- const uniqueKeys = [...new Set(apiProblems.map((p) => p.key))];
111749
- return {
111750
- status: "failed",
111751
- app,
111752
- fileCount: tarball.fileCount,
111753
- bytes: tarball.bytes,
111754
- error: {
111755
- title: "Undeclared env key",
111756
- explanation: `Source references ${uniqueKeys.length} env ${uniqueKeys.length === 1 ? "key" : "keys"} that aren't classified in percher.toml's [env] table: ${uniqueKeys.join(", ")}.`,
111757
- suggestion: "Add each key to [env].required (must exist before deploy), [env].optional (may be referenced), or [env].ignore (intentionally unset). See https://percher.app/docs/env for the contract.",
111758
- errorClass: "config_invalid",
111759
- phase: "config",
111760
- relevantFiles: ["percher.toml"]
111761
- },
111762
- recovery: recoveryFixConfig({
111763
- problems,
111764
- reasonCode: "env_key_undeclared"
111765
- }),
111766
- summary: `${uniqueKeys.length} undeclared env ${uniqueKeys.length === 1 ? "key" : "keys"} in source: ${uniqueKeys.join(", ")}.`,
111767
- configPath: tomlPathFor2(ctx.cwd),
111768
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111769
- };
111770
- }
111771
- if (err instanceof PercherApiError && err.code === "DAILY_QUOTA_EXCEEDED") {
111772
- const extra = err.extra ?? {};
111773
- const kind = extra.kind ?? "live";
111774
- const used = extra.used ?? 0;
111775
- const limit = extra.limit ?? 0;
111776
- const resetAt = extra.resetAt ?? "";
111777
- const otherKind = kind === "live" ? "preview" : "live";
111778
- return {
111779
- status: "failed",
111780
- app,
111781
- fileCount: tarball.fileCount,
111782
- bytes: tarball.bytes,
111783
- error: {
111784
- title: "Daily deploy quota reached",
111785
- explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
111786
- suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/settings to raise this cap.`
111787
- },
111788
- recovery: recoveryAsk({
111789
- prompt: `Daily ${kind}-deploy quota reached (${used}/${limit}). Counter resets at ${resetAt}. Wait until then, deploy a ${otherKind} instead, or upgrade at https://percher.app/settings.`,
111790
- options: ["wait", `deploy ${otherKind}`, "upgrade"],
111791
- reasonCode: "quota_exceeded"
111792
- }),
111793
- summary: `Daily ${kind}-deploy quota reached (${used}/${limit}). Resets ${resetAt}.`,
112149
+ if (err instanceof PercherApiError && DEPLOY_GATE_CODES.has(err.code)) {
112150
+ const classified = classifyPublishApiError(err, {
111794
112151
  configPath: tomlPathFor2(ctx.cwd),
111795
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111796
- };
112152
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
112153
+ app
112154
+ });
112155
+ if (classified)
112156
+ return classified;
111797
112157
  }
111798
112158
  throw err;
111799
112159
  }
@@ -111891,7 +112251,7 @@ ${keyList}
111891
112251
  }
111892
112252
  const buildMs = Date.now() - buildStart;
111893
112253
  if (deployment.status === "replaced") {
111894
- return await buildReplacedResult({
112254
+ return await buildReplacedResult2({
111895
112255
  ctx,
111896
112256
  app,
111897
112257
  deployment,
@@ -112003,150 +112363,21 @@ async function handleUnauthorized(ctx, config2, tarball, input) {
112003
112363
  token: newToken,
112004
112364
  sessionId: ctx.client.sessionId
112005
112365
  });
112366
+ const t0 = Date.now();
112006
112367
  const freshTarball = await createTarball({ cwd: ctx.cwd, config: config2 });
112007
- let firstDeployRetry = false;
112008
- let app;
112009
- ({ app, firstDeploy: firstDeployRetry } = await ensureApp2(ctx, config2));
112010
- ctx.status("Uploading...");
112011
- const freshTarballBytes = new Uint8Array(await new Response(freshTarball.stream).arrayBuffer());
112012
- const freshIdempotencyKey = crypto.randomUUID();
112013
- let deployment;
112014
- try {
112015
- const { result: deployResponse } = await runDeployWithRetry({
112016
- call: (attempt) => ctx.client.apps.deploy(app.id, {
112017
- tarball: freshTarballBytes,
112018
- type: input.preview ? "preview" : undefined,
112019
- note: input.message,
112020
- noCache: input.noCache,
112021
- idempotencyKey: freshIdempotencyKey,
112022
- retriedAttempts: attempt - 1
112023
- }),
112024
- onRetry: ({ attempt, decision }) => {
112025
- ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
112026
- }
112027
- });
112028
- if (isDeployAlreadyInProgress(deployResponse)) {
112029
- return buildAlreadyInProgressResult({
112030
- active: deployResponse,
112031
- app,
112032
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
112033
- cwd: ctx.cwd
112034
- });
112035
- }
112036
- deployment = deployResponse;
112037
- } catch (err) {
112038
- if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
112039
- return buildRetryLimitReachedResult({
112040
- err,
112041
- app,
112042
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
112043
- cwd: ctx.cwd
112044
- });
112045
- }
112046
- throw err;
112047
- }
112048
- const replacedPreview = deployment.replacedPreview === true;
112049
- if (input.waitForLive === false) {
112050
- ctx.status(`Queued (${deployment.id}) — resume with percher_wait_for_deploy.`);
112051
- return {
112052
- status: "queued",
112053
- app,
112054
- deployment,
112055
- fileCount: freshTarball.fileCount,
112056
- bytes: freshTarball.bytes,
112057
- replacedPreview: replacedPreview || undefined,
112058
- firstDeploy: firstDeployRetry || undefined,
112059
- recovery: recoveryWait({
112060
- app: app.name,
112061
- deployId: deployment.id,
112062
- reasonCode: "deploy_queued"
112063
- }),
112064
- summary: `${app.name} deploy ${deployment.id} queued — resume with percher_wait_for_deploy.`,
112065
- configPath: tomlPathFor2(ctx.cwd),
112066
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes }
112067
- };
112068
- }
112069
- const timeoutMs = TIMEOUTS.clientPublishPoll;
112070
- const start = Date.now();
112071
- while (deployment.status !== "live" && deployment.status !== "failed" && deployment.status !== "replaced") {
112072
- if (Date.now() - start > timeoutMs) {
112073
- return {
112074
- status: "failed",
112075
- app,
112076
- deployment,
112077
- fileCount: freshTarball.fileCount,
112078
- bytes: freshTarball.bytes,
112079
- error: {
112080
- title: "Deploy timed out",
112081
- explanation: "The deployment did not complete within 5 minutes.",
112082
- suggestion: `Run \`percher doctor --app ${app.name}\` (CLI) or the percher_doctor tool (MCP) to diagnose the stall. Doctor will read deploy state + build log and return a concrete next step.`
112083
- },
112084
- recovery: recoveryDoctor({
112085
- app: app.name,
112086
- deployId: deployment.id,
112087
- mode: "deploy",
112088
- reasonCode: "deploy_stalled"
112089
- }),
112090
- summary: `Deploy timed out after 5 minutes (${deployment.id}).`,
112091
- configPath: tomlPathFor2(ctx.cwd),
112092
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes }
112093
- };
112094
- }
112095
- ctx.status(`${deployment.status}...`);
112096
- await new Promise((r) => setTimeout(r, 2000));
112097
- deployment = await ctx.client.apps.getDeployment(app.id, deployment.id);
112098
- }
112099
- if (deployment.status === "replaced") {
112100
- return await buildReplacedResult({
112101
- ctx,
112102
- app,
112103
- deployment,
112104
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
112105
- cwd: ctx.cwd
112106
- });
112107
- }
112108
- if (deployment.status === "failed") {
112109
- return buildFailureResult({
112110
- ctx,
112111
- app,
112112
- deployment,
112113
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
112114
- input
112115
- });
112116
- }
112117
- const totalSeconds = (Date.now() - start) / 1000;
112118
- const url2 = deployment.previewUrl ?? deployment.url ?? app.url;
112119
- ctx.status(`Live at ${url2} (${totalSeconds.toFixed(0)}s)`);
112120
- return {
112121
- status: "live",
112122
- url: url2,
112368
+ const packageMs = Date.now() - t0;
112369
+ const { app, firstDeploy } = await ensureApp2(ctx, config2);
112370
+ return executeDeploy({
112371
+ ctx,
112372
+ config: config2,
112373
+ input,
112123
112374
  app,
112124
- deployment,
112125
- timing: {
112126
- totalSeconds: Math.round(totalSeconds),
112127
- packageMs: 0,
112128
- uploadMs: 0,
112129
- buildMs: 0
112130
- },
112131
- fileCount: freshTarball.fileCount,
112132
- bytes: freshTarball.bytes,
112133
- replacedPreview,
112134
- firstDeploy: firstDeployRetry || undefined,
112135
- recovery: RECOVERY_NONE,
112136
- cacheHit: deployment.cacheHit,
112137
- traceId: deployment.id,
112138
- summary: buildLiveSummary({
112139
- appName: app.name,
112140
- url: url2 ?? app.url,
112141
- totalSeconds: Math.round(totalSeconds),
112142
- framework: config2.app.framework,
112143
- preview: input.preview === true,
112144
- replacedPreview,
112145
- cacheHit: deployment.cacheHit ?? false
112146
- }),
112147
- configPath: tomlPathFor2(ctx.cwd),
112148
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes }
112149
- };
112375
+ firstDeploy,
112376
+ tarball: freshTarball,
112377
+ t0,
112378
+ packageMs,
112379
+ missingBuildEnvKeys: []
112380
+ });
112150
112381
  }
112151
112382
  async function loadOrGenerate(ctx) {
112152
112383
  const tomlPath = join20(ctx.cwd, "percher.toml");
@@ -112207,7 +112438,6 @@ var pushInputSchema = exports_external.object({
112207
112438
  preview: exports_external.boolean().optional(),
112208
112439
  noCache: exports_external.boolean().optional().describe("Skip the image-cache lookup and force a fresh build.")
112209
112440
  });
112210
- var MAX_BYTES2 = 500 * 1024 * 1024;
112211
112441
  var SUSPICIOUS_BYTES2 = 50 * 1024 * 1024;
112212
112442
  var SUSPICIOUS_FILES2 = 1e4;
112213
112443
  var PLACEHOLDER_PREFIXES = ["add-your-", "your-key-here"];
@@ -112281,7 +112511,7 @@ async function push(ctx, input = {}, internals = {}) {
112281
112511
  await checkPlaceholders(ctx, config2);
112282
112512
  await checkByoSecrets(ctx, config2);
112283
112513
  const tarball = await createTarball({ cwd: ctx.cwd, config: config2 });
112284
- if (tarball.bytes > MAX_BYTES2) {
112514
+ if (tarball.bytes > MAX_TARBALL_BYTES) {
112285
112515
  throw new PercherCoreError(`Repository is larger than 500 MB (current: ${(tarball.bytes / 1024 / 1024).toFixed(1)} MB)`, {
112286
112516
  code: "push.too-large",
112287
112517
  hint: `Biggest files: ${tarball.topFiles.map((f) => f.path).join(", ")}`,
@@ -112436,10 +112666,10 @@ async function push(ctx, input = {}, internals = {}) {
112436
112666
  const resetAt = extra.resetAt ?? "";
112437
112667
  throw new PercherCoreError(`Daily ${kind}-deploy quota reached (${used}/${limit}). Resets at ${resetAt}.`, {
112438
112668
  code: "push.quota-exceeded",
112439
- hint: `Wait until ${resetAt} or upgrade your plan at https://percher.app/settings.`,
112669
+ hint: `Wait until ${resetAt} or upgrade your plan at https://percher.app/account/billing.`,
112440
112670
  recovery: {
112441
112671
  ...recoveryAsk({
112442
- prompt: `Daily ${kind}-deploy quota reached (${used}/${limit}). Counter resets at ${resetAt}. Wait until then or upgrade at https://percher.app/settings.`,
112672
+ prompt: `Daily ${kind}-deploy quota reached (${used}/${limit}). Counter resets at ${resetAt}. Wait until then or upgrade at https://percher.app/account/billing.`,
112443
112673
  reasonCode: "quota_exceeded"
112444
112674
  })
112445
112675
  }
@@ -112593,10 +112823,10 @@ async function redeploy(ctx, input = {}) {
112593
112823
  error: {
112594
112824
  title: "Daily deploy quota reached",
112595
112825
  explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
112596
- suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/settings to raise this cap.`
112826
+ suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/account/billing to raise this cap.`
112597
112827
  },
112598
112828
  recovery: recoveryAsk({
112599
- prompt: `Daily ${kind}-deploy quota reached (${used}/${limit}). Counter resets at ${resetAt}. Wait until then, deploy a ${otherKind} instead, or upgrade at https://percher.app/settings.`,
112829
+ prompt: `Daily ${kind}-deploy quota reached (${used}/${limit}). Counter resets at ${resetAt}. Wait until then, deploy a ${otherKind} instead, or upgrade at https://percher.app/account/billing.`,
112600
112830
  options: ["wait", `deploy ${otherKind}`, "upgrade"],
112601
112831
  reasonCode: "quota_exceeded"
112602
112832
  }),
@@ -112609,7 +112839,7 @@ async function redeploy(ctx, input = {}) {
112609
112839
  return failureResult({
112610
112840
  title: "Deploy rate limit reached",
112611
112841
  explanation: err.message,
112612
- suggestion: "Wait a minute or two and try again. Hourly limits scale with your plan; upgrade at https://percher.app/settings to raise it."
112842
+ suggestion: "Wait a minute or two and try again. Hourly limits scale with your plan; upgrade at https://percher.app/account/billing to raise it."
112613
112843
  });
112614
112844
  case "DEPLOY_RATE_LIMITED": {
112615
112845
  const extra = err.extra ?? {};
@@ -112836,6 +113066,8 @@ function failureResult(error48) {
112836
113066
  };
112837
113067
  }
112838
113068
  // ../core/dist/commands/rename.js
113069
+ import { existsSync as existsSync16, readFileSync as readFileSync12, writeFileSync as writeFileSync8 } from "node:fs";
113070
+ import { join as join22 } from "node:path";
112839
113071
  var renameInputSchema = exports_external.object({
112840
113072
  app: exports_external.string().optional(),
112841
113073
  name: exports_external.string().min(3).max(40)
@@ -112848,12 +113080,33 @@ async function rename(ctx, input) {
112848
113080
  hint: "Pass an app name or run from a directory with percher.toml."
112849
113081
  });
112850
113082
  }
112851
- return ctx.client.apps.rename(appRef, input.name);
113083
+ const result = await ctx.client.apps.rename(appRef, input.name);
113084
+ return {
113085
+ ...result,
113086
+ tomlUpdated: updateLocalTomlName(ctx.cwd, result.previousName, result.name)
113087
+ };
113088
+ }
113089
+ function updateLocalTomlName(cwd, oldName, newName) {
113090
+ const p = join22(cwd, "percher.toml");
113091
+ if (!existsSync16(p))
113092
+ return false;
113093
+ try {
113094
+ const src = readFileSync12(p, "utf8");
113095
+ if (parse5(src).app.name !== oldName)
113096
+ return false;
113097
+ const next = src.replace(new RegExp(`(name\\s*=\\s*")${oldName}(")`), `$1${newName}$2`);
113098
+ if (next === src || parse5(next).app.name !== newName)
113099
+ return false;
113100
+ writeFileSync8(p, next);
113101
+ return true;
113102
+ } catch {
113103
+ return false;
113104
+ }
112852
113105
  }
112853
113106
  // ../core/dist/commands/reproduce.js
112854
- import { mkdtempSync, writeFileSync as writeFileSync8 } from "node:fs";
113107
+ import { mkdtempSync, writeFileSync as writeFileSync9 } from "node:fs";
112855
113108
  import { tmpdir } from "node:os";
112856
- import { join as join22 } from "node:path";
113109
+ import { join as join23 } from "node:path";
112857
113110
  var reproduceInputSchema = exports_external.object({
112858
113111
  app: exports_external.string().optional().describe("App name. Defaults to the [app].name in the current directory's percher.toml."),
112859
113112
  deployId: exports_external.string().describe("Deploy id (e.g. dep_abc123) to reproduce. Required — usually the failed deploy you want to debug.")
@@ -112937,9 +113190,9 @@ async function reproduce(ctx, input) {
112937
113190
  const envMap = await ctx.client.env.list(app.id);
112938
113191
  currentAppEnvKeys = Object.keys(envMap).sort();
112939
113192
  } catch {}
112940
- const tmpDir = mkdtempSync(join22(tmpdir(), `percher-reproduce-${input.deployId}-`));
112941
- const tarballPath = join22(tmpDir, "source.tar.gz");
112942
- writeFileSync8(tarballPath, bytes);
113193
+ const tmpDir = mkdtempSync(join23(tmpdir(), `percher-reproduce-${input.deployId}-`));
113194
+ const tarballPath = join23(tmpDir, "source.tar.gz");
113195
+ writeFileSync9(tarballPath, bytes);
112943
113196
  let extracted = false;
112944
113197
  try {
112945
113198
  const proc = Bun.spawn(["tar", "-xzf", tarballPath, "-C", tmpDir], {
@@ -113177,7 +113430,7 @@ async function rollback(ctx, input, internals = {}) {
113177
113430
  }
113178
113431
  // ../core/dist/commands/sharing.js
113179
113432
  var ShareRoleSchema = exports_external.enum(["viewer", "admin"]);
113180
- function resolveApp3(ctx, app) {
113433
+ function resolveApp4(ctx, app) {
113181
113434
  const name = app ?? readPercherTomlAppName(ctx.cwd);
113182
113435
  if (!name) {
113183
113436
  throw new PercherCoreError("No app specified and no percher.toml found", {
@@ -113205,19 +113458,19 @@ var shareRevokeInputSchema = exports_external.object({
113205
113458
  shareId: exports_external.string().min(1, "shareId is required")
113206
113459
  });
113207
113460
  async function shareList(ctx, input = {}) {
113208
- const appRef = resolveApp3(ctx, input.app);
113461
+ const appRef = resolveApp4(ctx, input.app);
113209
113462
  return ctx.client.sharing.list(appRef);
113210
113463
  }
113211
113464
  async function shareInvite(ctx, input) {
113212
- const appRef = resolveApp3(ctx, input.app);
113465
+ const appRef = resolveApp4(ctx, input.app);
113213
113466
  return ctx.client.sharing.create(appRef, { email: input.email, role: input.role });
113214
113467
  }
113215
113468
  async function shareSetRole(ctx, input) {
113216
- const appRef = resolveApp3(ctx, input.app);
113469
+ const appRef = resolveApp4(ctx, input.app);
113217
113470
  return ctx.client.sharing.setRole(appRef, input.shareId, input.role);
113218
113471
  }
113219
113472
  async function shareRevoke(ctx, input) {
113220
- const appRef = resolveApp3(ctx, input.app);
113473
+ const appRef = resolveApp4(ctx, input.app);
113221
113474
  await ctx.client.sharing.revoke(appRef, input.shareId);
113222
113475
  }
113223
113476
  var invitesListInputSchema = exports_external.object({});
@@ -113283,7 +113536,7 @@ function summarize2(verdict, health) {
113283
113536
  return "API responded but reported a non-ok status";
113284
113537
  }
113285
113538
  // ../core/dist/commands/transfers.js
113286
- function resolveApp4(ctx, app) {
113539
+ function resolveApp5(ctx, app) {
113287
113540
  const name = app ?? readPercherTomlAppName(ctx.cwd);
113288
113541
  if (!name) {
113289
113542
  throw new PercherCoreError("No app specified and no percher.toml found", {
@@ -113305,15 +113558,15 @@ var transferListForAppInputSchema = exports_external.object({
113305
113558
  app: exports_external.string().optional()
113306
113559
  });
113307
113560
  async function transferInitiate(ctx, input) {
113308
- const appRef = resolveApp4(ctx, input.app);
113561
+ const appRef = resolveApp5(ctx, input.app);
113309
113562
  return ctx.client.transfers.initiate(appRef, { email: input.email });
113310
113563
  }
113311
113564
  async function transferCancel(ctx, input) {
113312
- const appRef = resolveApp4(ctx, input.app);
113565
+ const appRef = resolveApp5(ctx, input.app);
113313
113566
  await ctx.client.transfers.cancel(appRef, input.transferId);
113314
113567
  }
113315
113568
  async function transferListForApp(ctx, input = {}) {
113316
- const appRef = resolveApp4(ctx, input.app);
113569
+ const appRef = resolveApp5(ctx, input.app);
113317
113570
  return ctx.client.transfers.listForApp(appRef);
113318
113571
  }
113319
113572
  var transfersIncomingInputSchema = exports_external.object({});
@@ -114215,6 +114468,67 @@ function registerAppsCommands(cli, ctx) {
114215
114468
  const timing = result.timing ? ` (${result.timing.totalSeconds.toFixed(0)}s)` : "";
114216
114469
  await writeLine(ctx, `${import_picocolors4.default.green("✓")} Redeployed to ${result.url}${timing}`);
114217
114470
  });
114471
+ cli.command("wait-for-deploy <deployId>", "Wait for an in-flight deploy to finish").option("--app <name>", "App name (defaults to percher.toml app.name)").option("--timeout <seconds>", "Max seconds to wait (default: 600)").action(async (deployId, options) => {
114472
+ const appName = options.app ?? readPercherTomlAppName(ctx.cwd);
114473
+ if (!appName) {
114474
+ await writeLine(ctx, `${import_picocolors4.default.red("✗")} No app specified — pass --app <name> or run from a directory with percher.toml.`);
114475
+ throw new SilentExitError(1);
114476
+ }
114477
+ const timeoutSec = Number.parseInt(options.timeout ?? "600", 10);
114478
+ if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) {
114479
+ await writeLine(ctx, `${import_picocolors4.default.red("✗")} --timeout must be a positive number of seconds.`);
114480
+ throw new SilentExitError(1);
114481
+ }
114482
+ const deadline = Date.now() + timeoutSec * 1000;
114483
+ let lastStatus = "";
114484
+ for (;; ) {
114485
+ const remainingMs = deadline - Date.now();
114486
+ if (remainingMs <= 0) {
114487
+ await writeLine(ctx, `${import_picocolors4.default.yellow("!")} Still running after the timeout — re-run: percher wait-for-deploy ${deployId} --app ${appName}`);
114488
+ throw new SilentExitError(1);
114489
+ }
114490
+ let raceTimer;
114491
+ let result = null;
114492
+ const callController = new AbortController;
114493
+ try {
114494
+ result = await Promise.race([
114495
+ waitForDeploy(ctx, {
114496
+ app: appName,
114497
+ deployId,
114498
+ timeoutSeconds: Math.min(Math.ceil(remainingMs / 1000), 30)
114499
+ }, { signal: callController.signal }),
114500
+ new Promise((resolve7) => {
114501
+ raceTimer = setTimeout(() => resolve7(null), remainingMs);
114502
+ })
114503
+ ]);
114504
+ } finally {
114505
+ clearTimeout(raceTimer);
114506
+ }
114507
+ if (result === null) {
114508
+ callController.abort();
114509
+ await writeLine(ctx, `${import_picocolors4.default.yellow("!")} Still running after the timeout — re-run: percher wait-for-deploy ${deployId} --app ${appName}`);
114510
+ throw new SilentExitError(1);
114511
+ }
114512
+ if (result.deployment.status !== lastStatus) {
114513
+ lastStatus = result.deployment.status;
114514
+ await writeLine(ctx, import_picocolors4.default.dim(`${lastStatus}...`));
114515
+ }
114516
+ if (result.status === "live") {
114517
+ await writeLine(ctx, `${import_picocolors4.default.green("✓")} ${result.summary}`);
114518
+ return;
114519
+ }
114520
+ if (result.status === "failed" || result.status === "replaced") {
114521
+ const mark = result.status === "failed" ? import_picocolors4.default.red("✗") : import_picocolors4.default.yellow("!");
114522
+ await writeLine(ctx, `${mark} ${result.summary}`);
114523
+ if (result.error?.suggestion) {
114524
+ await writeLine(ctx, ` ${import_picocolors4.default.dim(result.error.suggestion)}`);
114525
+ }
114526
+ if (result.status === "failed")
114527
+ throw new SilentExitError(1);
114528
+ return;
114529
+ }
114530
+ }
114531
+ });
114218
114532
  cli.command("push", "Package and deploy this project").option("--dry-run", "Show what would be deployed, don't upload").option("--force", "Skip size warnings").option("--preview", "Deploy as preview (does not replace the live version)").option("--no-cache", "Skip image-cache lookup and force a fresh build (use when you suspect cached image is stale)").action(async (options) => {
114219
114533
  const result = await push(ctx, {
114220
114534
  dryRun: options.dryRun,
@@ -114245,6 +114559,11 @@ function registerAppsCommands(cli, ctx) {
114245
114559
  const result = await rename(ctx, { app, name });
114246
114560
  await writeLine(ctx, `${import_picocolors4.default.green("✓")} Renamed to ${result.name} — now live at ${result.url}`);
114247
114561
  await writeLine(ctx, ` Previous name: ${result.previousName} (reserved for 60 days)`);
114562
+ if (result.tomlUpdated) {
114563
+ await writeLine(ctx, ` percher.toml updated: name = "${result.name}"`);
114564
+ } else {
114565
+ await writeLine(ctx, ` ${import_picocolors4.default.yellow("!")} Update percher.toml before the next publish: name = "${result.name}"`);
114566
+ }
114248
114567
  await writeLine(ctx, ` ${import_picocolors4.default.dim(`Next rename available in ${result.cooldownDays} days`)}`);
114249
114568
  });
114250
114569
  cli.command("open [app]", "Open the app URL in your browser").action(async (app) => {
@@ -114306,6 +114625,17 @@ function registerAppsCommands(cli, ctx) {
114306
114625
  const result = await resume(ctx, { app });
114307
114626
  await writeLine(ctx, `${import_picocolors4.default.green("✓")} ${result.message}`);
114308
114627
  });
114628
+ cli.command("cache <action> [app]", "Manage the app's build cache (reset)").action(async (action, app) => {
114629
+ if (action !== "reset") {
114630
+ throw new Error(`Unknown cache action "${action}". Use: percher cache reset [app]`);
114631
+ }
114632
+ const result = await cacheReset(ctx, { app });
114633
+ await writeLine(ctx, `${import_picocolors4.default.green("✓")} ${result.message}`);
114634
+ });
114635
+ cli.command("cache reset [app]", "Reset the app's build cache (next deploy runs cold)").action(async (app) => {
114636
+ const result = await cacheReset(ctx, { app });
114637
+ await writeLine(ctx, `${import_picocolors4.default.green("✓")} ${result.message}`);
114638
+ });
114309
114639
  cli.command("doctor [app]", "Check app health and connectivity").option("--json", "Output as JSON").action(async (app, options) => {
114310
114640
  const result = await doctor(ctx, { app, json: options.json });
114311
114641
  if (options.json) {
@@ -114586,24 +114916,60 @@ function registerDeploysCommands(cli, ctx, argv) {
114586
114916
  await writeLine(ctx, `${v.sha} ${v.timestamp} ${message}`);
114587
114917
  }
114588
114918
  });
114589
- cli.command("preview <action> [deployId]", "Manage preview deploys (promote | discard)").option("--app <name>", "App name (overrides percher.toml)").action(async (action, deployId, options) => {
114919
+ cli.command("preview <action> [arg] [arg2]", "Preview deploys: promote <id> | discard <id> | branch set <branch> | branch unset | branch show").option("--app <name>", "App name (overrides percher.toml)").action(async (action, arg, arg2, options) => {
114920
+ if (action === "branch") {
114921
+ await runPreviewBranch(arg, arg2, options.app);
114922
+ return;
114923
+ }
114590
114924
  const appRef = options.app ?? readPercherTomlAppName(ctx.cwd);
114591
- if (!appRef)
114925
+ if (!appRef) {
114592
114926
  throw new Error("App name required. Use --app or run from a project directory.");
114927
+ }
114593
114928
  if (action === "promote") {
114594
- if (!deployId)
114929
+ if (!arg)
114595
114930
  throw new Error("Deploy ID required. Usage: percher preview promote <id>");
114596
- const result = await ctx.client.apps.promotePreview(appRef, deployId);
114931
+ const result = await ctx.client.apps.promotePreview(appRef, arg);
114597
114932
  await writeLine(ctx, `${import_picocolors7.default.green("✓")} Preview promoted to live. Status: ${result.status}`);
114598
114933
  } else if (action === "discard") {
114599
- if (!deployId)
114934
+ if (!arg)
114600
114935
  throw new Error("Deploy ID required. Usage: percher preview discard <id>");
114601
- await ctx.client.apps.discardPreview(appRef, deployId);
114936
+ await ctx.client.apps.discardPreview(appRef, arg);
114602
114937
  await writeLine(ctx, `${import_picocolors7.default.green("✓")} Preview discarded.`);
114603
114938
  } else {
114604
- throw new Error(`Unknown preview action "${action}". Use promote | discard.`);
114939
+ throw new Error(`Unknown preview action "${action}". Use promote | discard | branch.`);
114605
114940
  }
114606
114941
  });
114942
+ async function runPreviewBranch(sub, branch, app) {
114943
+ if (sub === "set") {
114944
+ if (!branch)
114945
+ throw new Error("Branch required. Usage: percher preview branch set <branch>");
114946
+ const r = await previewBranchSet(ctx, { app, branch });
114947
+ await writeLine(ctx, `${import_picocolors7.default.green("✓")} Preview branch set to "${branch}".`);
114948
+ if (r.previewUrl)
114949
+ await writeLine(ctx, ` Your preview address: ${import_picocolors7.default.bold(r.previewUrl)}`);
114950
+ await writeLine(ctx, r.status === "error" ? import_picocolors7.default.yellow(" ⚠ The initial deploy failed — push to the branch to retry.") : import_picocolors7.default.dim(" Building now — live within ~90s."));
114951
+ return;
114952
+ }
114953
+ if (sub === "unset") {
114954
+ await previewBranchUnset(ctx, { app });
114955
+ await writeLine(ctx, `${import_picocolors7.default.green("✓")} Preview branch cleared.`);
114956
+ return;
114957
+ }
114958
+ if (sub === "show" || sub === undefined) {
114959
+ const s = await previewBranchShow(ctx, { app });
114960
+ if (!s.previewBranch) {
114961
+ await writeLine(ctx, "No preview branch set.");
114962
+ await writeLine(ctx, import_picocolors7.default.dim(" Set one: percher preview branch set <branch>"));
114963
+ return;
114964
+ }
114965
+ await writeLine(ctx, `Preview branch: ${import_picocolors7.default.bold(s.previewBranch)}`);
114966
+ if (s.previewUrl)
114967
+ await writeLine(ctx, ` Address: ${s.previewUrl}`);
114968
+ await writeLine(ctx, ` Status: ${s.status}`);
114969
+ return;
114970
+ }
114971
+ throw new Error(`Unknown preview branch action "${sub}". Use set <branch> | unset | show.`);
114972
+ }
114607
114973
  }
114608
114974
 
114609
114975
  // src/commands/env.ts
@@ -115198,7 +115564,7 @@ async function renderError(stderr, error48) {
115198
115564
  // package.json
115199
115565
  var package_default = {
115200
115566
  name: "percher",
115201
- version: "0.4.2",
115567
+ version: "0.5.0",
115202
115568
  description: "CLI for the Percher hosting platform — publish apps with one command",
115203
115569
  type: "module",
115204
115570
  bin: {
@@ -115279,18 +115645,18 @@ async function showTelemetryNotice() {
115279
115645
  if (isDisabled())
115280
115646
  return;
115281
115647
  const { homedir: homedir2 } = await import("node:os");
115282
- const { existsSync: existsSync16, mkdirSync: mkdirSync6, writeFileSync: writeFileSync9 } = await import("node:fs");
115283
- const { join: join23 } = await import("node:path");
115284
- const configDir = join23(homedir2(), ".percher");
115285
- const markerFile = join23(configDir, ".telemetry-notice-shown");
115286
- if (existsSync16(markerFile))
115648
+ const { existsSync: existsSync17, mkdirSync: mkdirSync6, writeFileSync: writeFileSync10 } = await import("node:fs");
115649
+ const { join: join24 } = await import("node:path");
115650
+ const configDir = join24(homedir2(), ".percher");
115651
+ const markerFile = join24(configDir, ".telemetry-notice-shown");
115652
+ if (existsSync17(markerFile))
115287
115653
  return;
115288
115654
  console.log("Percher sends non-account-linked usage stats (command, result, error type, OS, Bun version).");
115289
115655
  console.log(`Disable with PERCHER_NO_TELEMETRY=1
115290
115656
  `);
115291
115657
  try {
115292
115658
  mkdirSync6(configDir, { recursive: true });
115293
- writeFileSync9(markerFile, new Date().toISOString());
115659
+ writeFileSync10(markerFile, new Date().toISOString());
115294
115660
  } catch {}
115295
115661
  }
115296
115662