percher 0.4.1 → 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.
@@ -8824,7 +8824,7 @@ var require_util = __commonJS((exports) => {
8824
8824
  }
8825
8825
  path = url2.path;
8826
8826
  }
8827
- var isAbsolute2 = exports.isAbsolute(path);
8827
+ var isAbsolute3 = exports.isAbsolute(path);
8828
8828
  var parts = path.split(/\/+/);
8829
8829
  for (var part, up = 0, i = parts.length - 1;i >= 0; i--) {
8830
8830
  part = parts[i];
@@ -8844,7 +8844,7 @@ var require_util = __commonJS((exports) => {
8844
8844
  }
8845
8845
  path = parts.join("/");
8846
8846
  if (path === "") {
8847
- path = isAbsolute2 ? "/" : ".";
8847
+ path = isAbsolute3 ? "/" : ".";
8848
8848
  }
8849
8849
  if (url2) {
8850
8850
  url2.path = path;
@@ -101395,7 +101395,7 @@ function finalize(ctx, schema) {
101395
101395
  result.$schema = "http://json-schema.org/draft-07/schema#";
101396
101396
  } else if (ctx.target === "draft-04") {
101397
101397
  result.$schema = "http://json-schema.org/draft-04/schema#";
101398
- } else if (ctx.target === "openapi-3.0") {} else {}
101398
+ } else if (ctx.target === "openapi-3.0") {}
101399
101399
  if (ctx.external?.uri) {
101400
101400
  const id = ctx.external.registry.get(schema)?.id;
101401
101401
  if (!id)
@@ -101643,7 +101643,7 @@ var literalProcessor = (schema, ctx, json, _params) => {
101643
101643
  if (val === undefined) {
101644
101644
  if (ctx.unrepresentable === "throw") {
101645
101645
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
101646
- } else {}
101646
+ }
101647
101647
  } else if (typeof val === "bigint") {
101648
101648
  if (ctx.unrepresentable === "throw") {
101649
101649
  throw new Error("BigInt literals cannot be represented in JSON Schema");
@@ -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();
@@ -104202,8 +104241,18 @@ var devSchema = exports_external.object({
104202
104241
  var requiredEnvSchema = exports_external.record(exports_external.string(), exports_external.enum(["secret", "url", "string"]));
104203
104242
  var cronSchema = exports_external.object({
104204
104243
  schedule: exports_external.string().min(1),
104205
- command: exports_external.string().min(1)
104244
+ command: exports_external.string().min(1),
104245
+ description: exports_external.string().max(200).optional()
104206
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
+ });
104207
104256
  var addonsSchema = exports_external.object({
104208
104257
  pocketbase: exports_external.object({ enabled: exports_external.boolean().optional() }).strict().optional(),
104209
104258
  cron: exports_external.object({ enabled: exports_external.boolean().optional() }).strict().optional(),
@@ -104228,7 +104277,7 @@ var percherConfigSchema = exports_external.object({
104228
104277
  resources: resourcesSchema.optional(),
104229
104278
  dev: devSchema.optional(),
104230
104279
  required_env: requiredEnvSchema.optional(),
104231
- crons: exports_external.record(exports_external.string(), cronSchema).optional(),
104280
+ crons: cronsSchema.optional(),
104232
104281
  addons: addonsSchema.optional(),
104233
104282
  checks: exports_external.record(exports_external.string(), checkSchema).optional()
104234
104283
  }).strict();
@@ -104899,6 +104948,20 @@ var billingUpgradeInputSchema = exports_external.object({
104899
104948
  plan: exports_external.enum(["starter", "maker", "pro"])
104900
104949
  });
104901
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
+ }
104902
104965
  // ../core/dist/commands/create.js
104903
104966
  import { join as join6, resolve as resolve4 } from "node:path";
104904
104967
 
@@ -104958,9 +105021,8 @@ function scaffoldTemplate(input) {
104958
105021
  }
104959
105022
 
104960
105023
  // ../core/dist/commands/create.js
104961
- var APP_NAME_RE2 = /^[a-z][a-z0-9-]{2,39}$/;
104962
105024
  var createInputSchema = exports_external.object({
104963
- 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"),
104964
105026
  template: exports_external.string().min(1, "Template is required"),
104965
105027
  directory: exports_external.string().optional(),
104966
105028
  force: exports_external.boolean().optional()
@@ -105015,6 +105077,7 @@ var dashboardInputSchema = exports_external.object({
105015
105077
  });
105016
105078
  var DEFAULT_BASE = "https://percher.app";
105017
105079
  var TAB_SHAPE = /^[a-z][a-z0-9-]{0,31}$/;
105080
+ var SETTINGS_ANCHOR_TABS = new Set(["github", "env", "domains", "crash-diagnosis"]);
105018
105081
  async function dashboard(ctx, input = {}) {
105019
105082
  const name = input.app ?? readPercherTomlAppName(ctx.cwd);
105020
105083
  if (!name) {
@@ -105031,7 +105094,8 @@ async function dashboard(ctx, input = {}) {
105031
105094
  });
105032
105095
  }
105033
105096
  const base = ctx.env.PERCHER_DASHBOARD_URL ?? DEFAULT_BASE;
105034
- 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}`;
105035
105099
  await ctx.openUrl(url2);
105036
105100
  return url2;
105037
105101
  }
@@ -105123,6 +105187,28 @@ async function deleteApp(ctx, input) {
105123
105187
  }
105124
105188
  await ctx.client.apps.delete(name);
105125
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
+ }
105126
105212
  // ../client/dist/build-env-prefixes.js
105127
105213
  var AUTO_FORWARD_PREFIXES = [
105128
105214
  "NEXT_PUBLIC_",
@@ -105195,6 +105281,21 @@ class PercherApiError extends Error {
105195
105281
  this.extra = options.extra;
105196
105282
  }
105197
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
+ }
105198
105299
  var RESTORE_JOB_STATUSES_FOR_ERROR = [
105199
105300
  "preparing",
105200
105301
  "quiescing",
@@ -105221,7 +105322,33 @@ function isRestoreJobStatus(v) {
105221
105322
  return typeof v === "string" && RESTORE_JOB_STATUSES_FOR_ERROR.includes(v);
105222
105323
  }
105223
105324
 
105325
+ class SunsetError extends PercherApiError {
105326
+ gateCode;
105327
+ sunsetDate;
105328
+ finalDeletionDate;
105329
+ dashboardUrl;
105330
+ constructor(message, options) {
105331
+ super(message, options);
105332
+ this.name = "SunsetError";
105333
+ this.gateCode = options.gateCode;
105334
+ this.sunsetDate = options.sunsetDate;
105335
+ this.finalDeletionDate = options.finalDeletionDate;
105336
+ this.dashboardUrl = options.dashboardUrl;
105337
+ }
105338
+ }
105339
+ function isSunsetGateBody(body) {
105340
+ if (typeof body !== "object" || body === null)
105341
+ return false;
105342
+ const b = body;
105343
+ return (b.error === "platform_read_only" || b.error === "platform_delete_gated") && typeof b.sunsetDate === "string" && typeof b.finalDeletionDate === "string" && typeof b.dashboardUrl === "string";
105344
+ }
105345
+
105224
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
+
105225
105352
  class Http {
105226
105353
  apiUrl;
105227
105354
  token;
@@ -105235,8 +105362,8 @@ class Http {
105235
105362
  this.clientVersion = options.clientVersion;
105236
105363
  this.sessionId = options.sessionId;
105237
105364
  }
105238
- get(path) {
105239
- return this.request("GET", path);
105365
+ get(path, opts) {
105366
+ return this.request("GET", path, undefined, opts?.signal);
105240
105367
  }
105241
105368
  post(path, body) {
105242
105369
  return this.request("POST", path, body);
@@ -105274,7 +105401,7 @@ class Http {
105274
105401
  try {
105275
105402
  res = await this.fetchImpl(`${this.apiUrl}${path}`, { method: "GET", headers });
105276
105403
  } catch (err) {
105277
- throw new Error(`network error: ${err.message}`, { cause: err });
105404
+ throw new NetworkError(err.message, { cause: err });
105278
105405
  }
105279
105406
  if (!res.ok)
105280
105407
  throw await this.parseError(res);
@@ -105300,15 +105427,15 @@ class Http {
105300
105427
  try {
105301
105428
  res = await this.fetchImpl(`${this.apiUrl}${path}`, init);
105302
105429
  } catch (err) {
105303
- throw new Error(`network error: ${err.message}`, { cause: err });
105430
+ throw new NetworkError(err.message, { cause: err });
105304
105431
  }
105305
105432
  if (!res.ok)
105306
105433
  throw await this.parseError(res);
105307
105434
  return await res.json();
105308
105435
  }
105309
- async request(method, path, body) {
105436
+ async request(method, path, body, signal) {
105310
105437
  const headers = this.buildHeaders();
105311
- const init = { method, headers };
105438
+ const init = { method, headers, signal };
105312
105439
  if (body !== undefined) {
105313
105440
  headers["Content-Type"] = "application/json";
105314
105441
  init.body = JSON.stringify(body);
@@ -105317,7 +105444,7 @@ class Http {
105317
105444
  try {
105318
105445
  res = await this.fetchImpl(`${this.apiUrl}${path}`, init);
105319
105446
  } catch (err) {
105320
- throw new Error(`network error: ${err.message}`, { cause: err });
105447
+ throw new NetworkError(err.message, { cause: err });
105321
105448
  }
105322
105449
  if (!res.ok)
105323
105450
  throw await this.parseError(res);
@@ -105328,7 +105455,10 @@ class Http {
105328
105455
  return await res.json();
105329
105456
  if (contentType.startsWith("text/"))
105330
105457
  return await res.text();
105331
- 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" });
105332
105462
  }
105333
105463
  buildHeaders() {
105334
105464
  const headers = {
@@ -105342,10 +105472,23 @@ class Http {
105342
105472
  return headers;
105343
105473
  }
105344
105474
  async parseError(res) {
105345
- let body = {};
105475
+ let raw;
105346
105476
  try {
105347
- body = await res.json();
105477
+ raw = await res.json();
105348
105478
  } catch {}
105479
+ if (res.status === 503 && isSunsetGateBody(raw)) {
105480
+ const requestIdHdr = res.headers.get("x-request-id") ?? undefined;
105481
+ return new SunsetError(raw.message, {
105482
+ status: res.status,
105483
+ code: raw.error,
105484
+ requestId: requestIdHdr,
105485
+ gateCode: raw.error,
105486
+ sunsetDate: raw.sunsetDate,
105487
+ finalDeletionDate: raw.finalDeletionDate,
105488
+ dashboardUrl: raw.dashboardUrl
105489
+ });
105490
+ }
105491
+ const body = typeof raw === "object" && raw !== null ? raw : {};
105349
105492
  const code = body.error?.code ?? (res.status >= 500 ? "INTERNAL_ERROR" : "VALIDATION_ERROR");
105350
105493
  const message = body.error?.message ?? res.statusText ?? "request failed";
105351
105494
  const extra = {};
@@ -105385,6 +105528,12 @@ class AccountResource {
105385
105528
  getDiagnostics() {
105386
105529
  return this.http.get("/account/diagnostics");
105387
105530
  }
105531
+ listTokens() {
105532
+ return this.http.get("/account/tokens");
105533
+ }
105534
+ revokeToken(id) {
105535
+ return this.http.delete(`/account/tokens/${encodeURIComponent(id)}`);
105536
+ }
105388
105537
  exportData() {
105389
105538
  return this.http.get("/account/export");
105390
105539
  }
@@ -105410,11 +105559,12 @@ class AlertsResource {
105410
105559
  }
105411
105560
  }
105412
105561
 
105413
- // ../client/dist/resources/apps.js
105562
+ // ../client/dist/resources/apps-types.js
105414
105563
  function isDeployAlreadyInProgress(res) {
105415
105564
  return res.status === "already_in_progress";
105416
105565
  }
105417
105566
 
105567
+ // ../client/dist/resources/apps.js
105418
105568
  class AppsResource {
105419
105569
  http;
105420
105570
  constructor(http) {
@@ -105426,8 +105576,11 @@ class AppsResource {
105426
105576
  list() {
105427
105577
  return this.http.get("/apps");
105428
105578
  }
105429
- get(id) {
105430
- return this.http.get(`/apps/${encodeURIComponent(id)}`);
105579
+ listLight() {
105580
+ return this.http.get("/apps?light=1");
105581
+ }
105582
+ get(id, opts) {
105583
+ return this.http.get(`/apps/${encodeURIComponent(id)}`, opts);
105431
105584
  }
105432
105585
  async deploy(id, input) {
105433
105586
  const params = new URLSearchParams;
@@ -105461,6 +105614,43 @@ class AppsResource {
105461
105614
  crashAnalysisEnabled: enabled
105462
105615
  });
105463
105616
  }
105617
+ setGalleryOptIn(appRef, enabled) {
105618
+ return this.http.put(`/apps/${encodeURIComponent(appRef)}/gallery-opt-in`, {
105619
+ enabled
105620
+ });
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
+ }
105628
+ setDescription(appRef, description) {
105629
+ return this.http.patch(`/apps/${encodeURIComponent(appRef)}/description`, {
105630
+ description
105631
+ });
105632
+ }
105633
+ setIconColour(appRef, colour) {
105634
+ return this.http.patch(`/apps/${encodeURIComponent(appRef)}/icon`, {
105635
+ colour
105636
+ });
105637
+ }
105638
+ uploadIconImage(appRef, bytes, mime) {
105639
+ const ab = new ArrayBuffer(bytes.byteLength);
105640
+ new Uint8Array(ab).set(bytes);
105641
+ return this.http.postBinary(`/apps/${encodeURIComponent(appRef)}/icon/image`, new Blob([ab]), { "Content-Type": mime });
105642
+ }
105643
+ deleteIconImage(appRef) {
105644
+ return this.http.deleteJson(`/apps/${encodeURIComponent(appRef)}/icon/image`);
105645
+ }
105646
+ uploadPreviewImage(appRef, bytes, mime) {
105647
+ const ab = new ArrayBuffer(bytes.byteLength);
105648
+ new Uint8Array(ab).set(bytes);
105649
+ return this.http.postBinary(`/apps/${encodeURIComponent(appRef)}/preview/image`, new Blob([ab]), { "Content-Type": mime });
105650
+ }
105651
+ deletePreviewImage(appRef) {
105652
+ return this.http.deleteJson(`/apps/${encodeURIComponent(appRef)}/preview/image`);
105653
+ }
105464
105654
  async downloadSource(appRef, deployId) {
105465
105655
  return this.http.getBinary(`/apps/${encodeURIComponent(appRef)}/deploys/${encodeURIComponent(deployId)}/source`);
105466
105656
  }
@@ -105473,8 +105663,8 @@ class AppsResource {
105473
105663
  discardPreview(appRef, deployId) {
105474
105664
  return this.http.post(`/apps/${encodeURIComponent(appRef)}/deploys/${encodeURIComponent(deployId)}/discard`, {});
105475
105665
  }
105476
- getDeployment(appRef, deployId) {
105477
- 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);
105478
105668
  }
105479
105669
  listDeploys(appRef, opts) {
105480
105670
  const params = new URLSearchParams;
@@ -105483,32 +105673,36 @@ class AppsResource {
105483
105673
  if (opts?.limit)
105484
105674
  params.set("limit", String(opts.limit));
105485
105675
  const qs = params.toString();
105486
- 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);
105487
105677
  }
105488
- async getBuildLog(appRef, deployId) {
105678
+ async getBuildLog(appRef, deployId, opts) {
105489
105679
  const path = `/apps/${encodeURIComponent(appRef)}/deploys/${encodeURIComponent(deployId)}/build-log`;
105490
105680
  let attempt = 0;
105491
105681
  const maxRetries = 5;
105492
105682
  while (true) {
105493
105683
  try {
105494
- const res = await this.http.get(path);
105684
+ const res = await this.http.get(path, opts);
105495
105685
  if (typeof res === "string")
105496
105686
  return res;
105497
105687
  return res?.buildLog ?? "";
105498
105688
  } catch (err) {
105499
105689
  attempt++;
105500
- const msg = err.message ?? String(err);
105501
- const isNetwork = msg.startsWith("network error:");
105502
- if (!isNetwork || attempt >= maxRetries)
105690
+ if (opts?.signal?.aborted || !isTransientNetworkError(err) || attempt >= maxRetries) {
105503
105691
  throw err;
105692
+ }
105504
105693
  const backoffMs = 1000 * 2 ** (attempt - 1);
105505
- await new Promise((r) => setTimeout(r, backoffMs));
105694
+ await abortableSleep(backoffMs, opts?.signal);
105695
+ if (opts?.signal?.aborted)
105696
+ throw err;
105506
105697
  }
105507
105698
  }
105508
105699
  }
105509
105700
  unsuspend(appRef) {
105510
105701
  return this.http.post(`/apps/${encodeURIComponent(appRef)}/unsuspend`, {});
105511
105702
  }
105703
+ resetCache(appRef) {
105704
+ return this.http.post(`/apps/${encodeURIComponent(appRef)}/cache/reset`, {});
105705
+ }
105512
105706
  delete(id) {
105513
105707
  return this.http.delete(`/apps/${encodeURIComponent(id)}`);
105514
105708
  }
@@ -105545,12 +105739,18 @@ class AppsResource {
105545
105739
  listJobs(appRef, limit = 20) {
105546
105740
  return this.http.get(`/apps/${encodeURIComponent(appRef)}/jobs?limit=${limit}`);
105547
105741
  }
105742
+ listCrons(appRef) {
105743
+ return this.http.get(`/apps/${encodeURIComponent(appRef)}/crons`);
105744
+ }
105548
105745
  getDiagnostics(appRef) {
105549
105746
  return this.http.get(`/apps/${encodeURIComponent(appRef)}/diagnostics`);
105550
105747
  }
105551
105748
  getTopology(appRef) {
105552
105749
  return this.http.get(`/apps/${encodeURIComponent(appRef)}/topology`);
105553
105750
  }
105751
+ getPocketBaseVolume(appRef) {
105752
+ return this.http.get(`/apps/${encodeURIComponent(appRef)}/pocketbase/volume`);
105753
+ }
105554
105754
  getResources(appRef, opts = {}) {
105555
105755
  const params = new URLSearchParams;
105556
105756
  if (opts.window)
@@ -105712,6 +105912,40 @@ class EnvResource {
105712
105912
  }
105713
105913
  }
105714
105914
 
105915
+ // ../client/dist/resources/env-scan.js
105916
+ class EnvScanResource {
105917
+ http;
105918
+ constructor(http) {
105919
+ this.http = http;
105920
+ }
105921
+ scan(appId) {
105922
+ return this.http.post(`/apps/${encodeURIComponent(appId)}/env-scan`);
105923
+ }
105924
+ }
105925
+
105926
+ // ../client/dist/resources/export.js
105927
+ class ExportResource {
105928
+ http;
105929
+ constructor(http) {
105930
+ this.http = http;
105931
+ }
105932
+ mint(appRef, options) {
105933
+ return this.http.post(`/apps/${encodeURIComponent(appRef)}/export/mint`, {
105934
+ deliveryKind: options?.deliveryKind
105935
+ });
105936
+ }
105937
+ revoke(appRef, tokenId) {
105938
+ return this.http.post(`/apps/${encodeURIComponent(appRef)}/export/tokens/${encodeURIComponent(tokenId)}/revoke`);
105939
+ }
105940
+ history(appRef) {
105941
+ return this.http.get(`/apps/${encodeURIComponent(appRef)}/export/history`);
105942
+ }
105943
+ async download(downloadUrl) {
105944
+ const path = downloadUrl.startsWith("http") ? new URL(downloadUrl).pathname + new URL(downloadUrl).search : downloadUrl;
105945
+ return this.http.stream(path);
105946
+ }
105947
+ }
105948
+
105715
105949
  // ../client/dist/sse.js
105716
105950
  async function* parseSseStream(stream, signal) {
105717
105951
  const reader = stream.getReader();
@@ -105859,6 +106093,46 @@ class PlatformResource {
105859
106093
  }
105860
106094
  }
105861
106095
 
106096
+ // ../client/dist/resources/search.js
106097
+ class SearchResource {
106098
+ http;
106099
+ constructor(http) {
106100
+ this.http = http;
106101
+ }
106102
+ query(q) {
106103
+ return this.http.get(`/search?q=${encodeURIComponent(q)}`);
106104
+ }
106105
+ }
106106
+
106107
+ // ../client/dist/resources/security.js
106108
+ class SecurityResource {
106109
+ http;
106110
+ constructor(http) {
106111
+ this.http = http;
106112
+ }
106113
+ forApp(appRef) {
106114
+ return this.http.get(`/apps/${encodeURIComponent(appRef)}/security`);
106115
+ }
106116
+ forAppAsAdmin(appRef) {
106117
+ return this.http.get(`/admin/security/apps/${encodeURIComponent(appRef)}`);
106118
+ }
106119
+ resendNotificationAsAdmin(appRef) {
106120
+ return this.http.post(`/admin/security/apps/${encodeURIComponent(appRef)}/notify`, {});
106121
+ }
106122
+ overview() {
106123
+ return this.http.get("/admin/security/overview");
106124
+ }
106125
+ platformBaseOverview() {
106126
+ return this.http.get("/admin/security/platform-base");
106127
+ }
106128
+ rescanAllAsAdmin() {
106129
+ return this.http.post("/admin/security/rescan", {});
106130
+ }
106131
+ rescanAppAsAdmin(appRef) {
106132
+ return this.http.post(`/admin/security/apps/${encodeURIComponent(appRef)}/rescan`, {});
106133
+ }
106134
+ }
106135
+
105862
106136
  // ../client/dist/resources/sharing.js
105863
106137
  class SharingResource {
105864
106138
  http;
@@ -105929,12 +106203,16 @@ class PercherClient {
105929
106203
  apps;
105930
106204
  data;
105931
106205
  env;
106206
+ envScan;
106207
+ export;
105932
106208
  logs;
105933
106209
  auth;
105934
106210
  billing;
105935
106211
  account;
105936
106212
  alerts;
105937
106213
  platform;
106214
+ security;
106215
+ search;
105938
106216
  sharing;
105939
106217
  invites;
105940
106218
  transfers;
@@ -105953,12 +106231,16 @@ class PercherClient {
105953
106231
  this.apps = new AppsResource(http);
105954
106232
  this.data = new DataResource(http);
105955
106233
  this.env = new EnvResource(http);
106234
+ this.envScan = new EnvScanResource(http);
106235
+ this.export = new ExportResource(http);
105956
106236
  this.logs = new LogsResource(http);
105957
106237
  this.auth = new AuthResource(http);
105958
106238
  this.billing = new BillingResource(http);
105959
106239
  this.account = new AccountResource(http);
105960
106240
  this.alerts = new AlertsResource(http);
105961
106241
  this.platform = new PlatformResource(http);
106242
+ this.security = new SecurityResource(http);
106243
+ this.search = new SearchResource(http);
105962
106244
  this.sharing = new SharingResource(http);
105963
106245
  this.invites = new InvitesResource(http);
105964
106246
  this.transfers = new TransfersResource(http);
@@ -106032,17 +106314,6 @@ async function deploysInspect(ctx, input) {
106032
106314
  import { existsSync as existsSync7 } from "node:fs";
106033
106315
  import { join as join10 } from "node:path";
106034
106316
 
106035
- // ../shared/dist/timeouts.js
106036
- var TIMEOUTS = {
106037
- workerBuildSubprocess: 10 * 60000,
106038
- apiToWorkerBuildFetch: 11 * 60000,
106039
- clientPublishPoll: 12 * 60000,
106040
- workerHealthcheck: 5000,
106041
- watchdogList: 15000,
106042
- mcpWaitDeployDefault: 30000,
106043
- mcpWaitDeployMax: 12 * 60000
106044
- };
106045
-
106046
106317
  // ../core/dist/tarball.js
106047
106318
  var import_ignore = __toESM(require_ignore(), 1);
106048
106319
  import { createHash as createHash2 } from "node:crypto";
@@ -106418,6 +106689,19 @@ var patterns = [
106418
106689
  missingEnvVars: []
106419
106690
  }
106420
106691
  },
106692
+ {
106693
+ pattern: /Bad Gateway|HTTP 502|\b502\b/i,
106694
+ result: {
106695
+ title: "Gateway error (transient)",
106696
+ explanation: "A gateway in front of your app returned 502 Bad Gateway while the platform was routing the deploy. This is a transient platform-side hiccup, not your app code.",
106697
+ suggestion: "Wait 30 seconds and run percher publish again. A single retry almost always lands cleanly. If 502s persist across several spaced-out retries, check status.percher.app — that's a platform incident, not your app.",
106698
+ errorClass: "infra_unavailable",
106699
+ phase: "infra",
106700
+ cause: "bad_gateway",
106701
+ relevantFiles: [],
106702
+ missingEnvVars: []
106703
+ }
106704
+ },
106421
106705
  {
106422
106706
  pattern: /start command could not be found|no start command/i,
106423
106707
  result: {
@@ -106536,7 +106820,20 @@ var patterns = [
106536
106820
  }
106537
106821
  },
106538
106822
  {
106539
- pattern: /POCKETBASE|pocketbase.*error|pocketbase.*fail/i,
106823
+ pattern: /committed_budget|\bbox admission|platform (?:is )?at capacity/i,
106824
+ result: {
106825
+ title: "Platform at capacity",
106826
+ explanation: "The platform couldn't fit this deploy right now — the box is full, so the box-admission gate refused to start the container. This is a platform-side limit, not a problem with your app code or configuration.",
106827
+ suggestion: "Wait a few minutes and run percher publish again — capacity frees up as other deploys finish. This failure does not count against your app, and a retry usually lands once there's room. If it persists, contact the operator.",
106828
+ errorClass: "platform_at_capacity",
106829
+ phase: "infra",
106830
+ cause: "box_admission_rejected",
106831
+ relevantFiles: [],
106832
+ missingEnvVars: []
106833
+ }
106834
+ },
106835
+ {
106836
+ pattern: /pocketbase[^\n]{0,40}(?:error|fail|crash|panic|exit|unhealthy|could ?not|cannot|connection refused|timed out|not (?:start|ready|reachable))|(?:failed to (?:start|connect|reach)|error (?:starting|connecting to)|unable to (?:start|reach|connect to))[^\n]{0,40}pocketbase/i,
106540
106837
  result: {
106541
106838
  title: "PocketBase error",
106542
106839
  explanation: "PocketBase could not start or connect properly.",
@@ -106819,6 +107116,12 @@ function recoveryFromErrorClass(errorClass, failure = {}) {
106819
107116
  args: failure.publishInput ?? {},
106820
107117
  reasonCode: "infra_unavailable"
106821
107118
  });
107119
+ case "platform_at_capacity":
107120
+ return recoveryRetry({
107121
+ suggestedTool: "percher_publish",
107122
+ args: failure.publishInput ?? {},
107123
+ reasonCode: "infra_transient"
107124
+ });
106822
107125
  case "config_invalid":
106823
107126
  return recoveryFixConfig({
106824
107127
  problems: [
@@ -106967,15 +107270,17 @@ function trimMessage(raw) {
106967
107270
  function tomlPathFor(cwd) {
106968
107271
  return join11(cwd, "percher.toml");
106969
107272
  }
106970
- async function buildFailureError(ctx, app, deployment) {
107273
+ async function buildFailureError(ctx, app, deployment, signal) {
106971
107274
  let buildLogTail;
106972
107275
  let buildLogFetchFailed = false;
106973
107276
  let fullLog = "";
106974
107277
  try {
106975
- fullLog = await ctx.client.apps.getBuildLog(app.id, deployment.id);
106976
- if (!fullLog) {
106977
- await new Promise((r) => setTimeout(r, 1500));
106978
- 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
+ }
106979
107284
  }
106980
107285
  if (fullLog) {
106981
107286
  const lines = fullLog.split(`
@@ -106987,7 +107292,7 @@ async function buildFailureError(ctx, app, deployment) {
106987
107292
  buildLogFetchFailed = true;
106988
107293
  }
106989
107294
  const errorMessage = deployment.errorMessage ?? "";
106990
- const classified = classifyByErrorCode(deployment.errorCode, errorMessage) ?? classifyError("Deployment failed", fullLog) ?? classifyError(errorMessage, "");
107295
+ const classified = classifyByErrorCode(deployment.errorCode, errorMessage) ?? classifyError(errorMessage || "Deployment failed", fullLog);
106991
107296
  const rawErrorMessage = errorMessage && !buildLogTail && !classified?.explanation.includes(errorMessage) ? errorMessage : undefined;
106992
107297
  const problems = deployment.problems && deployment.problems.length > 0 ? deployment.problems : undefined;
106993
107298
  if (classified) {
@@ -107021,7 +107326,7 @@ async function buildFailureError(ctx, app, deployment) {
107021
107326
  }
107022
107327
  async function classifyDeploymentFailure(opts) {
107023
107328
  const { ctx, app, deployment, publishInput } = opts;
107024
- const error48 = await buildFailureError(ctx, app, deployment);
107329
+ const error48 = await buildFailureError(ctx, app, deployment, opts.signal);
107025
107330
  const recovery = error48.errorClass ? recoveryFromErrorClass(error48.errorClass, {
107026
107331
  missingEnvVars: error48.missingEnvVars,
107027
107332
  deployId: deployment.id,
@@ -107067,6 +107372,100 @@ var waitForDeployInputSchema = exports_external.object({
107067
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.`),
107068
107373
  pollIntervalSeconds: exports_external.number().int().min(1).max(30).optional().describe("Seconds between polls (default 2).")
107069
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
+ }
107070
107469
  async function resolveReplaced(opts) {
107071
107470
  const { ctx, app, replacedDeployment } = opts;
107072
107471
  const deployType = replacedDeployment.type ?? "live";
@@ -107074,7 +107473,8 @@ async function resolveReplaced(opts) {
107074
107473
  try {
107075
107474
  const deploys = await ctx.client.apps.listDeploys(app.name, {
107076
107475
  limit: 5,
107077
- type: deployType
107476
+ type: deployType,
107477
+ signal: opts.signal
107078
107478
  });
107079
107479
  latest = deploys.find((d) => d.id !== replacedDeployment.id);
107080
107480
  } catch {
@@ -107121,6 +107521,46 @@ async function resolveReplaced(opts) {
107121
107521
  summary: `${app.name} deploy ${replacedDeployment.id} was replaced; the latest deploy ${latest.id} is ${latest.status}. Surface to user.`
107122
107522
  };
107123
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
+ }
107124
107564
 
107125
107565
  // ../core/dist/commands/doctor.js
107126
107566
  var doctorInputSchema = exports_external.object({
@@ -107248,17 +107688,19 @@ async function doctor(ctx, input = {}) {
107248
107688
  message: diag.container.running ? "Running" : diag.container.state === "not-found" ? "No container found" : `State: ${diag.container.state}`
107249
107689
  });
107250
107690
  if (diag.containerHealth) {
107691
+ const latency = diag.containerHealth.latencyMs != null ? ` (${diag.containerHealth.latencyMs}ms)` : "";
107251
107692
  checks3.push({
107252
107693
  name: "Container health",
107253
107694
  status: diag.containerHealth.healthy ? "pass" : "fail",
107254
- 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`
107255
107696
  });
107256
107697
  }
107257
107698
  if (diag.publicRoute) {
107699
+ const latency = diag.publicRoute.latencyMs != null ? ` (${diag.publicRoute.latencyMs}ms)` : "";
107258
107700
  checks3.push({
107259
107701
  name: "Public route",
107260
107702
  status: diag.publicRoute.healthy ? "pass" : "fail",
107261
- 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`
107262
107704
  });
107263
107705
  }
107264
107706
  if (diag.pocketbase) {
@@ -107444,11 +107886,53 @@ function summarize(checks3, vctx = {}) {
107444
107886
  summary: verdict.summary
107445
107887
  };
107446
107888
  }
107447
- function deriveVerdict(checks3, vctx) {
107889
+ function buildVerdictRuleContext(checks3, vctx) {
107448
107890
  const findCheck = (name) => checks3.find((c) => c.name === name);
107449
- const auth = findCheck("Auth token");
107450
- 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");
107451
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: () => ({
107452
107936
  status: "blocked",
107453
107937
  diagnosis: {
107454
107938
  title: "Authentication required",
@@ -107458,150 +107942,188 @@ function deriveVerdict(checks3, vctx) {
107458
107942
  },
107459
107943
  recovery: recoveryLogin({ reasonCode: "auth_required" }),
107460
107944
  summary: "Login required: run `percher login` (or set PERCHER_TOKEN)."
107461
- };
107462
- }
107463
- const api2 = findCheck("API reachability");
107464
- if (api2?.status === "fail") {
107465
- return {
107466
- status: "blocked",
107467
- diagnosis: {
107468
- title: "Percher API is unreachable",
107469
- explanation: api2.message,
107470
- reasonCode: "infra_unavailable",
107471
- phase: "infra"
107472
- },
107473
- recovery: recoveryAsk({
107474
- 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.`,
107475
- reasonCode: "infra_unavailable",
107476
- retryable: true
107477
- }),
107478
- summary: "Cannot reach the Percher API — try again in a moment."
107479
- };
107480
- }
107481
- const toml = findCheck("percher.toml");
107482
- if (toml?.status === "fail" && !vctx.appProvided) {
107483
- return {
107484
- status: "blocked",
107485
- diagnosis: {
107486
- title: "Invalid percher.toml",
107487
- explanation: toml.message,
107488
- reasonCode: "config_invalid",
107489
- phase: "config"
107490
- },
107491
- recovery: recoveryFixConfig({
107492
- problems: [
107493
- {
107494
- file: "percher.toml",
107495
- message: toml.message
107496
- }
107497
- ],
107498
- reasonCode: "config_invalid"
107499
- }),
107500
- summary: "percher.toml is invalid — fix the issues and re-run."
107501
- };
107502
- }
107503
- const app = findCheck("App");
107504
- if (app?.status === "skip") {
107505
- return {
107506
- status: "blocked",
107507
- diagnosis: {
107508
- title: "No app specified",
107509
- explanation: app.message,
107510
- reasonCode: "config_missing",
107511
- phase: "config"
107512
- },
107513
- recovery: recoveryAsk({
107514
- prompt: `${app.message} — pass \`--app <name>\` or run \`percher init\` in a project directory to generate a percher.toml.`,
107515
- reasonCode: "config_missing"
107516
- }),
107517
- summary: "No percher.toml found and no --app supplied — pass --app or run percher init."
107518
- };
107519
- }
107520
- const inTransitionalDeploy = !!vctx.lastDeployStatus && TRANSITIONAL_DEPLOY_STATUSES.has(vctx.lastDeployStatus);
107521
- const inTransitionalApp = !!vctx.appStatus && TRANSITIONAL_APP_STATUSES.has(vctx.appStatus);
107522
- if (inTransitionalDeploy || inTransitionalApp) {
107523
- const reasonCode = vctx.lastDeployStatus === "queued" ? "deploy_queued" : vctx.lastDeployStatus === "building" ? "deploy_building" : vctx.lastDeployStatus === "deploying" ? "deploy_deploying" : "deploy_queued";
107524
- const what = inTransitionalApp ? `App ${vctx.appName ?? ""} is provisioning` : `Last deploy is ${vctx.lastDeployStatus}`;
107525
- const recovery = vctx.lastDeployId && vctx.appName ? recoveryWait({
107526
- app: vctx.appName,
107527
- deployId: vctx.lastDeployId,
107528
- reasonCode
107529
- }) : recoveryAsk({
107530
- prompt: `${what.trim()} for ${vctx.appName ?? "this app"}. Wait for it to finish before retrying — there's no deployId to track yet.`,
107531
- reasonCode
107532
- });
107533
- return {
107534
- status: "in_progress",
107535
- diagnosis: {
107536
- title: what.trim(),
107537
- explanation: vctx.lastDeployId ? `${what.trim()} (deploy ${vctx.lastDeployId}). Wait for it to finish before retrying.` : `${what.trim()}. Wait for it to finish before retrying.`,
107538
- reasonCode,
107539
- phase: "deploy"
107540
- },
107541
- recovery,
107542
- summary: vctx.lastDeployId ? `${what.trim()} — wait for deploy ${vctx.lastDeployId}.` : `${what.trim()} — wait a moment and re-run.`
107543
- };
107544
- }
107545
- const verdictChecks = vctx.appProvided && toml?.status === "fail" ? checks3.filter((c) => c.name !== "percher.toml") : checks3;
107546
- if (vctx.replacedResolution) {
107547
- const r = vctx.replacedResolution;
107548
- const isResolvedLive = r.recovery.nextAction === "none" && !!r.url;
107549
- 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 ?? "";
107550
107952
  return {
107551
- status: "ok",
107552
- recovery: { ...r.recovery, reasonCode: "replaced_by_newer" },
107553
- 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."
107554
107966
  };
107555
107967
  }
107556
- const status = r.recovery.nextAction === "wait_deploy" ? "in_progress" : "needs_action";
107557
- return {
107558
- status,
107559
- diagnosis: {
107560
- title: "Last deploy was replaced",
107561
- explanation: r.summary,
107562
- reasonCode: "replaced_by_newer",
107563
- phase: "deploy"
107564
- },
107565
- recovery: { ...r.recovery, reasonCode: "replaced_by_newer" },
107566
- summary: r.summary
107567
- };
107568
- }
107569
- if (vctx.lastDeployStatus === "failed") {
107570
- if (vctx.inputMode === "deploy") {
107571
- const cls = vctx.buildLogClassification;
107572
- if (cls && cls.errorClass === "missing_env" && cls.missingEnvVars.length > 0) {
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 ?? "";
107974
+ return {
107975
+ status: "blocked",
107976
+ diagnosis: {
107977
+ title: "Invalid percher.toml",
107978
+ explanation: message,
107979
+ reasonCode: "config_invalid",
107980
+ phase: "config"
107981
+ },
107982
+ recovery: recoveryFixConfig({
107983
+ problems: [
107984
+ {
107985
+ file: "percher.toml",
107986
+ message
107987
+ }
107988
+ ],
107989
+ reasonCode: "config_invalid"
107990
+ }),
107991
+ summary: "percher.toml is invalid — fix the issues and re-run."
107992
+ };
107993
+ }
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) {
107573
108053
  return {
107574
- status: "needs_action",
107575
- diagnosis: {
107576
- title: cls.title,
107577
- explanation: cls.explanation,
107578
- reasonCode: "missing_env",
107579
- phase: "build"
107580
- },
107581
- recovery: recoveryEnv({
107582
- app: vctx.appName,
107583
- keys: cls.missingEnvVars
107584
- }),
107585
- summary: vctx.appName ? `Build failed — missing env vars on ${vctx.appName}: ${cls.missingEnvVars.join(", ")}.` : `Build failed — missing env vars: ${cls.missingEnvVars.join(", ")}.`
108054
+ status: "ok",
108055
+ recovery: { ...r.recovery, reasonCode: "replaced_by_newer" },
108056
+ summary: r.summary
107586
108057
  };
107587
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;
107588
108105
  const fileProblems = (vctx.buildProblems ?? []).filter((p) => p.file).map(buildProblemToRecoveryProblem);
107589
- if (fileProblems.length > 0) {
107590
- return {
107591
- status: "needs_action",
107592
- diagnosis: {
107593
- title: cls?.title ?? "Build failed with file-located problems",
107594
- explanation: cls?.explanation ?? `Build extracted ${fileProblems.length} structured problem${fileProblems.length === 1 ? "" : "s"} with file locations. Patch the files directly.`,
107595
- reasonCode: "build_failed",
107596
- phase: "build"
107597
- },
107598
- recovery: recoveryFixProblems({
107599
- problems: fileProblems,
107600
- reasonCode: "build_failed"
107601
- }),
107602
- 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.`
107603
- };
107604
- }
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;
107605
108127
  return {
107606
108128
  status: "needs_action",
107607
108129
  diagnosis: {
@@ -107618,7 +108140,11 @@ function deriveVerdict(checks3, vctx) {
107618
108140
  summary: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} failed — inspect the build log.` : "Last deploy failed — inspect the latest failed deploy's build log."
107619
108141
  };
107620
108142
  }
107621
- return {
108143
+ },
108144
+ {
108145
+ id: "deploy-failed-dispatch",
108146
+ when: ({ vctx }) => vctx.lastDeployStatus === "failed",
108147
+ verdict: ({ vctx }) => ({
107622
108148
  status: "needs_action",
107623
108149
  diagnosis: {
107624
108150
  title: "Last deploy failed",
@@ -107638,10 +108164,12 @@ function deriveVerdict(checks3, vctx) {
107638
108164
  reasonCode: "build_failed"
107639
108165
  },
107640
108166
  summary: vctx.lastDeployId ? `Deploy ${vctx.lastDeployId} failed — call percher_doctor with mode='deploy'.` : "Last deploy failed — call percher_doctor with mode='deploy'."
107641
- };
107642
- }
107643
- if (vctx.appStatus === "suspended") {
107644
- return {
108167
+ })
108168
+ },
108169
+ {
108170
+ id: "app-suspended",
108171
+ when: ({ vctx }) => vctx.appStatus === "suspended",
108172
+ verdict: ({ vctx }) => ({
107645
108173
  status: "needs_action",
107646
108174
  diagnosis: {
107647
108175
  title: "App is suspended",
@@ -107654,33 +108182,27 @@ function deriveVerdict(checks3, vctx) {
107654
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."
107655
108183
  }),
107656
108184
  summary: vctx.appName ? `${vctx.appName} is suspended — resume the app before retrying.` : "App is suspended — resume it before retrying."
107657
- };
107658
- }
107659
- if (verdictChecks.every((c) => c.status === "pass" || c.status === "skip" || c.status === "warn")) {
107660
- const passing = verdictChecks.filter((c) => c.status === "pass").length;
107661
- const noun = passing === 1 ? "check" : "checks";
107662
- return {
107663
- status: "ok",
107664
- recovery: recoveryNone({ reasonCode: "none" }),
107665
- summary: vctx.appName ? `All ${passing} ${noun} passed for ${vctx.appName}.` : `All ${passing} ${noun} passed.`
107666
- };
107667
- }
107668
- const containerDown = vctx.containerRunning === false && vctx.containerState !== undefined && vctx.containerState !== "not-found" || vctx.containerHealthy === false;
107669
- if (vctx.appStatus === "crashed" || containerDown) {
107670
- const containerCheck = findCheck("Container");
107671
- const healthCheck = findCheck("Container health");
107672
- const lastCrashCheck = findCheck("Last crash");
107673
- const explanationBits = [];
107674
- if (vctx.appStatus === "crashed")
107675
- explanationBits.push("App is in crashed state.");
107676
- if (containerCheck?.status === "fail")
107677
- explanationBits.push(containerCheck.message);
107678
- if (healthCheck?.status === "fail")
107679
- explanationBits.push(healthCheck.message);
107680
- if (lastCrashCheck)
107681
- explanationBits.push(lastCrashCheck.message);
107682
- const explanation = explanationBits.length > 0 ? explanationBits.join(" ") : "Runtime is not responding — container or health check is failing.";
107683
- 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);
107684
108206
  const cr = vctx.crashReport;
107685
108207
  const hasAnalysis = cr && cr.analysisStatus === "completed";
107686
108208
  const promptParts = [];
@@ -107717,58 +108239,62 @@ ${cr.logs.join(`
107717
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."
107718
108240
  };
107719
108241
  }
107720
- return {
107721
- status: "needs_action",
107722
- diagnosis: {
107723
- title: vctx.appStatus === "crashed" ? "App crashed" : "Runtime not responding",
107724
- explanation,
107725
- reasonCode: "runtime_crashed",
107726
- phase: "runtime"
107727
- },
107728
- recovery: {
107729
- retryable: false,
107730
- nextAction: "run_doctor",
107731
- suggestedTool: "percher_doctor",
107732
- args: { app: vctx.appName, mode: "runtime" },
107733
- reasonCode: "runtime_crashed"
107734
- },
107735
- summary: vctx.appName ? `${vctx.appName} runtime needs investigation — call percher_doctor with mode='runtime'.` : "Runtime needs investigation — call percher_doctor with mode='runtime'."
107736
- };
107737
- }
107738
- if (vctx.publicRouteHealthy === false) {
107739
- const routeCheck = findCheck("Public route");
107740
- return {
107741
- status: "needs_action",
107742
- diagnosis: {
107743
- title: "Public route is not responding",
107744
- explanation: routeCheck?.message ?? "Container looks healthy but the public URL isn't responding — route reconcile usually self-heals.",
107745
- reasonCode: "infra_transient",
107746
- phase: "infra"
107747
- },
107748
- recovery: {
107749
- retryable: true,
107750
- nextAction: "retry",
107751
- suggestedTool: "percher_doctor",
107752
- args: { app: vctx.appName },
107753
- reasonCode: "infra_transient"
107754
- },
107755
- 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."
107756
- };
107757
- }
107758
- const firstFailed = verdictChecks.find((c) => c.status === "fail");
107759
- return {
107760
- status: "needs_action",
107761
- diagnosis: firstFailed ? {
107762
- title: firstFailed.name,
107763
- explanation: firstFailed.message,
107764
- reasonCode: "unknown"
107765
- } : undefined,
107766
- recovery: recoveryAsk({
107767
- 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.",
107768
- reasonCode: "unknown"
107769
- }),
107770
- summary: firstFailed ? `${firstFailed.name} failed: ${firstFailed.message}` : "Doctor reported issues — review the checks list."
107771
- };
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);
107772
108298
  }
107773
108299
  // ../core/dist/commands/domains.js
107774
108300
  var domainAddInputSchema = exports_external.object({
@@ -107858,6 +108384,82 @@ async function envList(ctx, input = {}) {
107858
108384
  masked[k] = mask(v);
107859
108385
  return masked;
107860
108386
  }
108387
+ // ../core/dist/commands/export.js
108388
+ import { isAbsolute, resolve as resolvePath } from "node:path";
108389
+ var exportInputSchema = exports_external.object({
108390
+ app: exports_external.string().optional(),
108391
+ out: exports_external.string().optional(),
108392
+ deliveryKind: exports_external.enum(["cli", "mcp"]).default("cli")
108393
+ });
108394
+ function resolveAppName3(ctx, provided) {
108395
+ const name = provided ?? readPercherTomlAppName(ctx.cwd);
108396
+ if (!name) {
108397
+ throw new PercherCoreError("No app specified and no percher.toml found", {
108398
+ code: "export.no-app",
108399
+ hint: "Pass --app <name> or run from a directory with percher.toml."
108400
+ });
108401
+ }
108402
+ return name;
108403
+ }
108404
+ async function streamToFile(outPath, source) {
108405
+ const file2 = Bun.file(outPath);
108406
+ const writer = file2.writer();
108407
+ let bytes = 0;
108408
+ const reader = source.getReader();
108409
+ try {
108410
+ while (true) {
108411
+ const { done, value } = await reader.read();
108412
+ if (done)
108413
+ break;
108414
+ if (value && value.length > 0) {
108415
+ writer.write(value);
108416
+ bytes += value.length;
108417
+ }
108418
+ }
108419
+ await writer.end();
108420
+ } catch (err) {
108421
+ try {
108422
+ await writer.end();
108423
+ } catch {}
108424
+ try {
108425
+ await Bun.write(outPath, "");
108426
+ await Bun.file(outPath).delete?.();
108427
+ } catch {}
108428
+ throw err;
108429
+ }
108430
+ return { bytes };
108431
+ }
108432
+ function resolveOutPath(ctxCwd, filename) {
108433
+ if (isAbsolute(filename))
108434
+ return filename;
108435
+ return resolvePath(ctxCwd, filename);
108436
+ }
108437
+ async function exportApp(ctx, input) {
108438
+ const name = resolveAppName3(ctx, input.app);
108439
+ const mint = await ctx.client.export.mint(name, { deliveryKind: input.deliveryKind });
108440
+ const dl = await ctx.client.export.download(mint.downloadUrl);
108441
+ if (!dl.body) {
108442
+ throw new PercherCoreError("Export download returned an empty body", {
108443
+ code: "export.empty-body",
108444
+ hint: "Try again — the link is single-use, so a new mint is required."
108445
+ });
108446
+ }
108447
+ const filenameFromHeader = (() => {
108448
+ const cd = dl.headers.get("Content-Disposition") ?? "";
108449
+ const m = cd.match(/filename="?([^";]+)"?/);
108450
+ return m?.[1];
108451
+ })();
108452
+ const filename = input.out ?? filenameFromHeader ?? `${name}-export.tar.gz`;
108453
+ const outPath = resolveOutPath(ctx.cwd, filename);
108454
+ const { bytes } = await streamToFile(outPath, dl.body);
108455
+ return {
108456
+ appName: name,
108457
+ outPath,
108458
+ bytes,
108459
+ expiresAt: mint.expiresAt,
108460
+ includedSecrets: false
108461
+ };
108462
+ }
107861
108463
  // ../core/dist/commands/forgejo.js
107862
108464
  var FORGEJO_URL_RE = /^https:\/\/[\w.-]+\/[\w.-]+\/[\w.-]+(\.git)?$/;
107863
108465
  var forgejoConnectInputSchema = exports_external.object({
@@ -108726,11 +109328,6 @@ async function login(ctx, input = {}) {
108726
109328
  code: "login.expired"
108727
109329
  });
108728
109330
  }
108729
- // ../shared/dist/logs.js
108730
- var MAX_LOG_SEARCH_LENGTH = 256;
108731
- // ../shared/dist/runtime.js
108732
- var DEFAULT_NODE_VERSION_MAJOR = "24";
108733
- var DEFAULT_NODE_VERSION_LABEL = `${DEFAULT_NODE_VERSION_MAJOR} LTS`;
108734
109331
  // ../core/dist/commands/logs.js
108735
109332
  var logsInputSchema = exports_external.object({
108736
109333
  app: exports_external.string().optional(),
@@ -109641,7 +110238,7 @@ function collectWarnings2(collections, rlsPolicies, unsupported) {
109641
110238
  }
109642
110239
  // ../core/dist/commands/migrate-supabase-schema.js
109643
110240
  import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
109644
- import { isAbsolute, join as join17, relative as relative3, resolve as resolve6 } from "node:path";
110241
+ import { isAbsolute as isAbsolute2, join as join17, relative as relative3, resolve as resolve6 } from "node:path";
109645
110242
 
109646
110243
  // ../core/dist/commands/migrate-supabase-scripts.js
109647
110244
  function generateDataImportScripts(preview) {
@@ -110154,7 +110751,7 @@ async function migrateSupabaseSchema(ctx, input) {
110154
110751
  }
110155
110752
  function resolveOutputDir(cwd, requested) {
110156
110753
  const raw = requested ?? "./migration-preview";
110157
- return isAbsolute(raw) ? raw : resolve6(cwd, raw);
110754
+ return isAbsolute2(raw) ? raw : resolve6(cwd, raw);
110158
110755
  }
110159
110756
  async function writePreviewFiles(ctx, outputDir, preview) {
110160
110757
  await mkdir2(outputDir, { recursive: true });
@@ -110373,6 +110970,45 @@ async function open(ctx, input = {}) {
110373
110970
  await ctx.openUrl(app.url);
110374
110971
  return app.url;
110375
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
+ }
110376
111012
  // ../core/dist/commands/publish.js
110377
111013
  import { existsSync as existsSync14 } from "node:fs";
110378
111014
  import { join as join20 } from "node:path";
@@ -110508,10 +111144,10 @@ async function pollDeployment(opts) {
110508
111144
  polled = true;
110509
111145
  } catch (err) {
110510
111146
  attempt++;
110511
- const msg = err.message ?? String(err);
110512
- const isNetwork = msg.startsWith("network error:");
111147
+ const isNetwork = isTransientNetworkError(err);
110513
111148
  if (!isNetwork || attempt >= maxRetries) {
110514
111149
  if (isNetwork) {
111150
+ const msg = err.message ?? String(err);
110515
111151
  throw new Error(`${msg}
110516
111152
 
110517
111153
  The deploy may already be live — your network connection dropped mid-poll. Check with: percher deploys list ${opts.appName}`, { cause: err });
@@ -110673,7 +111309,7 @@ function classifyDeployRetry(err, attempt) {
110673
111309
  category: "non_retryable"
110674
111310
  };
110675
111311
  }
110676
- if (err instanceof Error && /network error|fetch failed|ECONNRESET|ENOTFOUND|ETIMEDOUT/i.test(err.message)) {
111312
+ if (isTransientNetworkError(err)) {
110677
111313
  const delay = NETWORK_BACKOFF_MS[idx];
110678
111314
  if (delay === undefined) {
110679
111315
  return {
@@ -110808,6 +111444,12 @@ function* walkDir(dir, base) {
110808
111444
  }
110809
111445
 
110810
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
+ ]);
110811
111453
  function classifyPublishApiError(err, input) {
110812
111454
  if (!(err instanceof PercherApiError))
110813
111455
  return null;
@@ -110832,6 +111474,18 @@ function classifyPublishApiError(err, input) {
110832
111474
  const auth = matchAuthError(err);
110833
111475
  if (auth)
110834
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);
110835
111489
  const clientError = matchGenericClientError(err);
110836
111490
  if (clientError)
110837
111491
  return buildResult(clientError, input);
@@ -110840,12 +111494,17 @@ function classifyPublishApiError(err, input) {
110840
111494
  function buildResult(c, input) {
110841
111495
  return {
110842
111496
  status: "failed",
111497
+ ...input.app !== undefined ? { app: input.app } : {},
110843
111498
  fileCount: input.bundle.fileCount,
110844
111499
  bytes: input.bundle.bytes,
110845
111500
  error: {
110846
111501
  title: c.title,
110847
111502
  explanation: c.explanation,
110848
- 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 } : {}
110849
111508
  },
110850
111509
  recovery: c.recovery,
110851
111510
  summary: c.summary,
@@ -110864,9 +111523,9 @@ function matchPlanLimit(err) {
110864
111523
  return {
110865
111524
  title: "Plan limit reached",
110866
111525
  explanation: err.message,
110867
- 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>`.",
110868
111527
  recovery: recoveryAsk({
110869
- 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>\`.`,
110870
111529
  options: ["upgrade", "delete unused app", "cancel"],
110871
111530
  reasonCode: "quota_exceeded"
110872
111531
  }),
@@ -110884,7 +111543,7 @@ function matchRateLimit(err) {
110884
111543
  return {
110885
111544
  title: "Rate limit hit",
110886
111545
  explanation: err.message,
110887
- 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.",
110888
111547
  recovery: recoveryAsk({
110889
111548
  prompt: `Rate limit: ${err.message}. Wait a few minutes then retry, or upgrade your plan.`,
110890
111549
  options: ["wait and retry", "upgrade", "cancel"],
@@ -110902,7 +111561,7 @@ function matchConcurrentLimit(err) {
110902
111561
  explanation: err.message,
110903
111562
  suggestion: "Wait for one of your in-flight deploys to finish, or upgrade your plan to raise the concurrency cap.",
110904
111563
  recovery: recoveryAsk({
110905
- 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.`,
110906
111565
  options: ["wait", "upgrade", "cancel"],
110907
111566
  reasonCode: "deploy_already_in_progress"
110908
111567
  }),
@@ -110927,14 +111586,46 @@ function matchSuspended(err) {
110927
111586
  function matchAppSuspended(err) {
110928
111587
  if (err.code !== "APP_SUSPENDED" || err.status !== 403)
110929
111588
  return null;
111589
+ const liftAfterRaw = typeof err.extra?.liftAfter === "string" ? err.extra.liftAfter : null;
111590
+ const liftableNow = err.extra?.liftableNow === true;
111591
+ const canLiftViaPublish = err.extra?.canLiftViaPublish === true;
111592
+ const fixInstructions = typeof err.extra?.fixInstructions === "string" && err.extra.fixInstructions.length > 0 ? err.extra.fixInstructions : null;
111593
+ let suggestion;
111594
+ let prompt;
111595
+ let options;
111596
+ if (canLiftViaPublish && liftableNow) {
111597
+ suggestion = fixInstructions ? `Fix the underlying cause (${fixInstructions}) and run \`percher publish\` — a successful deploy will lift the suspension automatically.` : "Fix the underlying cause and run `percher publish` — a successful deploy will lift the suspension automatically.";
111598
+ prompt = fixInstructions ? `${err.message}
111599
+
111600
+ The suspension window has passed. Fix the underlying cause per the admin's instructions (${fixInstructions}), then run \`percher publish\` — a successful deploy will lift the suspension.` : `${err.message}
111601
+
111602
+ The suspension window has passed. Fix the underlying cause, then run \`percher publish\` — a successful deploy will lift the suspension.`;
111603
+ options = ["fix and republish", "open dashboard", "contact support"];
111604
+ } else if (canLiftViaPublish && liftAfterRaw) {
111605
+ suggestion = fixInstructions ? `Wait until ${liftAfterRaw}, then fix the underlying cause (${fixInstructions}) and run \`percher publish\`. A successful deploy after the window passes will lift the suspension.` : `Wait until ${liftAfterRaw}, then run \`percher publish\`. A successful deploy after the window passes will lift the suspension.`;
111606
+ prompt = `${err.message}
111607
+
111608
+ You can try again after ${liftAfterRaw}. Until then, retrying will fail.`;
111609
+ options = ["wait and republish", "open dashboard", "contact support"];
111610
+ } else if (liftAfterRaw) {
111611
+ suggestion = `A Percher admin will review the suspension after ${liftAfterRaw}. Contact support if you need it lifted sooner.`;
111612
+ prompt = `${err.message}
111613
+
111614
+ Admin will review after ${liftAfterRaw}. Contact support at https://percher.app if you need it lifted sooner.`;
111615
+ options = ["wait", "contact support"];
111616
+ } else {
111617
+ suggestion = "Contact support at https://percher.app or check the dashboard for unsuspend options. Re-publishing won't unsuspend the app.";
111618
+ prompt = `${err.message} Open https://percher.app or contact support — re-publish won't help until the app is unsuspended.`;
111619
+ options = ["contact support", "open dashboard"];
111620
+ }
110930
111621
  return {
110931
111622
  title: "App suspended",
110932
111623
  explanation: err.message,
110933
- suggestion: "Contact support at https://percher.app or check the dashboard for unsuspend options. Re-publishing won't unsuspend the app.",
111624
+ suggestion,
110934
111625
  recovery: recoveryAsk({
110935
- prompt: `${err.message} Open https://percher.app or contact support — re-publish won't help until the app is unsuspended.`,
110936
- options: ["contact support", "open dashboard"],
110937
- reasonCode: "unknown"
111626
+ prompt,
111627
+ options,
111628
+ reasonCode: "app_suspended"
110938
111629
  }),
110939
111630
  summary: `App suspended: ${err.message}`
110940
111631
  };
@@ -110945,7 +111636,7 @@ function matchEmailNotVerified(err) {
110945
111636
  return {
110946
111637
  title: "Email not verified",
110947
111638
  explanation: err.message,
110948
- 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.",
110949
111640
  recovery: recoveryAsk({
110950
111641
  prompt: `${err.message} Verify the email, then retry publish.`,
110951
111642
  options: ["check inbox", "open settings"],
@@ -110979,6 +111670,94 @@ function matchAuthError(err) {
110979
111670
  summary: `Permission denied (${err.status} ${err.code || "—"}): ${err.message}`
110980
111671
  };
110981
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
+ }
110982
111761
  function matchGenericClientError(err) {
110983
111762
  if (err.status < 400 || err.status >= 500)
110984
111763
  return null;
@@ -111042,13 +111821,12 @@ var publishInputSchema = exports_external.object({
111042
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."),
111043
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.")
111044
111823
  });
111045
- var MAX_BYTES = 500 * 1024 * 1024;
111046
111824
  var SUSPICIOUS_BYTES = 50 * 1024 * 1024;
111047
111825
  var SUSPICIOUS_FILES = 1e4;
111048
111826
  function tomlPathFor2(cwd) {
111049
111827
  return join20(cwd, "percher.toml");
111050
111828
  }
111051
- async function buildReplacedResult(opts) {
111829
+ async function buildReplacedResult2(opts) {
111052
111830
  const { ctx, app, deployment, tarball, cwd } = opts;
111053
111831
  const resolved = await resolveReplaced({
111054
111832
  ctx,
@@ -111086,33 +111864,6 @@ function buildAlreadyInProgressResult(opts) {
111086
111864
  bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111087
111865
  };
111088
111866
  }
111089
- function buildRetryLimitReachedResult(opts) {
111090
- const { err, app, tarball, cwd } = opts;
111091
- const extra = err.extra ?? {};
111092
- const resetAtStr = extra.resetAt ?? "";
111093
- const resetAtForPrompt = resetAtStr || "the cooldown window expires";
111094
- const prompt = `Percher already retried once after an infrastructure failure for ${app.name}. Wait until ${resetAtForPrompt} or surface to the user.`;
111095
- return {
111096
- status: "failed",
111097
- app,
111098
- fileCount: tarball.fileCount,
111099
- bytes: tarball.bytes,
111100
- error: {
111101
- title: "Retry limit reached",
111102
- 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.",
111103
- 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.`
111104
- },
111105
- recovery: recoveryAsk({
111106
- prompt,
111107
- options: ["wait before retrying", "inspect status"],
111108
- reasonCode: "retry_limit_reached",
111109
- retryable: false
111110
- }),
111111
- summary: `Retry limit reached for ${app.name}${resetAtStr ? ` — wait until ${resetAtStr}` : ""}.`,
111112
- configPath: tomlPathFor2(cwd),
111113
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111114
- };
111115
- }
111116
111867
  function buildLiveSummary(opts) {
111117
111868
  const cacheTag = opts.cacheHit ? "cached, " : "";
111118
111869
  const fwSuffix = opts.framework ? ` (${opts.framework}, ${cacheTag}${opts.totalSeconds}s)` : ` (${cacheTag}${opts.totalSeconds}s)`;
@@ -111235,7 +111986,7 @@ ${bullets}`,
111235
111986
  ctx.status("[2/4] Packaging files...");
111236
111987
  const tarball = await createTarball({ cwd: ctx.cwd, config: config2 });
111237
111988
  const packageMs = Date.now() - t0;
111238
- if (tarball.bytes > MAX_BYTES) {
111989
+ if (tarball.bytes > MAX_TARBALL_BYTES) {
111239
111990
  return {
111240
111991
  status: "failed",
111241
111992
  fileCount: tarball.fileCount,
@@ -111330,12 +112081,26 @@ ${keyList}
111330
112081
  ctx.status(`Build queue wait: ~${preflight.queueWaitEstimateSec}s — your build is queued behind ${preflight.queueDepth} other(s).`);
111331
112082
  }
111332
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;
111333
112098
  const uploadStart = Date.now();
111334
112099
  ctx.status("[3/4] Uploading...");
111335
112100
  const tarballBytes = new Uint8Array(await new Response(tarball.stream).arrayBuffer());
111336
112101
  let cacheReusedDeployment = null;
111337
112102
  const contentHash = tarball.contentHash;
111338
- if (!input.noCache) {
112103
+ if (!input.noCache && !input.preview) {
111339
112104
  try {
111340
112105
  const probe = await ctx.client.platform.cacheProbe({
111341
112106
  appId: app.id,
@@ -111381,99 +112146,14 @@ ${keyList}
111381
112146
  }
111382
112147
  deployment = deployResponse;
111383
112148
  } catch (err) {
111384
- if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
111385
- return buildRetryLimitReachedResult({
111386
- err,
111387
- app,
111388
- tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
111389
- cwd: ctx.cwd
111390
- });
111391
- }
111392
- if (err instanceof PercherApiError && err.code === "REQUIRED_ENV_MISSING") {
111393
- const extra = err.extra ?? {};
111394
- const keys = extra.keys ?? [];
111395
- const source = extra.source ?? "contract";
111396
- 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";
111397
- return {
111398
- status: "failed",
111399
- app,
111400
- fileCount: tarball.fileCount,
111401
- bytes: tarball.bytes,
111402
- error: {
111403
- title: "Required env keys not set",
111404
- explanation: `${keys.length} env ${keys.length === 1 ? "key is" : "keys are"} ${sourceText} but missing from the env store: ${keys.join(", ")}.`,
111405
- suggestion: `Run \`bunx percher env set ${keys[0] ?? "KEY"}=value\` (and similarly for the rest) and re-publish.`,
111406
- errorClass: "missing_env",
111407
- phase: "config",
111408
- missingEnvVars: keys
111409
- },
111410
- recovery: recoveryEnv({
111411
- app: app.name,
111412
- keys,
111413
- reasonCode: "missing_env"
111414
- }),
111415
- summary: `Missing required env ${keys.length === 1 ? "key" : "keys"}: ${keys.join(", ")}.`,
111416
- configPath: tomlPathFor2(ctx.cwd),
111417
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111418
- };
111419
- }
111420
- if (err instanceof PercherApiError && err.code === "ENV_KEY_UNDECLARED") {
111421
- const extra = err.extra ?? {};
111422
- const apiProblems = extra.problems ?? [];
111423
- const problems = apiProblems.map((p) => ({
111424
- file: p.file,
111425
- line: p.line,
111426
- 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)"}`
111427
- }));
111428
- const uniqueKeys = [...new Set(apiProblems.map((p) => p.key))];
111429
- return {
111430
- status: "failed",
111431
- app,
111432
- fileCount: tarball.fileCount,
111433
- bytes: tarball.bytes,
111434
- error: {
111435
- title: "Undeclared env key",
111436
- explanation: `Source references ${uniqueKeys.length} env ${uniqueKeys.length === 1 ? "key" : "keys"} that aren't classified in percher.toml's [env] table: ${uniqueKeys.join(", ")}.`,
111437
- 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.",
111438
- errorClass: "config_invalid",
111439
- phase: "config",
111440
- relevantFiles: ["percher.toml"]
111441
- },
111442
- recovery: recoveryFixConfig({
111443
- problems,
111444
- reasonCode: "env_key_undeclared"
111445
- }),
111446
- summary: `${uniqueKeys.length} undeclared env ${uniqueKeys.length === 1 ? "key" : "keys"} in source: ${uniqueKeys.join(", ")}.`,
111447
- configPath: tomlPathFor2(ctx.cwd),
111448
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111449
- };
111450
- }
111451
- if (err instanceof PercherApiError && err.code === "DAILY_QUOTA_EXCEEDED") {
111452
- const extra = err.extra ?? {};
111453
- const kind = extra.kind ?? "live";
111454
- const used = extra.used ?? 0;
111455
- const limit = extra.limit ?? 0;
111456
- const resetAt = extra.resetAt ?? "";
111457
- const otherKind = kind === "live" ? "preview" : "live";
111458
- return {
111459
- status: "failed",
111460
- app,
111461
- fileCount: tarball.fileCount,
111462
- bytes: tarball.bytes,
111463
- error: {
111464
- title: "Daily deploy quota reached",
111465
- explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
111466
- suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/settings to raise this cap.`
111467
- },
111468
- recovery: recoveryAsk({
111469
- 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.`,
111470
- options: ["wait", `deploy ${otherKind}`, "upgrade"],
111471
- reasonCode: "quota_exceeded"
111472
- }),
111473
- 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, {
111474
112151
  configPath: tomlPathFor2(ctx.cwd),
111475
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
111476
- };
112152
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
112153
+ app
112154
+ });
112155
+ if (classified)
112156
+ return classified;
111477
112157
  }
111478
112158
  throw err;
111479
112159
  }
@@ -111571,7 +112251,7 @@ ${keyList}
111571
112251
  }
111572
112252
  const buildMs = Date.now() - buildStart;
111573
112253
  if (deployment.status === "replaced") {
111574
- return await buildReplacedResult({
112254
+ return await buildReplacedResult2({
111575
112255
  ctx,
111576
112256
  app,
111577
112257
  deployment,
@@ -111683,150 +112363,21 @@ async function handleUnauthorized(ctx, config2, tarball, input) {
111683
112363
  token: newToken,
111684
112364
  sessionId: ctx.client.sessionId
111685
112365
  });
112366
+ const t0 = Date.now();
111686
112367
  const freshTarball = await createTarball({ cwd: ctx.cwd, config: config2 });
111687
- let firstDeployRetry = false;
111688
- let app;
111689
- ({ app, firstDeploy: firstDeployRetry } = await ensureApp2(ctx, config2));
111690
- ctx.status("Uploading...");
111691
- const freshTarballBytes = new Uint8Array(await new Response(freshTarball.stream).arrayBuffer());
111692
- const freshIdempotencyKey = crypto.randomUUID();
111693
- let deployment;
111694
- try {
111695
- const { result: deployResponse } = await runDeployWithRetry({
111696
- call: (attempt) => ctx.client.apps.deploy(app.id, {
111697
- tarball: freshTarballBytes,
111698
- type: input.preview ? "preview" : undefined,
111699
- note: input.message,
111700
- noCache: input.noCache,
111701
- idempotencyKey: freshIdempotencyKey,
111702
- retriedAttempts: attempt - 1
111703
- }),
111704
- onRetry: ({ attempt, decision }) => {
111705
- ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
111706
- }
111707
- });
111708
- if (isDeployAlreadyInProgress(deployResponse)) {
111709
- return buildAlreadyInProgressResult({
111710
- active: deployResponse,
111711
- app,
111712
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
111713
- cwd: ctx.cwd
111714
- });
111715
- }
111716
- deployment = deployResponse;
111717
- } catch (err) {
111718
- if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
111719
- return buildRetryLimitReachedResult({
111720
- err,
111721
- app,
111722
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
111723
- cwd: ctx.cwd
111724
- });
111725
- }
111726
- throw err;
111727
- }
111728
- const replacedPreview = deployment.replacedPreview === true;
111729
- if (input.waitForLive === false) {
111730
- ctx.status(`Queued (${deployment.id}) — resume with percher_wait_for_deploy.`);
111731
- return {
111732
- status: "queued",
111733
- app,
111734
- deployment,
111735
- fileCount: freshTarball.fileCount,
111736
- bytes: freshTarball.bytes,
111737
- replacedPreview: replacedPreview || undefined,
111738
- firstDeploy: firstDeployRetry || undefined,
111739
- recovery: recoveryWait({
111740
- app: app.name,
111741
- deployId: deployment.id,
111742
- reasonCode: "deploy_queued"
111743
- }),
111744
- summary: `${app.name} deploy ${deployment.id} queued — resume with percher_wait_for_deploy.`,
111745
- configPath: tomlPathFor2(ctx.cwd),
111746
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes }
111747
- };
111748
- }
111749
- const timeoutMs = TIMEOUTS.clientPublishPoll;
111750
- const start = Date.now();
111751
- while (deployment.status !== "live" && deployment.status !== "failed" && deployment.status !== "replaced") {
111752
- if (Date.now() - start > timeoutMs) {
111753
- return {
111754
- status: "failed",
111755
- app,
111756
- deployment,
111757
- fileCount: freshTarball.fileCount,
111758
- bytes: freshTarball.bytes,
111759
- error: {
111760
- title: "Deploy timed out",
111761
- explanation: "The deployment did not complete within 5 minutes.",
111762
- 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.`
111763
- },
111764
- recovery: recoveryDoctor({
111765
- app: app.name,
111766
- deployId: deployment.id,
111767
- mode: "deploy",
111768
- reasonCode: "deploy_stalled"
111769
- }),
111770
- summary: `Deploy timed out after 5 minutes (${deployment.id}).`,
111771
- configPath: tomlPathFor2(ctx.cwd),
111772
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes }
111773
- };
111774
- }
111775
- ctx.status(`${deployment.status}...`);
111776
- await new Promise((r) => setTimeout(r, 2000));
111777
- deployment = await ctx.client.apps.getDeployment(app.id, deployment.id);
111778
- }
111779
- if (deployment.status === "replaced") {
111780
- return await buildReplacedResult({
111781
- ctx,
111782
- app,
111783
- deployment,
111784
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
111785
- cwd: ctx.cwd
111786
- });
111787
- }
111788
- if (deployment.status === "failed") {
111789
- return buildFailureResult({
111790
- ctx,
111791
- app,
111792
- deployment,
111793
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
111794
- input
111795
- });
111796
- }
111797
- const totalSeconds = (Date.now() - start) / 1000;
111798
- const url2 = deployment.previewUrl ?? deployment.url ?? app.url;
111799
- ctx.status(`Live at ${url2} (${totalSeconds.toFixed(0)}s)`);
111800
- return {
111801
- status: "live",
111802
- 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,
111803
112374
  app,
111804
- deployment,
111805
- timing: {
111806
- totalSeconds: Math.round(totalSeconds),
111807
- packageMs: 0,
111808
- uploadMs: 0,
111809
- buildMs: 0
111810
- },
111811
- fileCount: freshTarball.fileCount,
111812
- bytes: freshTarball.bytes,
111813
- replacedPreview,
111814
- firstDeploy: firstDeployRetry || undefined,
111815
- recovery: RECOVERY_NONE,
111816
- cacheHit: deployment.cacheHit,
111817
- traceId: deployment.id,
111818
- summary: buildLiveSummary({
111819
- appName: app.name,
111820
- url: url2 ?? app.url,
111821
- totalSeconds: Math.round(totalSeconds),
111822
- framework: config2.app.framework,
111823
- preview: input.preview === true,
111824
- replacedPreview,
111825
- cacheHit: deployment.cacheHit ?? false
111826
- }),
111827
- configPath: tomlPathFor2(ctx.cwd),
111828
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes }
111829
- };
112375
+ firstDeploy,
112376
+ tarball: freshTarball,
112377
+ t0,
112378
+ packageMs,
112379
+ missingBuildEnvKeys: []
112380
+ });
111830
112381
  }
111831
112382
  async function loadOrGenerate(ctx) {
111832
112383
  const tomlPath = join20(ctx.cwd, "percher.toml");
@@ -111887,7 +112438,6 @@ var pushInputSchema = exports_external.object({
111887
112438
  preview: exports_external.boolean().optional(),
111888
112439
  noCache: exports_external.boolean().optional().describe("Skip the image-cache lookup and force a fresh build.")
111889
112440
  });
111890
- var MAX_BYTES2 = 500 * 1024 * 1024;
111891
112441
  var SUSPICIOUS_BYTES2 = 50 * 1024 * 1024;
111892
112442
  var SUSPICIOUS_FILES2 = 1e4;
111893
112443
  var PLACEHOLDER_PREFIXES = ["add-your-", "your-key-here"];
@@ -111961,7 +112511,7 @@ async function push(ctx, input = {}, internals = {}) {
111961
112511
  await checkPlaceholders(ctx, config2);
111962
112512
  await checkByoSecrets(ctx, config2);
111963
112513
  const tarball = await createTarball({ cwd: ctx.cwd, config: config2 });
111964
- if (tarball.bytes > MAX_BYTES2) {
112514
+ if (tarball.bytes > MAX_TARBALL_BYTES) {
111965
112515
  throw new PercherCoreError(`Repository is larger than 500 MB (current: ${(tarball.bytes / 1024 / 1024).toFixed(1)} MB)`, {
111966
112516
  code: "push.too-large",
111967
112517
  hint: `Biggest files: ${tarball.topFiles.map((f) => f.path).join(", ")}`,
@@ -112116,10 +112666,10 @@ async function push(ctx, input = {}, internals = {}) {
112116
112666
  const resetAt = extra.resetAt ?? "";
112117
112667
  throw new PercherCoreError(`Daily ${kind}-deploy quota reached (${used}/${limit}). Resets at ${resetAt}.`, {
112118
112668
  code: "push.quota-exceeded",
112119
- 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.`,
112120
112670
  recovery: {
112121
112671
  ...recoveryAsk({
112122
- 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.`,
112123
112673
  reasonCode: "quota_exceeded"
112124
112674
  })
112125
112675
  }
@@ -112273,10 +112823,10 @@ async function redeploy(ctx, input = {}) {
112273
112823
  error: {
112274
112824
  title: "Daily deploy quota reached",
112275
112825
  explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
112276
- 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.`
112277
112827
  },
112278
112828
  recovery: recoveryAsk({
112279
- 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.`,
112280
112830
  options: ["wait", `deploy ${otherKind}`, "upgrade"],
112281
112831
  reasonCode: "quota_exceeded"
112282
112832
  }),
@@ -112289,7 +112839,7 @@ async function redeploy(ctx, input = {}) {
112289
112839
  return failureResult({
112290
112840
  title: "Deploy rate limit reached",
112291
112841
  explanation: err.message,
112292
- 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."
112293
112843
  });
112294
112844
  case "DEPLOY_RATE_LIMITED": {
112295
112845
  const extra = err.extra ?? {};
@@ -112516,6 +113066,8 @@ function failureResult(error48) {
112516
113066
  };
112517
113067
  }
112518
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";
112519
113071
  var renameInputSchema = exports_external.object({
112520
113072
  app: exports_external.string().optional(),
112521
113073
  name: exports_external.string().min(3).max(40)
@@ -112528,12 +113080,33 @@ async function rename(ctx, input) {
112528
113080
  hint: "Pass an app name or run from a directory with percher.toml."
112529
113081
  });
112530
113082
  }
112531
- 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
+ }
112532
113105
  }
112533
113106
  // ../core/dist/commands/reproduce.js
112534
- import { mkdtempSync, writeFileSync as writeFileSync8 } from "node:fs";
113107
+ import { mkdtempSync, writeFileSync as writeFileSync9 } from "node:fs";
112535
113108
  import { tmpdir } from "node:os";
112536
- import { join as join22 } from "node:path";
113109
+ import { join as join23 } from "node:path";
112537
113110
  var reproduceInputSchema = exports_external.object({
112538
113111
  app: exports_external.string().optional().describe("App name. Defaults to the [app].name in the current directory's percher.toml."),
112539
113112
  deployId: exports_external.string().describe("Deploy id (e.g. dep_abc123) to reproduce. Required — usually the failed deploy you want to debug.")
@@ -112617,9 +113190,9 @@ async function reproduce(ctx, input) {
112617
113190
  const envMap = await ctx.client.env.list(app.id);
112618
113191
  currentAppEnvKeys = Object.keys(envMap).sort();
112619
113192
  } catch {}
112620
- const tmpDir = mkdtempSync(join22(tmpdir(), `percher-reproduce-${input.deployId}-`));
112621
- const tarballPath = join22(tmpDir, "source.tar.gz");
112622
- 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);
112623
113196
  let extracted = false;
112624
113197
  try {
112625
113198
  const proc = Bun.spawn(["tar", "-xzf", tarballPath, "-C", tmpDir], {
@@ -112857,7 +113430,7 @@ async function rollback(ctx, input, internals = {}) {
112857
113430
  }
112858
113431
  // ../core/dist/commands/sharing.js
112859
113432
  var ShareRoleSchema = exports_external.enum(["viewer", "admin"]);
112860
- function resolveApp3(ctx, app) {
113433
+ function resolveApp4(ctx, app) {
112861
113434
  const name = app ?? readPercherTomlAppName(ctx.cwd);
112862
113435
  if (!name) {
112863
113436
  throw new PercherCoreError("No app specified and no percher.toml found", {
@@ -112885,19 +113458,19 @@ var shareRevokeInputSchema = exports_external.object({
112885
113458
  shareId: exports_external.string().min(1, "shareId is required")
112886
113459
  });
112887
113460
  async function shareList(ctx, input = {}) {
112888
- const appRef = resolveApp3(ctx, input.app);
113461
+ const appRef = resolveApp4(ctx, input.app);
112889
113462
  return ctx.client.sharing.list(appRef);
112890
113463
  }
112891
113464
  async function shareInvite(ctx, input) {
112892
- const appRef = resolveApp3(ctx, input.app);
113465
+ const appRef = resolveApp4(ctx, input.app);
112893
113466
  return ctx.client.sharing.create(appRef, { email: input.email, role: input.role });
112894
113467
  }
112895
113468
  async function shareSetRole(ctx, input) {
112896
- const appRef = resolveApp3(ctx, input.app);
113469
+ const appRef = resolveApp4(ctx, input.app);
112897
113470
  return ctx.client.sharing.setRole(appRef, input.shareId, input.role);
112898
113471
  }
112899
113472
  async function shareRevoke(ctx, input) {
112900
- const appRef = resolveApp3(ctx, input.app);
113473
+ const appRef = resolveApp4(ctx, input.app);
112901
113474
  await ctx.client.sharing.revoke(appRef, input.shareId);
112902
113475
  }
112903
113476
  var invitesListInputSchema = exports_external.object({});
@@ -112963,7 +113536,7 @@ function summarize2(verdict, health) {
112963
113536
  return "API responded but reported a non-ok status";
112964
113537
  }
112965
113538
  // ../core/dist/commands/transfers.js
112966
- function resolveApp4(ctx, app) {
113539
+ function resolveApp5(ctx, app) {
112967
113540
  const name = app ?? readPercherTomlAppName(ctx.cwd);
112968
113541
  if (!name) {
112969
113542
  throw new PercherCoreError("No app specified and no percher.toml found", {
@@ -112985,15 +113558,15 @@ var transferListForAppInputSchema = exports_external.object({
112985
113558
  app: exports_external.string().optional()
112986
113559
  });
112987
113560
  async function transferInitiate(ctx, input) {
112988
- const appRef = resolveApp4(ctx, input.app);
113561
+ const appRef = resolveApp5(ctx, input.app);
112989
113562
  return ctx.client.transfers.initiate(appRef, { email: input.email });
112990
113563
  }
112991
113564
  async function transferCancel(ctx, input) {
112992
- const appRef = resolveApp4(ctx, input.app);
113565
+ const appRef = resolveApp5(ctx, input.app);
112993
113566
  await ctx.client.transfers.cancel(appRef, input.transferId);
112994
113567
  }
112995
113568
  async function transferListForApp(ctx, input = {}) {
112996
- const appRef = resolveApp4(ctx, input.app);
113569
+ const appRef = resolveApp5(ctx, input.app);
112997
113570
  return ctx.client.transfers.listForApp(appRef);
112998
113571
  }
112999
113572
  var transfersIncomingInputSchema = exports_external.object({});
@@ -113059,6 +113632,7 @@ var SOURCE_EXTS2 = new Set([
113059
113632
  ]);
113060
113633
  var DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
113061
113634
  // ../core/dist/plans.js
113635
+ var PB_INITIAL_FOOTPRINT_MB = 256;
113062
113636
  var PLAN_CATALOG = {
113063
113637
  free: {
113064
113638
  label: "Free",
@@ -113080,7 +113654,7 @@ var PLAN_CATALOG = {
113080
113654
  logRetentionDays: 1,
113081
113655
  buildLogRetentionDays: 3,
113082
113656
  cronJobs: 1,
113083
- storageMb: 500,
113657
+ storageMb: PB_INITIAL_FOOTPRINT_MB,
113084
113658
  instances: 1,
113085
113659
  kvStorageMb: 10,
113086
113660
  uptimeIntervalSec: 0,
@@ -113112,7 +113686,7 @@ var PLAN_CATALOG = {
113112
113686
  logRetentionDays: 7,
113113
113687
  buildLogRetentionDays: 14,
113114
113688
  cronJobs: 3,
113115
- storageMb: 5000,
113689
+ storageMb: PB_INITIAL_FOOTPRINT_MB,
113116
113690
  instances: 1,
113117
113691
  kvStorageMb: 50,
113118
113692
  uptimeIntervalSec: 300,
@@ -113144,7 +113718,7 @@ var PLAN_CATALOG = {
113144
113718
  logRetentionDays: 30,
113145
113719
  buildLogRetentionDays: 30,
113146
113720
  cronJobs: 10,
113147
- storageMb: 25000,
113721
+ storageMb: PB_INITIAL_FOOTPRINT_MB,
113148
113722
  instances: 2,
113149
113723
  kvStorageMb: 200,
113150
113724
  uptimeIntervalSec: 60,
@@ -113176,7 +113750,7 @@ var PLAN_CATALOG = {
113176
113750
  logRetentionDays: 90,
113177
113751
  buildLogRetentionDays: 90,
113178
113752
  cronJobs: 25,
113179
- storageMb: 75000,
113753
+ storageMb: PB_INITIAL_FOOTPRINT_MB,
113180
113754
  instances: 4,
113181
113755
  kvStorageMb: 1000,
113182
113756
  uptimeIntervalSec: 30,
@@ -113290,6 +113864,18 @@ function formatStatus(status2) {
113290
113864
  return import_picocolors.default.red(status2);
113291
113865
  return import_picocolors.default.yellow(status2);
113292
113866
  }
113867
+ function formatBytes(bytes) {
113868
+ if (!Number.isFinite(bytes) || bytes < 0)
113869
+ return "—";
113870
+ const units = ["B", "KB", "MB", "GB", "TB"];
113871
+ let n = bytes;
113872
+ let i = 0;
113873
+ while (n >= 1024 && i < units.length - 1) {
113874
+ n /= 1024;
113875
+ i += 1;
113876
+ }
113877
+ return `${i === 0 ? n : n.toFixed(1)} ${units[i]}`;
113878
+ }
113293
113879
  function firstNonFlagAfter(argv, from, valueFlags = new Set) {
113294
113880
  let i = from + 1;
113295
113881
  while (i < argv.length) {
@@ -113329,6 +113915,9 @@ async function runDeploysInspect(ctx, input) {
113329
113915
  await writeLine(ctx, `${import_picocolors.default.bold("Type:")} ${deploy.type}`);
113330
113916
  }
113331
113917
  await writeLine(ctx, `${import_picocolors.default.bold("Created:")} ${formatDeployTimestamp(deploy.createdAt)}`);
113918
+ if (typeof deploy.imageSizeBytes === "number") {
113919
+ await writeLine(ctx, `${import_picocolors.default.bold("Image:")} ${formatBytes(deploy.imageSizeBytes)}`);
113920
+ }
113332
113921
  if (deploy.url) {
113333
113922
  await writeLine(ctx, `${import_picocolors.default.bold("URL:")} ${deploy.url}`);
113334
113923
  }
@@ -113789,6 +114378,9 @@ async function handlePublishResult(ctx, result, options = {}) {
113789
114378
  await writeLine(ctx, import_picocolors4.default.dim(" Build cache: miss (fresh build)"));
113790
114379
  }
113791
114380
  }
114381
+ if (typeof result.deployment?.imageSizeBytes === "number") {
114382
+ await writeLine(ctx, import_picocolors4.default.dim(` Image size: ${formatBytes(result.deployment.imageSizeBytes)}`));
114383
+ }
113792
114384
  if (result.replacedPreview) {
113793
114385
  await writeLine(ctx, import_picocolors4.default.dim(" (replaced previous preview — free plan keeps 1 preview slot per app)"));
113794
114386
  }
@@ -113876,6 +114468,67 @@ function registerAppsCommands(cli, ctx) {
113876
114468
  const timing = result.timing ? ` (${result.timing.totalSeconds.toFixed(0)}s)` : "";
113877
114469
  await writeLine(ctx, `${import_picocolors4.default.green("✓")} Redeployed to ${result.url}${timing}`);
113878
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
+ });
113879
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) => {
113880
114533
  const result = await push(ctx, {
113881
114534
  dryRun: options.dryRun,
@@ -113897,12 +114550,20 @@ function registerAppsCommands(cli, ctx) {
113897
114550
  } else if (status2 === "fresh") {
113898
114551
  await writeLine(ctx, import_picocolors4.default.dim(" Build cache: miss (fresh build)"));
113899
114552
  }
114553
+ if (typeof result.deployment?.imageSizeBytes === "number") {
114554
+ await writeLine(ctx, import_picocolors4.default.dim(` Image size: ${formatBytes(result.deployment.imageSizeBytes)}`));
114555
+ }
113900
114556
  }
113901
114557
  });
113902
114558
  cli.command("rename <name> [app]", "Rename an app (changes its subdomain)").action(async (name, app) => {
113903
114559
  const result = await rename(ctx, { app, name });
113904
114560
  await writeLine(ctx, `${import_picocolors4.default.green("✓")} Renamed to ${result.name} — now live at ${result.url}`);
113905
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
+ }
113906
114567
  await writeLine(ctx, ` ${import_picocolors4.default.dim(`Next rename available in ${result.cooldownDays} days`)}`);
113907
114568
  });
113908
114569
  cli.command("open [app]", "Open the app URL in your browser").action(async (app) => {
@@ -113964,6 +114625,17 @@ function registerAppsCommands(cli, ctx) {
113964
114625
  const result = await resume(ctx, { app });
113965
114626
  await writeLine(ctx, `${import_picocolors4.default.green("✓")} ${result.message}`);
113966
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
+ });
113967
114639
  cli.command("doctor [app]", "Check app health and connectivity").option("--json", "Output as JSON").action(async (app, options) => {
113968
114640
  const result = await doctor(ctx, { app, json: options.json });
113969
114641
  if (options.json) {
@@ -114244,24 +114916,60 @@ function registerDeploysCommands(cli, ctx, argv) {
114244
114916
  await writeLine(ctx, `${v.sha} ${v.timestamp} ${message}`);
114245
114917
  }
114246
114918
  });
114247
- 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
+ }
114248
114924
  const appRef = options.app ?? readPercherTomlAppName(ctx.cwd);
114249
- if (!appRef)
114925
+ if (!appRef) {
114250
114926
  throw new Error("App name required. Use --app or run from a project directory.");
114927
+ }
114251
114928
  if (action === "promote") {
114252
- if (!deployId)
114929
+ if (!arg)
114253
114930
  throw new Error("Deploy ID required. Usage: percher preview promote <id>");
114254
- const result = await ctx.client.apps.promotePreview(appRef, deployId);
114931
+ const result = await ctx.client.apps.promotePreview(appRef, arg);
114255
114932
  await writeLine(ctx, `${import_picocolors7.default.green("✓")} Preview promoted to live. Status: ${result.status}`);
114256
114933
  } else if (action === "discard") {
114257
- if (!deployId)
114934
+ if (!arg)
114258
114935
  throw new Error("Deploy ID required. Usage: percher preview discard <id>");
114259
- await ctx.client.apps.discardPreview(appRef, deployId);
114936
+ await ctx.client.apps.discardPreview(appRef, arg);
114260
114937
  await writeLine(ctx, `${import_picocolors7.default.green("✓")} Preview discarded.`);
114261
114938
  } else {
114262
- throw new Error(`Unknown preview action "${action}". Use promote | discard.`);
114939
+ throw new Error(`Unknown preview action "${action}". Use promote | discard | branch.`);
114263
114940
  }
114264
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
+ }
114265
114973
  }
114266
114974
 
114267
114975
  // src/commands/env.ts
@@ -114318,10 +115026,24 @@ function registerEnvCommands(cli, ctx) {
114318
115026
  });
114319
115027
  }
114320
115028
 
114321
- // src/commands/github.ts
115029
+ // src/commands/export.ts
114322
115030
  var import_picocolors9 = __toESM(require_picocolors(), 1);
115031
+ function registerExportCommands(cli, ctx) {
115032
+ cli.command("export [app]", "Download an export bundle for an app (no secrets)").option("--out <path>", "Where to write the .tar.gz (defaults to ./{name}-{date}.tar.gz)").action(async (app, options) => {
115033
+ const result = await exportApp(ctx, {
115034
+ app,
115035
+ out: options.out,
115036
+ deliveryKind: "cli"
115037
+ });
115038
+ await writeLine(ctx, `${import_picocolors9.default.green("✓")} exported ${result.appName} → ${result.outPath} (${(result.bytes / 1024).toFixed(1)} KB)`);
115039
+ await writeLine(ctx, import_picocolors9.default.dim("Env values are not included. To export encrypted env values, use the dashboard's Settings → Export and confirm your password."));
115040
+ });
115041
+ }
115042
+
115043
+ // src/commands/github.ts
115044
+ var import_picocolors10 = __toESM(require_picocolors(), 1);
114323
115045
  async function printWebhookInstructions(ctx, instructions, branch) {
114324
- await writeLine(ctx, import_picocolors9.default.bold("Add this webhook in GitHub to enable auto-deploy on push:"));
115046
+ await writeLine(ctx, import_picocolors10.default.bold("Add this webhook in GitHub to enable auto-deploy on push:"));
114325
115047
  await writeLine(ctx, ` URL: ${instructions.settingsUrl}`);
114326
115048
  await writeLine(ctx, "");
114327
115049
  await writeLine(ctx, " Field Value");
@@ -114331,9 +115053,9 @@ async function printWebhookInstructions(ctx, instructions, branch) {
114331
115053
  await writeLine(ctx, ` Secret ${instructions.secret}`);
114332
115054
  await writeLine(ctx, " Which events? Just the push event");
114333
115055
  await writeLine(ctx, "");
114334
- await writeLine(ctx, import_picocolors9.default.yellow(" ⚠ Treat this secret like a password — save it now, it won't be shown again."));
115056
+ await writeLine(ctx, import_picocolors10.default.yellow(" ⚠ Treat this secret like a password — save it now, it won't be shown again."));
114335
115057
  await writeLine(ctx, "");
114336
- await writeLine(ctx, import_picocolors9.default.dim(`After adding the webhook, every push to ${branch} will auto-deploy.`));
115058
+ await writeLine(ctx, import_picocolors10.default.dim(`After adding the webhook, every push to ${branch} will auto-deploy.`));
114337
115059
  }
114338
115060
  function registerGithubCommands(cli, ctx) {
114339
115061
  cli.command("github [action] [repoUrl] [...rest]", "GitHub integration: connect | setup-webhook").option("--app <name>", "App name (overrides percher.toml)").option("--branch <branch>", "(connect only) Branch to track (default: main)").action(async (action, repoUrl, _rest, options) => {
@@ -114346,24 +115068,24 @@ function registerGithubCommands(cli, ctx) {
114346
115068
  repoUrl,
114347
115069
  branch: options.branch
114348
115070
  });
114349
- await writeLine(ctx, `${import_picocolors9.default.green("✓")} Connected to ${result.repoUrl} (branch: ${result.branch})`);
114350
- await writeLine(ctx, `${import_picocolors9.default.green("✓")} Deploy started: ${result.deployId}`);
115071
+ await writeLine(ctx, `${import_picocolors10.default.green("✓")} Connected to ${result.repoUrl} (branch: ${result.branch})`);
115072
+ await writeLine(ctx, `${import_picocolors10.default.green("✓")} Deploy started: ${result.deployId}`);
114351
115073
  await writeLine(ctx, "");
114352
115074
  if (result.webhookSetupFailed) {
114353
- await writeLine(ctx, import_picocolors9.default.yellow(`⚠ Webhook setup failed: ${result.webhookSetupError ?? "unknown error"}`));
114354
- await writeLine(ctx, import_picocolors9.default.yellow(" Auto-deploy will NOT work until you re-run webhook setup:"));
114355
- await writeLine(ctx, ` ${import_picocolors9.default.bold(`percher github setup-webhook${options.app ? ` --app ${options.app}` : ""}`)}`);
115075
+ await writeLine(ctx, import_picocolors10.default.yellow(`⚠ Webhook setup failed: ${result.webhookSetupError ?? "unknown error"}`));
115076
+ await writeLine(ctx, import_picocolors10.default.yellow(" Auto-deploy will NOT work until you re-run webhook setup:"));
115077
+ await writeLine(ctx, ` ${import_picocolors10.default.bold(`percher github setup-webhook${options.app ? ` --app ${options.app}` : ""}`)}`);
114356
115078
  return;
114357
115079
  }
114358
115080
  if (result.webhookInstructions) {
114359
115081
  await printWebhookInstructions(ctx, result.webhookInstructions, result.branch);
114360
115082
  }
114361
- await writeLine(ctx, import_picocolors9.default.dim(`Track the initial deploy: percher deploys inspect ${result.deployId}`));
115083
+ await writeLine(ctx, import_picocolors10.default.dim(`Track the initial deploy: percher deploys inspect ${result.deployId}`));
114362
115084
  return;
114363
115085
  }
114364
115086
  if (action === "setup-webhook") {
114365
115087
  const result = await githubSetupWebhook(ctx, { app: options.app });
114366
- await writeLine(ctx, `${import_picocolors9.default.green("✓")} Webhook regenerated for ${result.app}`);
115088
+ await writeLine(ctx, `${import_picocolors10.default.green("✓")} Webhook regenerated for ${result.app}`);
114367
115089
  await writeLine(ctx, "");
114368
115090
  await printWebhookInstructions(ctx, result.webhookInstructions, result.branch);
114369
115091
  return;
@@ -114375,17 +115097,17 @@ function registerGithubCommands(cli, ctx) {
114375
115097
  }
114376
115098
 
114377
115099
  // src/commands/migrate.ts
114378
- var import_picocolors10 = __toESM(require_picocolors(), 1);
115100
+ var import_picocolors11 = __toESM(require_picocolors(), 1);
114379
115101
  function registerMigrateCommands(cli, ctx) {
114380
115102
  cli.command("migrate-from-supabase", "Migrate a Supabase project to PocketBase (FUTURE9 Phase A read-only + Phase B preview writes)").option("--project <ref>", "Supabase project reference (the 20-char id from the project URL)").option("--token <token>", "Supabase personal access token (sbp_...). Read from SUPABASE_ACCESS_TOKEN if omitted.").option("--inspect-only", "Run schema introspection only (Phase A — read-only, no file writes)").option("--output-dir <dir>", "Where to write the Phase B preview files (default: ./migration-preview/)").option("--json", "Output the structured result as JSON").action(async (options) => {
114381
115103
  const projectRef = options.project;
114382
115104
  const token = options.token ?? ctx.env.SUPABASE_ACCESS_TOKEN;
114383
115105
  if (!projectRef) {
114384
- await writeLine(ctx, `${import_picocolors10.default.red("✗")} Missing --project. Pass your Supabase project ref (e.g. --project abcdefghijklmnopqrst).`);
115106
+ await writeLine(ctx, `${import_picocolors11.default.red("✗")} Missing --project. Pass your Supabase project ref (e.g. --project abcdefghijklmnopqrst).`);
114385
115107
  throw new SilentExitError(1);
114386
115108
  }
114387
115109
  if (!token) {
114388
- await writeLine(ctx, `${import_picocolors10.default.red("✗")} Missing --token. Pass --token sbp_... or set SUPABASE_ACCESS_TOKEN. Generate one at https://supabase.com/dashboard/account/tokens`);
115110
+ await writeLine(ctx, `${import_picocolors11.default.red("✗")} Missing --token. Pass --token sbp_... or set SUPABASE_ACCESS_TOKEN. Generate one at https://supabase.com/dashboard/account/tokens`);
114389
115111
  throw new SilentExitError(1);
114390
115112
  }
114391
115113
  if (options.inspectOnly) {
@@ -114420,103 +115142,103 @@ function registerMigrateCommands(cli, ctx) {
114420
115142
  async function renderInspectHuman(ctx, result) {
114421
115143
  if (result.status === "failed") {
114422
115144
  const err = result.error;
114423
- await writeLine(ctx, `${import_picocolors10.default.red("✗")} ${err?.title ?? "Inspection failed"}`);
115145
+ await writeLine(ctx, `${import_picocolors11.default.red("✗")} ${err?.title ?? "Inspection failed"}`);
114424
115146
  if (err?.explanation)
114425
115147
  await writeLine(ctx, ` ${err.explanation}`);
114426
115148
  if (err?.suggestion)
114427
- await writeLine(ctx, import_picocolors10.default.dim(` ${err.suggestion}`));
115149
+ await writeLine(ctx, import_picocolors11.default.dim(` ${err.suggestion}`));
114428
115150
  return;
114429
115151
  }
114430
- await writeLine(ctx, `${import_picocolors10.default.green("✓")} ${result.summary}`);
115152
+ await writeLine(ctx, `${import_picocolors11.default.green("✓")} ${result.summary}`);
114431
115153
  await writeLine(ctx, "");
114432
- await writeLine(ctx, import_picocolors10.default.bold("Tables"));
115154
+ await writeLine(ctx, import_picocolors11.default.bold("Tables"));
114433
115155
  if (result.tables.length === 0) {
114434
- await writeLine(ctx, import_picocolors10.default.dim(" (none in user schemas)"));
115156
+ await writeLine(ctx, import_picocolors11.default.dim(" (none in user schemas)"));
114435
115157
  }
114436
115158
  for (const table of result.tables) {
114437
115159
  const pkCount = table.columns.filter((c) => c.isPrimaryKey).length;
114438
115160
  const fkCount = table.columns.filter((c) => c.foreignKey !== null).length;
114439
115161
  const policyCount = table.rlsPolicies.length;
114440
115162
  const rowEstimate = table.estimatedRowCount.toLocaleString();
114441
- await writeLine(ctx, ` ${import_picocolors10.default.cyan(`${table.schema}.${table.name}`)} ${import_picocolors10.default.dim(`cols=${table.columns.length} pk=${pkCount} fk=${fkCount} rls=${policyCount} rows≈${rowEstimate}`)}`);
115163
+ await writeLine(ctx, ` ${import_picocolors11.default.cyan(`${table.schema}.${table.name}`)} ${import_picocolors11.default.dim(`cols=${table.columns.length} pk=${pkCount} fk=${fkCount} rls=${policyCount} rows≈${rowEstimate}`)}`);
114442
115164
  }
114443
115165
  if (result.storageBuckets.length > 0) {
114444
115166
  await writeLine(ctx, "");
114445
- await writeLine(ctx, import_picocolors10.default.bold("Storage buckets"));
115167
+ await writeLine(ctx, import_picocolors11.default.bold("Storage buckets"));
114446
115168
  for (const bucket of result.storageBuckets) {
114447
115169
  const visibility = bucket.public ? "public" : "private";
114448
- await writeLine(ctx, ` ${import_picocolors10.default.cyan(bucket.name)} ${import_picocolors10.default.dim(`(${visibility})`)}`);
115170
+ await writeLine(ctx, ` ${import_picocolors11.default.cyan(bucket.name)} ${import_picocolors11.default.dim(`(${visibility})`)}`);
114449
115171
  }
114450
115172
  }
114451
115173
  if (result.authUsersCount !== null) {
114452
115174
  await writeLine(ctx, "");
114453
- await writeLine(ctx, `${import_picocolors10.default.bold("Auth users")}: ${result.authUsersCount}`);
115175
+ await writeLine(ctx, `${import_picocolors11.default.bold("Auth users")}: ${result.authUsersCount}`);
114454
115176
  }
114455
115177
  if (result.warnings.length > 0) {
114456
115178
  await writeLine(ctx, "");
114457
- await writeLine(ctx, import_picocolors10.default.bold(`Warnings (${result.warnings.length})`));
115179
+ await writeLine(ctx, import_picocolors11.default.bold(`Warnings (${result.warnings.length})`));
114458
115180
  for (const warning of result.warnings) {
114459
- await writeLine(ctx, ` ${import_picocolors10.default.yellow("⚠")} ${warning}`);
115181
+ await writeLine(ctx, ` ${import_picocolors11.default.yellow("⚠")} ${warning}`);
114460
115182
  }
114461
115183
  }
114462
115184
  await writeLine(ctx, "");
114463
- await writeLine(ctx, import_picocolors10.default.dim("--inspect-only is read-only. Drop the flag to also write the Phase B PocketBase-schema preview."));
115185
+ await writeLine(ctx, import_picocolors11.default.dim("--inspect-only is read-only. Drop the flag to also write the Phase B PocketBase-schema preview."));
114464
115186
  }
114465
115187
  async function renderSchemaHuman(ctx, result) {
114466
115188
  if (result.status === "failed") {
114467
115189
  const err = result.error;
114468
- await writeLine(ctx, `${import_picocolors10.default.red("✗")} ${err?.title ?? "Migration preview failed"}`);
115190
+ await writeLine(ctx, `${import_picocolors11.default.red("✗")} ${err?.title ?? "Migration preview failed"}`);
114469
115191
  if (err?.explanation)
114470
115192
  await writeLine(ctx, ` ${err.explanation}`);
114471
115193
  if (err?.suggestion)
114472
- await writeLine(ctx, import_picocolors10.default.dim(` ${err.suggestion}`));
115194
+ await writeLine(ctx, import_picocolors11.default.dim(` ${err.suggestion}`));
114473
115195
  return;
114474
115196
  }
114475
- await writeLine(ctx, `${import_picocolors10.default.green("✓")} ${result.summary}`);
115197
+ await writeLine(ctx, `${import_picocolors11.default.green("✓")} ${result.summary}`);
114476
115198
  await writeLine(ctx, "");
114477
- await writeLine(ctx, import_picocolors10.default.bold("Files written"));
115199
+ await writeLine(ctx, import_picocolors11.default.bold("Files written"));
114478
115200
  for (const path of result.filesWritten) {
114479
- await writeLine(ctx, ` ${import_picocolors10.default.cyan(path)}`);
115201
+ await writeLine(ctx, ` ${import_picocolors11.default.cyan(path)}`);
114480
115202
  }
114481
115203
  const preview = result.preview;
114482
115204
  if (!preview)
114483
115205
  return;
114484
115206
  if (preview.collections.length > 0) {
114485
115207
  await writeLine(ctx, "");
114486
- await writeLine(ctx, import_picocolors10.default.bold("Collections"));
115208
+ await writeLine(ctx, import_picocolors11.default.bold("Collections"));
114487
115209
  for (const collection of preview.collections) {
114488
115210
  const fields = collection.schema.length;
114489
115211
  const relations = collection.schema.filter((f) => f.type === "relation").length;
114490
- await writeLine(ctx, ` ${import_picocolors10.default.cyan(collection.name)} ${import_picocolors10.default.dim(`← ${collection.sourceTable} fields=${fields} relations=${relations}`)}`);
115212
+ await writeLine(ctx, ` ${import_picocolors11.default.cyan(collection.name)} ${import_picocolors11.default.dim(`← ${collection.sourceTable} fields=${fields} relations=${relations}`)}`);
114491
115213
  }
114492
115214
  }
114493
115215
  if (preview.warnings.length > 0) {
114494
115216
  await writeLine(ctx, "");
114495
- await writeLine(ctx, import_picocolors10.default.bold(`Warnings (${preview.warnings.length})`));
115217
+ await writeLine(ctx, import_picocolors11.default.bold(`Warnings (${preview.warnings.length})`));
114496
115218
  for (const warning of preview.warnings.slice(0, 10)) {
114497
- await writeLine(ctx, ` ${import_picocolors10.default.yellow("⚠")} ${warning}`);
115219
+ await writeLine(ctx, ` ${import_picocolors11.default.yellow("⚠")} ${warning}`);
114498
115220
  }
114499
115221
  if (preview.warnings.length > 10) {
114500
- await writeLine(ctx, import_picocolors10.default.dim(` …and ${preview.warnings.length - 10} more — see MIGRATION_NOTES.md`));
115222
+ await writeLine(ctx, import_picocolors11.default.dim(` …and ${preview.warnings.length - 10} more — see MIGRATION_NOTES.md`));
114501
115223
  }
114502
115224
  }
114503
115225
  await writeLine(ctx, "");
114504
- await writeLine(ctx, import_picocolors10.default.dim("Phase B is a preview. Phase C (data import) and Phase D (client rewrite) ship in later releases. Review MIGRATION_NOTES.md before any import."));
115226
+ await writeLine(ctx, import_picocolors11.default.dim("Phase B is a preview. Phase C (data import) and Phase D (client rewrite) ship in later releases. Review MIGRATION_NOTES.md before any import."));
114505
115227
  }
114506
115228
 
114507
115229
  // src/commands/sharing.ts
114508
- var import_picocolors11 = __toESM(require_picocolors(), 1);
115230
+ var import_picocolors12 = __toESM(require_picocolors(), 1);
114509
115231
  function registerSharingCommands(cli, ctx) {
114510
115232
  cli.command("share [action] [arg] [valueOrRole]", "Per-app collaborator invites: list | invite <email> | role <shareId> <role> | revoke <shareId>").option("--app <name>", "App name (overrides percher.toml)").option("--role <role>", "(invite only) viewer | admin — viewer is read-only, admin can deploy + change config").action(async (action, arg, valueOrRole, options) => {
114511
115233
  if (action === "list" || action === undefined) {
114512
115234
  const rows = await shareList(ctx, { app: options.app });
114513
115235
  if (rows.length === 0) {
114514
- await writeLine(ctx, import_picocolors11.default.dim("No collaborators."));
115236
+ await writeLine(ctx, import_picocolors12.default.dim("No collaborators."));
114515
115237
  return;
114516
115238
  }
114517
115239
  for (const r of rows) {
114518
- const state = r.accepted ? import_picocolors11.default.green("accepted") : import_picocolors11.default.yellow("pending");
114519
- const roleStr = r.role === "admin" ? import_picocolors11.default.cyan("admin") : import_picocolors11.default.dim("viewer");
115240
+ const state = r.accepted ? import_picocolors12.default.green("accepted") : import_picocolors12.default.yellow("pending");
115241
+ const roleStr = r.role === "admin" ? import_picocolors12.default.cyan("admin") : import_picocolors12.default.dim("viewer");
114520
115242
  await writeLine(ctx, `${r.id} ${roleStr.padEnd(15)} ${state.padEnd(15)} ${r.email}`);
114521
115243
  }
114522
115244
  return;
@@ -114534,8 +115256,8 @@ function registerSharingCommands(cli, ctx) {
114534
115256
  email: arg,
114535
115257
  role: roleRaw
114536
115258
  });
114537
- await writeLine(ctx, `${import_picocolors11.default.green("✓")} Invited ${created.email} as ${created.role} (id ${created.id})`);
114538
- await writeLine(ctx, import_picocolors11.default.dim(`Pending until accepted — invite expires ${new Date(created.expiresAt).toLocaleDateString()}.`));
115259
+ await writeLine(ctx, `${import_picocolors12.default.green("✓")} Invited ${created.email} as ${created.role} (id ${created.id})`);
115260
+ await writeLine(ctx, import_picocolors12.default.dim(`Pending until accepted — invite expires ${new Date(created.expiresAt).toLocaleDateString()}.`));
114539
115261
  return;
114540
115262
  }
114541
115263
  if (action === "role") {
@@ -114550,7 +115272,7 @@ function registerSharingCommands(cli, ctx) {
114550
115272
  shareId: arg,
114551
115273
  role: valueOrRole
114552
115274
  });
114553
- await writeLine(ctx, `${import_picocolors11.default.green("✓")} ${updated.email} is now ${import_picocolors11.default.cyan(updated.role)}`);
115275
+ await writeLine(ctx, `${import_picocolors12.default.green("✓")} ${updated.email} is now ${import_picocolors12.default.cyan(updated.role)}`);
114554
115276
  return;
114555
115277
  }
114556
115278
  if (action === "revoke") {
@@ -114558,7 +115280,7 @@ function registerSharingCommands(cli, ctx) {
114558
115280
  throw new Error("Usage: percher share revoke <shareId> [--app <name>]");
114559
115281
  }
114560
115282
  await shareRevoke(ctx, { app: options.app, shareId: arg });
114561
- await writeLine(ctx, `${import_picocolors11.default.green("✓")} Revoked share ${arg}`);
115283
+ await writeLine(ctx, `${import_picocolors12.default.green("✓")} Revoked share ${arg}`);
114562
115284
  return;
114563
115285
  }
114564
115286
  throw new Error(`Unknown share action "${action}". Use list | invite <email> | role <shareId> <role> | revoke <shareId>.`);
@@ -114571,11 +115293,11 @@ function registerSharingCommands(cli, ctx) {
114571
115293
  if (action === undefined || action === "list") {
114572
115294
  const pending = await invitesList(ctx, {});
114573
115295
  if (pending.length === 0) {
114574
- await writeLine(ctx, import_picocolors11.default.dim("No pending invites."));
115296
+ await writeLine(ctx, import_picocolors12.default.dim("No pending invites."));
114575
115297
  return;
114576
115298
  }
114577
115299
  for (const inv of pending) {
114578
- const roleStr = inv.role === "admin" ? import_picocolors11.default.cyan("admin") : import_picocolors11.default.dim("viewer");
115300
+ const roleStr = inv.role === "admin" ? import_picocolors12.default.cyan("admin") : import_picocolors12.default.dim("viewer");
114579
115301
  await writeLine(ctx, `${inv.id} ${roleStr.padEnd(15)} ${inv.appName.padEnd(24)} from ${inv.invitedBy}`);
114580
115302
  }
114581
115303
  return;
@@ -114586,10 +115308,10 @@ function registerSharingCommands(cli, ctx) {
114586
115308
  }
114587
115309
  const accepted = await invitesAccept(ctx, { shareId });
114588
115310
  if (accepted.alreadyAccepted) {
114589
- await writeLine(ctx, import_picocolors11.default.dim(`Invite ${shareId} was already accepted.`));
115311
+ await writeLine(ctx, import_picocolors12.default.dim(`Invite ${shareId} was already accepted.`));
114590
115312
  return;
114591
115313
  }
114592
- await writeLine(ctx, `${import_picocolors11.default.green("✓")} Accepted — you now have ${import_picocolors11.default.cyan(accepted.role)} access to the shared app.`);
115314
+ await writeLine(ctx, `${import_picocolors12.default.green("✓")} Accepted — you now have ${import_picocolors12.default.cyan(accepted.role)} access to the shared app.`);
114593
115315
  return;
114594
115316
  }
114595
115317
  if (action === "decline") {
@@ -114597,7 +115319,7 @@ function registerSharingCommands(cli, ctx) {
114597
115319
  throw new Error("Usage: percher invites decline <shareId>");
114598
115320
  }
114599
115321
  await invitesDecline(ctx, { shareId });
114600
- await writeLine(ctx, `${import_picocolors11.default.green("✓")} Declined invite ${shareId}.`);
115322
+ await writeLine(ctx, `${import_picocolors12.default.green("✓")} Declined invite ${shareId}.`);
114601
115323
  return;
114602
115324
  }
114603
115325
  throw new Error(`Unknown invites action "${action}". Use list | accept <shareId> | decline <shareId>.`);
@@ -114608,7 +115330,7 @@ function registerSharingCommands(cli, ctx) {
114608
115330
  }
114609
115331
 
114610
115332
  // src/commands/status.ts
114611
- var import_picocolors12 = __toESM(require_picocolors(), 1);
115333
+ var import_picocolors13 = __toESM(require_picocolors(), 1);
114612
115334
  function registerStatusCommands(cli, ctx) {
114613
115335
  cli.command("status", "Show Percher platform status (API + worker health)").option("--json", "Output JSON instead of formatted text").action(async (options) => {
114614
115336
  const result = await status(ctx, { json: Boolean(options.json) });
@@ -114624,44 +115346,44 @@ function registerStatusCommands(cli, ctx) {
114624
115346
  });
114625
115347
  }
114626
115348
  async function renderHumanStatus(ctx, result) {
114627
- const symbol2 = result.verdict === "operational" ? import_picocolors12.default.green("✓") : result.verdict === "worker-degraded" ? import_picocolors12.default.yellow("⚠") : import_picocolors12.default.red("✗");
115349
+ const symbol2 = result.verdict === "operational" ? import_picocolors13.default.green("✓") : result.verdict === "worker-degraded" ? import_picocolors13.default.yellow("⚠") : import_picocolors13.default.red("✗");
114628
115350
  await writeLine(ctx, `${symbol2} ${result.summary}`);
114629
115351
  if (!result.health)
114630
115352
  return;
114631
115353
  const worker = result.health.worker;
114632
115354
  if (!worker) {
114633
- await writeLine(ctx, import_picocolors12.default.dim(" worker: not configured (local Docker socket)"));
115355
+ await writeLine(ctx, import_picocolors13.default.dim(" worker: not configured (local Docker socket)"));
114634
115356
  return;
114635
115357
  }
114636
115358
  if (worker.health) {
114637
115359
  const ok = worker.health.ok;
114638
- const symbol3 = ok ? import_picocolors12.default.green("✓") : import_picocolors12.default.red("✗");
115360
+ const symbol3 = ok ? import_picocolors13.default.green("✓") : import_picocolors13.default.red("✗");
114639
115361
  const lastOk = worker.health.lastOkAt ?? "never";
114640
- await writeLine(ctx, ` ${symbol3} worker health: ${ok ? "ok" : `failing (${worker.health.consecutiveErrs} consecutive)`} last-ok=${import_picocolors12.default.dim(lastOk)}`);
115362
+ await writeLine(ctx, ` ${symbol3} worker health: ${ok ? "ok" : `failing (${worker.health.consecutiveErrs} consecutive)`} last-ok=${import_picocolors13.default.dim(lastOk)}`);
114641
115363
  }
114642
115364
  if (worker.circuitBreaker) {
114643
115365
  const breaker = worker.circuitBreaker;
114644
- const symbol3 = breaker.open ? import_picocolors12.default.red("✗") : import_picocolors12.default.green("✓");
115366
+ const symbol3 = breaker.open ? import_picocolors13.default.red("✗") : import_picocolors13.default.green("✓");
114645
115367
  const state = breaker.open ? "open (deploys fail fast)" : "closed";
114646
115368
  await writeLine(ctx, ` ${symbol3} circuit breaker: ${state} ${breaker.failuresInWindow}/${breaker.threshold} failures in last ${Math.round(breaker.windowMs / 1000)}s`);
114647
115369
  }
114648
115370
  if (result.health.version) {
114649
- await writeLine(ctx, import_picocolors12.default.dim(` api version: ${result.health.version}`));
115371
+ await writeLine(ctx, import_picocolors13.default.dim(` api version: ${result.health.version}`));
114650
115372
  }
114651
115373
  }
114652
115374
 
114653
115375
  // src/commands/transfers.ts
114654
- var import_picocolors13 = __toESM(require_picocolors(), 1);
115376
+ var import_picocolors14 = __toESM(require_picocolors(), 1);
114655
115377
  function registerTransfersCommands(cli, ctx) {
114656
115378
  cli.command("transfer [emailOrTransferId]", "Transfer ownership of an app: `transfer <email>` initiates; `transfer --cancel <id>` cancels; `transfer --list` shows history").option("--app <name>", "App name (overrides percher.toml)").option("--cancel", "Cancel a pending transfer by id (positional arg is the transferId)").option("--list", "List the transfer history for this app").action(async (emailOrTransferId, options) => {
114657
115379
  if (options.list) {
114658
115380
  const rows = await transferListForApp(ctx, { app: options.app });
114659
115381
  if (rows.length === 0) {
114660
- await writeLine(ctx, import_picocolors13.default.dim("No transfers for this app."));
115382
+ await writeLine(ctx, import_picocolors14.default.dim("No transfers for this app."));
114661
115383
  return;
114662
115384
  }
114663
115385
  for (const r of rows) {
114664
- const status2 = r.status === "pending" ? import_picocolors13.default.yellow("pending") : r.status === "accepted" ? import_picocolors13.default.green("accepted") : import_picocolors13.default.dim(r.status);
115386
+ const status2 = r.status === "pending" ? import_picocolors14.default.yellow("pending") : r.status === "accepted" ? import_picocolors14.default.green("accepted") : import_picocolors14.default.dim(r.status);
114665
115387
  await writeLine(ctx, `${r.id} ${status2.padEnd(20)} → ${r.toEmail} (${r.createdAt})`);
114666
115388
  }
114667
115389
  return;
@@ -114674,7 +115396,7 @@ function registerTransfersCommands(cli, ctx) {
114674
115396
  app: options.app,
114675
115397
  transferId: emailOrTransferId
114676
115398
  });
114677
- await writeLine(ctx, `${import_picocolors13.default.green("✓")} Cancelled transfer ${emailOrTransferId}`);
115399
+ await writeLine(ctx, `${import_picocolors14.default.green("✓")} Cancelled transfer ${emailOrTransferId}`);
114678
115400
  return;
114679
115401
  }
114680
115402
  if (!emailOrTransferId) {
@@ -114684,17 +115406,17 @@ function registerTransfersCommands(cli, ctx) {
114684
115406
  app: options.app,
114685
115407
  email: emailOrTransferId
114686
115408
  });
114687
- await writeLine(ctx, `${import_picocolors13.default.green("✓")} Transfer initiated to ${initiated.toEmail} (id ${initiated.id})`);
114688
- await writeLine(ctx, import_picocolors13.default.dim(`Expires ${new Date(initiated.expiresAt).toLocaleDateString()}. The recipient must accept via 'percher transfers accept ${initiated.id}'.`));
115409
+ await writeLine(ctx, `${import_picocolors14.default.green("✓")} Transfer initiated to ${initiated.toEmail} (id ${initiated.id})`);
115410
+ await writeLine(ctx, import_picocolors14.default.dim(`Expires ${new Date(initiated.expiresAt).toLocaleDateString()}. The recipient must accept via 'percher transfers accept ${initiated.id}'.`));
114689
115411
  if (initiated.notes) {
114690
- await writeLine(ctx, import_picocolors13.default.yellow(` ⚠ ${initiated.notes}`));
115412
+ await writeLine(ctx, import_picocolors14.default.yellow(` ⚠ ${initiated.notes}`));
114691
115413
  }
114692
115414
  });
114693
115415
  cli.command("transfers [action] [transferId]", "Incoming ownership transfers: list (default) | accept <transferId> | decline <transferId>").action(async (action, transferId) => {
114694
115416
  if (action === undefined || action === "list") {
114695
115417
  const rows = await transfersIncoming(ctx, {});
114696
115418
  if (rows.length === 0) {
114697
- await writeLine(ctx, import_picocolors13.default.dim("No pending incoming transfers."));
115419
+ await writeLine(ctx, import_picocolors14.default.dim("No pending incoming transfers."));
114698
115420
  return;
114699
115421
  }
114700
115422
  for (const r of rows) {
@@ -114707,7 +115429,7 @@ function registerTransfersCommands(cli, ctx) {
114707
115429
  throw new Error("Usage: percher transfers accept <transferId>");
114708
115430
  }
114709
115431
  const accepted = await transfersAccept(ctx, { transferId });
114710
- await writeLine(ctx, `${import_picocolors13.default.green("✓")} You now own ${import_picocolors13.default.cyan(accepted.appName)}. Run 'percher open --app ${accepted.appName}' to see it.`);
115432
+ await writeLine(ctx, `${import_picocolors14.default.green("✓")} You now own ${import_picocolors14.default.cyan(accepted.appName)}. Run 'percher open --app ${accepted.appName}' to see it.`);
114711
115433
  return;
114712
115434
  }
114713
115435
  if (action === "decline") {
@@ -114715,7 +115437,7 @@ function registerTransfersCommands(cli, ctx) {
114715
115437
  throw new Error("Usage: percher transfers decline <transferId>");
114716
115438
  }
114717
115439
  await transfersDecline(ctx, { transferId });
114718
- await writeLine(ctx, `${import_picocolors13.default.green("✓")} Declined transfer ${transferId}.`);
115440
+ await writeLine(ctx, `${import_picocolors14.default.green("✓")} Declined transfer ${transferId}.`);
114719
115441
  return;
114720
115442
  }
114721
115443
  throw new Error(`Unknown transfers action "${action}". Use list | accept <id> | decline <id>.`);
@@ -114784,40 +115506,52 @@ async function createCliContext() {
114784
115506
  }
114785
115507
 
114786
115508
  // src/output.ts
114787
- var import_picocolors14 = __toESM(require_picocolors(), 1);
115509
+ var import_picocolors15 = __toESM(require_picocolors(), 1);
114788
115510
  async function renderError(stderr, error48) {
114789
115511
  const writer = stderr.getWriter();
114790
115512
  const encoder = new TextEncoder;
114791
115513
  const lines = [];
114792
115514
  if (error48 instanceof PercherTomlError) {
114793
- lines.push(`${import_picocolors14.default.red(`error (${error48.code}):`)} ${error48.message}`);
115515
+ lines.push(`${import_picocolors15.default.red(`error (${error48.code}):`)} ${error48.message}`);
114794
115516
  if (error48.tomlLine !== undefined) {
114795
- lines.push(import_picocolors14.default.dim(` at percher.toml line ${error48.tomlLine}`));
115517
+ lines.push(import_picocolors15.default.dim(` at percher.toml line ${error48.tomlLine}`));
114796
115518
  }
114797
115519
  for (const issue2 of error48.issues ?? []) {
114798
- lines.push(`${import_picocolors14.default.dim(" · ") + import_picocolors14.default.yellow(issue2.path.join("."))}: ${issue2.message}`);
115520
+ lines.push(`${import_picocolors15.default.dim(" · ") + import_picocolors15.default.yellow(issue2.path.join("."))}: ${issue2.message}`);
115521
+ }
115522
+ } else if (error48 instanceof SunsetError) {
115523
+ if (error48.gateCode === "platform_delete_gated") {
115524
+ lines.push(`${import_picocolors15.default.yellow("Percher is shutting down — deletes are paused.")}`);
115525
+ lines.push(error48.message);
115526
+ lines.push(import_picocolors15.default.dim(` To delete and permanently forfeit your export window, confirm with your password in the dashboard: ${error48.dashboardUrl}`));
115527
+ lines.push(import_picocolors15.default.dim(" The CLI cannot perform this delete during sunset — it must go through the dashboard."));
115528
+ } else {
115529
+ lines.push(`${import_picocolors15.default.yellow("Percher is in read-only mode ahead of shutdown.")}`);
115530
+ lines.push(error48.message);
115531
+ lines.push(import_picocolors15.default.dim(` Your apps keep running. Export them any time from the dashboard: ${error48.dashboardUrl}`));
114799
115532
  }
115533
+ lines.push(import_picocolors15.default.dim(` Final data deletion: ${error48.finalDeletionDate}`));
114800
115534
  } else if (error48 instanceof PercherApiError) {
114801
115535
  if (error48.status === 502 || error48.status === 503) {
114802
- lines.push(`${import_picocolors14.default.red("error:")} Percher API is temporarily unavailable (HTTP ${error48.status}). Try again in a few seconds.`);
115536
+ lines.push(`${import_picocolors15.default.red("error:")} Percher API is temporarily unavailable (HTTP ${error48.status}). Try again in a few seconds.`);
114803
115537
  } else {
114804
- lines.push(`${import_picocolors14.default.red("error:")} ${error48.message}`);
114805
- lines.push(import_picocolors14.default.dim(` ${error48.code} (HTTP ${error48.status})`));
115538
+ lines.push(`${import_picocolors15.default.red("error:")} ${error48.message}`);
115539
+ lines.push(import_picocolors15.default.dim(` ${error48.code} (HTTP ${error48.status})`));
114806
115540
  }
114807
115541
  if (error48.requestId)
114808
- lines.push(import_picocolors14.default.dim(` request: ${error48.requestId}`));
115542
+ lines.push(import_picocolors15.default.dim(` request: ${error48.requestId}`));
114809
115543
  } else if (error48 instanceof PercherCoreError) {
114810
- lines.push(`${import_picocolors14.default.red(`error (${error48.code}):`)} ${error48.message}`);
115544
+ lines.push(`${import_picocolors15.default.red(`error (${error48.code}):`)} ${error48.message}`);
114811
115545
  if (error48.hint)
114812
- lines.push(import_picocolors14.default.dim(` ${error48.hint}`));
115546
+ lines.push(import_picocolors15.default.dim(` ${error48.hint}`));
114813
115547
  } else if (error48 instanceof Error) {
114814
- lines.push(`${import_picocolors14.default.red("unexpected error:")} ${error48.message}`);
115548
+ lines.push(`${import_picocolors15.default.red("unexpected error:")} ${error48.message}`);
114815
115549
  if (error48.stack)
114816
- lines.push(import_picocolors14.default.dim(error48.stack.split(`
115550
+ lines.push(import_picocolors15.default.dim(error48.stack.split(`
114817
115551
  `).slice(1, 4).join(`
114818
115552
  `)));
114819
115553
  } else {
114820
- lines.push(`${import_picocolors14.default.red("unexpected error:")} ${String(error48)}`);
115554
+ lines.push(`${import_picocolors15.default.red("unexpected error:")} ${String(error48)}`);
114821
115555
  }
114822
115556
  try {
114823
115557
  await writer.write(encoder.encode(`${lines.join(`
@@ -114830,7 +115564,7 @@ async function renderError(stderr, error48) {
114830
115564
  // package.json
114831
115565
  var package_default = {
114832
115566
  name: "percher",
114833
- version: "0.4.1",
115567
+ version: "0.5.0",
114834
115568
  description: "CLI for the Percher hosting platform — publish apps with one command",
114835
115569
  type: "module",
114836
115570
  bin: {
@@ -114911,23 +115645,23 @@ async function showTelemetryNotice() {
114911
115645
  if (isDisabled())
114912
115646
  return;
114913
115647
  const { homedir: homedir2 } = await import("node:os");
114914
- const { existsSync: existsSync16, mkdirSync: mkdirSync6, writeFileSync: writeFileSync9 } = await import("node:fs");
114915
- const { join: join23 } = await import("node:path");
114916
- const configDir = join23(homedir2(), ".percher");
114917
- const markerFile = join23(configDir, ".telemetry-notice-shown");
114918
- 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))
114919
115653
  return;
114920
115654
  console.log("Percher sends non-account-linked usage stats (command, result, error type, OS, Bun version).");
114921
115655
  console.log(`Disable with PERCHER_NO_TELEMETRY=1
114922
115656
  `);
114923
115657
  try {
114924
115658
  mkdirSync6(configDir, { recursive: true });
114925
- writeFileSync9(markerFile, new Date().toISOString());
115659
+ writeFileSync10(markerFile, new Date().toISOString());
114926
115660
  } catch {}
114927
115661
  }
114928
115662
 
114929
115663
  // src/version-check.ts
114930
- var import_picocolors15 = __toESM(require_picocolors(), 1);
115664
+ var import_picocolors16 = __toESM(require_picocolors(), 1);
114931
115665
  async function checkForUpdates() {
114932
115666
  try {
114933
115667
  const res = await fetch("https://registry.npmjs.org/percher/latest", {
@@ -114938,9 +115672,9 @@ async function checkForUpdates() {
114938
115672
  const data2 = await res.json();
114939
115673
  const latest = data2.version;
114940
115674
  if (latest && latest !== VERSION) {
114941
- console.error(import_picocolors15.default.yellow(`
115675
+ console.error(import_picocolors16.default.yellow(`
114942
115676
  Update available: ${VERSION} → ${latest}`));
114943
- console.error(import_picocolors15.default.dim(`Run: bunx percher@latest
115677
+ console.error(import_picocolors16.default.dim(`Run: bunx percher@latest
114944
115678
  `));
114945
115679
  }
114946
115680
  } catch {}
@@ -114953,6 +115687,7 @@ async function run(argv, ctxOverride) {
114953
115687
  registerAppsCommands(cli, ctx);
114954
115688
  registerDeploysCommands(cli, ctx, argv);
114955
115689
  registerEnvCommands(cli, ctx);
115690
+ registerExportCommands(cli, ctx);
114956
115691
  registerDataCommands(cli, ctx, argv);
114957
115692
  registerAuthCommands(cli, ctx);
114958
115693
  registerAccountCommands(cli, ctx);