percher 0.3.0 → 0.4.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.
@@ -10237,6 +10237,7 @@ function refineBunRuntime(detected, cwd) {
10237
10237
  }
10238
10238
  // ../core/dist/tarball.js
10239
10239
  var import_ignore = __toESM(require_ignore(), 1);
10240
+ import { createHash } from "node:crypto";
10240
10241
  import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, statSync } from "node:fs";
10241
10242
  import { join as join2, relative, sep } from "node:path";
10242
10243
  import { Readable } from "node:stream";
@@ -10287,18 +10288,29 @@ async function createTarball(options) {
10287
10288
  }
10288
10289
  if (options.extraIgnore)
10289
10290
  ig.add(options.extraIgnore);
10290
- const files = [];
10291
+ const fileBuffers = [];
10291
10292
  for (const rel of walk(options.cwd, options.cwd, ig)) {
10292
- const stats = statSync(join2(options.cwd, rel));
10293
- files.push({ path: rel, size: stats.size });
10293
+ const data = readFileSync2(join2(options.cwd, rel));
10294
+ fileBuffers.push({ path: rel, size: data.length, data });
10295
+ }
10296
+ fileBuffers.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
10297
+ const totalBytes = fileBuffers.reduce((sum, f) => sum + f.size, 0);
10298
+ const contentHasher = createHash("sha256");
10299
+ for (const f of fileBuffers) {
10300
+ contentHasher.update(`PATH:${f.path.length}:${f.path}
10301
+ `);
10302
+ contentHasher.update(`SIZE:${f.size}
10303
+ `);
10304
+ contentHasher.update(f.data);
10305
+ contentHasher.update(`
10306
+ `);
10294
10307
  }
10295
- const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
10308
+ const contentHash = contentHasher.digest("hex");
10296
10309
  const pack = $pack();
10297
10310
  (async () => {
10298
- for (const file of files) {
10299
- const data = readFileSync2(join2(options.cwd, file.path));
10311
+ for (const file of fileBuffers) {
10300
10312
  await new Promise((resolve, reject) => {
10301
- pack.entry({ name: file.path, size: data.length }, data, (err) => err ? reject(err) : resolve());
10313
+ pack.entry({ name: file.path, size: file.data.length }, file.data, (err) => err ? reject(err) : resolve());
10302
10314
  });
10303
10315
  }
10304
10316
  pack.finalize();
@@ -10306,12 +10318,13 @@ async function createTarball(options) {
10306
10318
  const gzip = createGzip();
10307
10319
  const nodeStream = pack.pipe(gzip);
10308
10320
  const webStream = Readable.toWeb(nodeStream);
10309
- const topFiles = [...files].sort((a, b) => b.size - a.size).slice(0, 5);
10321
+ const topFiles = [...fileBuffers].sort((a, b) => b.size - a.size).slice(0, 5).map((f) => ({ path: f.path, size: f.size }));
10310
10322
  return {
10311
10323
  stream: webStream,
10312
10324
  bytes: totalBytes,
10313
- fileCount: files.length,
10314
- topFiles
10325
+ fileCount: fileBuffers.length,
10326
+ topFiles,
10327
+ contentHash
10315
10328
  };
10316
10329
  }
10317
10330
  // ../core/dist/env-scan-source.js
@@ -14652,10 +14665,160 @@ function readPercherTomlAppName(cwd) {
14652
14665
  return null;
14653
14666
  }
14654
14667
  }
14668
+ // ../core/dist/event-renderer.js
14669
+ function renderDeployEvent(event) {
14670
+ if (event.stage === "queue") {
14671
+ if (event.status === "started") {
14672
+ return `Queued behind ${event.message ?? "other builds"} — your turn shortly`;
14673
+ }
14674
+ return null;
14675
+ }
14676
+ if (event.stage === "build")
14677
+ return renderBuildEvent(event);
14678
+ if (event.stage === "start") {
14679
+ if (event.status === "started")
14680
+ return "Starting container";
14681
+ if (event.status === "ok")
14682
+ return "Container started";
14683
+ if (event.status === "failed")
14684
+ return "Container failed to start";
14685
+ return null;
14686
+ }
14687
+ if (event.stage === "health") {
14688
+ if (event.status === "started")
14689
+ return "Checking health";
14690
+ if (event.status === "ok")
14691
+ return "Health check passed";
14692
+ if (event.status === "failed")
14693
+ return "Health check failed";
14694
+ return null;
14695
+ }
14696
+ if (event.stage === "swap") {
14697
+ if (event.status === "ok")
14698
+ return "Traffic swapped to new version";
14699
+ if (event.status === "failed")
14700
+ return "Traffic swap failed";
14701
+ return null;
14702
+ }
14703
+ if (event.stage === "canary") {
14704
+ if (event.status === "started")
14705
+ return "Canary monitor running";
14706
+ if (event.status === "ok")
14707
+ return "Canary monitor passed";
14708
+ if (event.status === "failed")
14709
+ return "Canary failed — rolling back";
14710
+ return null;
14711
+ }
14712
+ if (event.stage === "rollback") {
14713
+ if (event.status === "ok")
14714
+ return "Rolled back to previous version";
14715
+ if (event.status === "failed")
14716
+ return "Rollback failed";
14717
+ return null;
14718
+ }
14719
+ return null;
14720
+ }
14721
+ function renderBuildEvent(event) {
14722
+ if (event.subStage === "cache_hit" && event.status === "ok") {
14723
+ return "Build cache hit (reused image)";
14724
+ }
14725
+ if (event.subStage === "deps_install" && event.status === "ok") {
14726
+ return withDuration("Dependencies installed", event.durationMs);
14727
+ }
14728
+ if (event.subStage === "build_command" && event.status === "ok") {
14729
+ return withDuration("Build command finished", event.durationMs);
14730
+ }
14731
+ if (event.subStage === "image_finalize" && event.status === "ok") {
14732
+ return withDuration("Image built", event.durationMs);
14733
+ }
14734
+ if (event.subStage === "docker_layers" && event.status === "ok") {
14735
+ return withDuration("Docker layers built", event.durationMs);
14736
+ }
14737
+ if (event.subStage !== null) {
14738
+ if (event.status === "started")
14739
+ return `${labelForSubStage(event.subStage)}…`;
14740
+ if (event.status === "failed")
14741
+ return `${labelForSubStage(event.subStage)} failed`;
14742
+ return null;
14743
+ }
14744
+ if (event.status === "started")
14745
+ return "Build started";
14746
+ if (event.status === "failed")
14747
+ return "Build failed";
14748
+ return null;
14749
+ }
14750
+ function labelForSubStage(subStage) {
14751
+ switch (subStage) {
14752
+ case "deps_install":
14753
+ return "Installing dependencies";
14754
+ case "build_command":
14755
+ return "Running build command";
14756
+ case "image_finalize":
14757
+ return "Finalizing image";
14758
+ case "docker_layers":
14759
+ return "Building Docker layers";
14760
+ case "cache_hit":
14761
+ return "Reusing cached image";
14762
+ }
14763
+ }
14764
+ function withDuration(label, durationMs) {
14765
+ if (durationMs === null || durationMs <= 0)
14766
+ return label;
14767
+ if (durationMs < 1000)
14768
+ return `${label} (${durationMs}ms)`;
14769
+ const seconds = (durationMs / 1000).toFixed(1);
14770
+ return `${label} (${seconds}s)`;
14771
+ }
14655
14772
  // ../core/dist/commands/whoami.js
14656
14773
  async function whoami(ctx) {
14657
14774
  return ctx.client.auth.whoami();
14658
14775
  }
14776
+ // ../core/dist/commands/status.js
14777
+ var statusInputSchema = exports_external.object({
14778
+ json: exports_external.boolean().optional()
14779
+ });
14780
+ async function status(ctx, _input = {}) {
14781
+ let health;
14782
+ try {
14783
+ health = await ctx.client.platform.health();
14784
+ } catch (err) {
14785
+ return {
14786
+ verdict: "unreachable",
14787
+ summary: `API unreachable (${err.message})`,
14788
+ health: null,
14789
+ reachError: err.message
14790
+ };
14791
+ }
14792
+ const verdict = deriveVerdict(health);
14793
+ return {
14794
+ verdict,
14795
+ summary: summarize(verdict, health),
14796
+ health
14797
+ };
14798
+ }
14799
+ function deriveVerdict(health) {
14800
+ if (health.status !== "ok")
14801
+ return "worker-degraded";
14802
+ if (health.worker?.circuitBreaker?.open)
14803
+ return "worker-degraded";
14804
+ if (health.worker?.health && health.worker.health.ok === false)
14805
+ return "worker-degraded";
14806
+ return "operational";
14807
+ }
14808
+ function summarize(verdict, health) {
14809
+ if (verdict === "operational")
14810
+ return "All systems operational";
14811
+ const breaker = health.worker?.circuitBreaker;
14812
+ const workerHealth = health.worker?.health;
14813
+ if (breaker?.open) {
14814
+ return `Worker circuit breaker open (${breaker.failuresInWindow}/${breaker.threshold} failures in last ${Math.round(breaker.windowMs / 1000)}s) — deploys will fail fast until it recovers`;
14815
+ }
14816
+ if (workerHealth && workerHealth.ok === false) {
14817
+ const lastErr = workerHealth.lastErr ? ` — last error: ${workerHealth.lastErr}` : "";
14818
+ return `Worker unreachable (${workerHealth.consecutiveErrs} consecutive failed health checks)${lastErr}`;
14819
+ }
14820
+ return "API responded but reported a non-ok status";
14821
+ }
14659
14822
  // ../core/dist/commands/account.js
14660
14823
  import { mkdir, writeFile } from "node:fs/promises";
14661
14824
  import { join as join4, resolve } from "node:path";
@@ -14882,9 +15045,13 @@ class Http {
14882
15045
  const ab = await res.arrayBuffer();
14883
15046
  return new Uint8Array(ab);
14884
15047
  }
14885
- async postBinary(path, body) {
15048
+ async postBinary(path, body, extraHeaders) {
14886
15049
  const headers = this.buildHeaders();
14887
15050
  headers["Content-Type"] = "application/octet-stream";
15051
+ if (extraHeaders) {
15052
+ for (const [k, v] of Object.entries(extraHeaders))
15053
+ headers[k] = v;
15054
+ }
14888
15055
  const init = {
14889
15056
  method: "POST",
14890
15057
  headers,
@@ -14893,7 +15060,12 @@ class Http {
14893
15060
  if (body instanceof ReadableStream) {
14894
15061
  init.duplex = "half";
14895
15062
  }
14896
- const res = await this.fetchImpl(`${this.apiUrl}${path}`, init);
15063
+ let res;
15064
+ try {
15065
+ res = await this.fetchImpl(`${this.apiUrl}${path}`, init);
15066
+ } catch (err) {
15067
+ throw new Error(`network error: ${err.message}`, { cause: err });
15068
+ }
14897
15069
  if (!res.ok)
14898
15070
  throw await this.parseError(res);
14899
15071
  return await res.json();
@@ -15011,7 +15183,17 @@ class AppsResource {
15011
15183
  if (input.noCache)
15012
15184
  params.set("noCache", "1");
15013
15185
  const qs = params.toString();
15014
- return this.http.postBinary(`/apps/${encodeURIComponent(id)}/deploys${qs ? `?${qs}` : ""}`, input.tarball);
15186
+ const headers = {};
15187
+ if (input.idempotencyKey)
15188
+ headers["Idempotency-Key"] = input.idempotencyKey;
15189
+ if (input.traceId)
15190
+ headers["X-Trace-Id"] = input.traceId;
15191
+ if (input.tarballHash)
15192
+ headers["X-Tarball-Hash"] = input.tarballHash;
15193
+ if (typeof input.retriedAttempts === "number" && input.retriedAttempts > 0) {
15194
+ headers["X-Publish-Attempts"] = String(input.retriedAttempts);
15195
+ }
15196
+ return this.http.postBinary(`/apps/${encodeURIComponent(id)}/deploys${qs ? `?${qs}` : ""}`, input.tarball, headers);
15015
15197
  }
15016
15198
  promotePreview(appRef, deployId) {
15017
15199
  return this.http.post(`/apps/${encodeURIComponent(appRef)}/deploys/${encodeURIComponent(deployId)}/promote`, {});
@@ -15355,6 +15537,23 @@ class LogsResource {
15355
15537
  }
15356
15538
  }
15357
15539
 
15540
+ // ../client/dist/resources/platform.js
15541
+ class PlatformResource {
15542
+ http;
15543
+ constructor(http) {
15544
+ this.http = http;
15545
+ }
15546
+ health() {
15547
+ return this.http.get("/health");
15548
+ }
15549
+ preflight() {
15550
+ return this.http.get("/publish/preflight");
15551
+ }
15552
+ cacheProbe(input) {
15553
+ return this.http.post("/publish/cache-probe", input);
15554
+ }
15555
+ }
15556
+
15358
15557
  // ../client/dist/client.js
15359
15558
  class PercherClient {
15360
15559
  apiUrl;
@@ -15366,6 +15565,7 @@ class PercherClient {
15366
15565
  auth;
15367
15566
  billing;
15368
15567
  account;
15568
+ platform;
15369
15569
  constructor(options = {}) {
15370
15570
  const defaults = loadConfig();
15371
15571
  this.apiUrl = options.apiUrl ?? defaults.apiUrl;
@@ -15383,6 +15583,7 @@ class PercherClient {
15383
15583
  this.auth = new AuthResource(http);
15384
15584
  this.billing = new BillingResource(http);
15385
15585
  this.account = new AccountResource(http);
15586
+ this.platform = new PlatformResource(http);
15386
15587
  }
15387
15588
  }
15388
15589
  // ../client/dist/build-env-prefixes.js
@@ -15636,6 +15837,17 @@ var unsuspendInputSchema = exports_external.object({
15636
15837
  import { existsSync as existsSync6 } from "node:fs";
15637
15838
  import { join as join7 } from "node:path";
15638
15839
 
15840
+ // ../shared/dist/timeouts.js
15841
+ var TIMEOUTS = {
15842
+ workerBuildSubprocess: 10 * 60000,
15843
+ apiToWorkerBuildFetch: 11 * 60000,
15844
+ clientPublishPoll: 12 * 60000,
15845
+ workerHealthcheck: 5000,
15846
+ watchdogList: 15000,
15847
+ mcpWaitDeployDefault: 30000,
15848
+ mcpWaitDeployMax: 12 * 60000
15849
+ };
15850
+
15639
15851
  // ../core/dist/error-classifier.js
15640
15852
  function extractEnvVarsFromLog(combined) {
15641
15853
  const envVarPattern = /process\.env\.(\w+)\s*(?:is\s+)?(?:undefined|not set)|(?:missing|undefined).*?(\b[A-Z][A-Z0-9_]{2,}\b)/gi;
@@ -15666,7 +15878,7 @@ var patterns = [
15666
15878
  }
15667
15879
  },
15668
15880
  {
15669
- pattern: /Unable to connect.*?(access the url|cache\.nixos)|Could not resolve host|getaddrinfo ENOTFOUND|Worker \/[\w-]+(\/[\w-]+)? failed \(50\d\)|Failed to fetch.*?worker|deployer\/(run|build) failed|The socket connection was closed unexpectedly|WORKER_UNAVAILABLE:/i,
15881
+ pattern: /Unable to connect.*?(access the url|cache\.nixos)|Could not resolve host|getaddrinfo ENOTFOUND|Worker \/[\w-]+(\/[\w-]+)? failed \(50\d\)|Failed to fetch.*?worker|deployer\/(run|build) failed|The socket connection was closed unexpectedly|WORKER_UNAVAILABLE(_PERSISTENT)?:/i,
15670
15882
  result: {
15671
15883
  title: "Build infrastructure unavailable",
15672
15884
  explanation: "The Percher build server couldn't reach an upstream service it needs (Nix cache, worker daemon, or registry). This is a platform-side problem, not your app code. These failures are usually transient and clear within 1–2 minutes.",
@@ -15907,10 +16119,14 @@ ${buildLog ?? ""}`;
15907
16119
  async function pollDeployment(opts) {
15908
16120
  const terminal = opts.terminal ?? ["live", "failed"];
15909
16121
  const intervalMs = opts.intervalMs ?? 2000;
15910
- const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1000;
16122
+ const timeoutMs = opts.timeoutMs ?? TIMEOUTS.clientPublishPoll;
15911
16123
  const maxRetries = opts.maxRetries ?? 5;
15912
16124
  let deployment = opts.initial;
15913
16125
  const startedAt = Date.now();
16126
+ const seenEventIds = new Set;
16127
+ if (opts.onEvent) {
16128
+ await drainNewEvents(opts, deployment.id, seenEventIds);
16129
+ }
15914
16130
  while (!terminal.includes(deployment.status)) {
15915
16131
  if (Date.now() - startedAt > timeoutMs) {
15916
16132
  opts.onTimeout(deployment);
@@ -15939,9 +16155,204 @@ The deploy may already be live — your network connection dropped mid-poll. Che
15939
16155
  await new Promise((r) => setTimeout(r, backoff));
15940
16156
  }
15941
16157
  }
16158
+ if (opts.onEvent) {
16159
+ await drainNewEvents(opts, deployment.id, seenEventIds);
16160
+ }
15942
16161
  }
15943
16162
  return deployment;
15944
16163
  }
16164
+ async function drainNewEvents(opts, deployId, seenEventIds) {
16165
+ if (!opts.onEvent)
16166
+ return;
16167
+ try {
16168
+ const { events } = await opts.ctx.client.apps.getDeployEvents(opts.appId, deployId);
16169
+ for (const event of events) {
16170
+ if (seenEventIds.has(event.id))
16171
+ continue;
16172
+ seenEventIds.add(event.id);
16173
+ opts.onEvent(event);
16174
+ }
16175
+ } catch {}
16176
+ }
16177
+
16178
+ // ../core/dist/publish-retry.js
16179
+ var NON_RETRYABLE_CODES = new Set([
16180
+ "WORKER_UNAVAILABLE_PERSISTENT",
16181
+ "BUILD_FAILED",
16182
+ "BUILD_TIMEOUT",
16183
+ "DEPLOY_STALLED",
16184
+ "REQUIRED_ENV_MISSING",
16185
+ "ENV_KEY_UNDECLARED",
16186
+ "DAILY_QUOTA_EXCEEDED",
16187
+ "RETRY_LIMIT_REACHED",
16188
+ "ACCOUNT_SUSPENDED",
16189
+ "APP_SUSPENDED",
16190
+ "EMAIL_NOT_VERIFIED",
16191
+ "PREVIEW_LIMIT_REACHED",
16192
+ "VALIDATION_ERROR",
16193
+ "PLATFORM_DOWN"
16194
+ ]);
16195
+ var WORKER_UNAVAILABLE_BACKOFF_MS = [5000, 15000, 45000];
16196
+ var NETWORK_BACKOFF_MS = [2000, 8000, 24000];
16197
+ var HTTP_5XX_BACKOFF_MS = [5000, 20000];
16198
+ function classifyDeployRetry(err, attempt) {
16199
+ const idx = attempt - 1;
16200
+ if (err instanceof PercherApiError) {
16201
+ if (err.code === "WORKER_UNAVAILABLE_PERSISTENT") {
16202
+ return {
16203
+ retryable: false,
16204
+ delayMs: 0,
16205
+ reason: "platform circuit breaker open — surfacing as incident",
16206
+ category: "persistent"
16207
+ };
16208
+ }
16209
+ if (err.code === "WORKER_UNAVAILABLE") {
16210
+ const delay = WORKER_UNAVAILABLE_BACKOFF_MS[idx];
16211
+ if (delay === undefined) {
16212
+ return {
16213
+ retryable: false,
16214
+ delayMs: 0,
16215
+ reason: "worker unavailable after 3 attempts",
16216
+ category: "worker_unavailable"
16217
+ };
16218
+ }
16219
+ return {
16220
+ retryable: true,
16221
+ delayMs: delay,
16222
+ reason: "worker was restarting",
16223
+ category: "worker_unavailable"
16224
+ };
16225
+ }
16226
+ if (err.code === "DEPLOY_RATE_LIMITED") {
16227
+ if (idx >= 2) {
16228
+ return {
16229
+ retryable: false,
16230
+ delayMs: 0,
16231
+ reason: "rate-limited after 2 attempts",
16232
+ category: "rate_limited"
16233
+ };
16234
+ }
16235
+ const retryAfterSec = typeof err.extra?.retryAfterSec === "number" ? err.extra.retryAfterSec : 5;
16236
+ return {
16237
+ retryable: true,
16238
+ delayMs: Math.min(60000, Math.max(1000, retryAfterSec * 1000)),
16239
+ reason: "per-app rate limit",
16240
+ category: "rate_limited"
16241
+ };
16242
+ }
16243
+ if (err.code === "USER_CONCURRENT_LIMIT") {
16244
+ if (idx >= 1) {
16245
+ return {
16246
+ retryable: false,
16247
+ delayMs: 0,
16248
+ reason: "concurrent-deploy cap still saturated after one wait",
16249
+ category: "rate_limited"
16250
+ };
16251
+ }
16252
+ const retryAfterSec = typeof err.extra?.retryAfterSec === "number" ? err.extra.retryAfterSec : 60;
16253
+ return {
16254
+ retryable: true,
16255
+ delayMs: Math.min(2 * 60000, Math.max(5000, retryAfterSec * 1000)),
16256
+ reason: "per-user concurrent-deploy cap",
16257
+ category: "rate_limited"
16258
+ };
16259
+ }
16260
+ if (err.code === "PLATFORM_OVERLOADED") {
16261
+ if (idx >= 3) {
16262
+ return {
16263
+ retryable: false,
16264
+ delayMs: 0,
16265
+ reason: "platform overloaded after 3 attempts",
16266
+ category: "rate_limited"
16267
+ };
16268
+ }
16269
+ const retryAfterSec = typeof err.extra?.retryAfterSec === "number" ? err.extra.retryAfterSec : 30;
16270
+ return {
16271
+ retryable: true,
16272
+ delayMs: Math.min(5 * 60000, Math.max(5000, retryAfterSec * 1000)),
16273
+ reason: "platform overloaded — queue-depth backpressure",
16274
+ category: "rate_limited"
16275
+ };
16276
+ }
16277
+ if (NON_RETRYABLE_CODES.has(err.code)) {
16278
+ return {
16279
+ retryable: false,
16280
+ delayMs: 0,
16281
+ reason: `non-retryable: ${err.code}`,
16282
+ category: "non_retryable"
16283
+ };
16284
+ }
16285
+ if (err.status >= 500 && err.status < 600) {
16286
+ const delay = HTTP_5XX_BACKOFF_MS[idx];
16287
+ if (delay === undefined) {
16288
+ return {
16289
+ retryable: false,
16290
+ delayMs: 0,
16291
+ reason: `HTTP ${err.status} after 2 attempts`,
16292
+ category: "http_5xx"
16293
+ };
16294
+ }
16295
+ return {
16296
+ retryable: true,
16297
+ delayMs: delay,
16298
+ reason: `HTTP ${err.status}`,
16299
+ category: "http_5xx"
16300
+ };
16301
+ }
16302
+ return {
16303
+ retryable: false,
16304
+ delayMs: 0,
16305
+ reason: `HTTP ${err.status} ${err.code}`,
16306
+ category: "non_retryable"
16307
+ };
16308
+ }
16309
+ if (err instanceof Error && /network error|fetch failed|ECONNRESET|ENOTFOUND|ETIMEDOUT/i.test(err.message)) {
16310
+ const delay = NETWORK_BACKOFF_MS[idx];
16311
+ if (delay === undefined) {
16312
+ return {
16313
+ retryable: false,
16314
+ delayMs: 0,
16315
+ reason: "network error after 3 attempts",
16316
+ category: "network"
16317
+ };
16318
+ }
16319
+ return {
16320
+ retryable: true,
16321
+ delayMs: delay,
16322
+ reason: "network blip",
16323
+ category: "network"
16324
+ };
16325
+ }
16326
+ return {
16327
+ retryable: false,
16328
+ delayMs: 0,
16329
+ reason: "unknown error",
16330
+ category: "non_retryable"
16331
+ };
16332
+ }
16333
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
16334
+ async function runDeployWithRetry(opts) {
16335
+ const max = opts.maxAttempts ?? 4;
16336
+ const sleep = opts.sleep ?? defaultSleep;
16337
+ let attempt = 0;
16338
+ let lastErr;
16339
+ while (attempt < max) {
16340
+ attempt += 1;
16341
+ try {
16342
+ const result = await opts.call(attempt);
16343
+ return { result, attempts: attempt };
16344
+ } catch (err) {
16345
+ lastErr = err;
16346
+ const decision = classifyDeployRetry(err, attempt);
16347
+ if (!decision.retryable || attempt >= max) {
16348
+ throw err;
16349
+ }
16350
+ opts.onRetry?.({ attempt, decision, err });
16351
+ await sleep(decision.delayMs);
16352
+ }
16353
+ }
16354
+ throw lastErr;
16355
+ }
15945
16356
 
15946
16357
  // ../core/dist/recovery.js
15947
16358
  function recoveryNone(opts = {}) {
@@ -15969,7 +16380,7 @@ function recoveryWait(opts) {
15969
16380
  args: {
15970
16381
  app: opts.app,
15971
16382
  deployId: opts.deployId,
15972
- timeoutSeconds: opts.timeoutSeconds ?? 30
16383
+ timeoutSeconds: opts.timeoutSeconds ?? TIMEOUTS.mcpWaitDeployDefault / 1000
15973
16384
  },
15974
16385
  reasonCode: opts.reasonCode
15975
16386
  };
@@ -16299,12 +16710,21 @@ async function push(ctx, input = {}, internals = {}) {
16299
16710
  }
16300
16711
  }
16301
16712
  ctx.status("Uploading...");
16713
+ const tarballBytes = new Uint8Array(await new Response(tarball.stream).arrayBuffer());
16714
+ const idempotencyKey = crypto.randomUUID();
16302
16715
  let initialDeployment;
16303
16716
  try {
16304
- const deployResponse = await ctx.client.apps.deploy(app.id, {
16305
- tarball: tarball.stream,
16306
- type: input.preview ? "preview" : undefined,
16307
- noCache: input.noCache
16717
+ const { result: deployResponse } = await runDeployWithRetry({
16718
+ call: (attempt) => ctx.client.apps.deploy(app.id, {
16719
+ tarball: tarballBytes,
16720
+ type: input.preview ? "preview" : undefined,
16721
+ noCache: input.noCache,
16722
+ idempotencyKey,
16723
+ retriedAttempts: attempt - 1
16724
+ }),
16725
+ onRetry: ({ attempt, decision }) => {
16726
+ ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
16727
+ }
16308
16728
  });
16309
16729
  if (isDeployAlreadyInProgress(deployResponse)) {
16310
16730
  throw new PercherCoreError(`${app.name} already has a deploy in progress (${deployResponse.deployId}, ${deployResponse.deployStatus}).`, {
@@ -16386,7 +16806,7 @@ async function push(ctx, input = {}, internals = {}) {
16386
16806
  throw err;
16387
16807
  }
16388
16808
  const pollInterval = internals.pollIntervalMs ?? 2000;
16389
- const timeoutMs = internals.timeoutMs ?? 5 * 60 * 1000;
16809
+ const timeoutMs = internals.timeoutMs ?? TIMEOUTS.clientPublishPoll;
16390
16810
  const startedAt = Date.now();
16391
16811
  const deployment = await pollDeployment({
16392
16812
  ctx,
@@ -16541,6 +16961,82 @@ function* walkDir(dir, base) {
16541
16961
 
16542
16962
  // ../core/dist/commands/publish-failure.js
16543
16963
  import { join as join9 } from "node:path";
16964
+
16965
+ // ../core/dist/structured-error-codes.js
16966
+ var MAPPINGS = {
16967
+ WORKER_UNAVAILABLE: {
16968
+ errorClass: "infra_unavailable",
16969
+ phase: "infra",
16970
+ cause: "worker_fetch_failed",
16971
+ title: "Build worker is unreachable",
16972
+ explanation: (raw) => raw ? `The Percher build worker did not respond in time: ${trimMessage(raw)}. This is a platform-side issue, not your code.` : "The Percher build worker did not respond in time. This is a platform-side issue, not your code.",
16973
+ suggestion: "Wait 30 seconds and run `percher publish` again. If retry #2 also fails, pause 5 minutes before the next attempt — back-to-back retries during a partial outage just waste your time. After 3 spaced-out retries, check status.percher.app or contact support. Your build never started."
16974
+ },
16975
+ WORKER_UNAVAILABLE_PERSISTENT: {
16976
+ errorClass: "infra_unavailable",
16977
+ phase: "infra",
16978
+ cause: "worker_circuit_breaker_open",
16979
+ title: "Build worker is in a crash loop",
16980
+ explanation: () => "The Percher build worker has failed repeatedly in the last minute and the platform's circuit breaker has flipped open. Retrying immediately would just queue more failures.",
16981
+ suggestion: "Wait 2-3 minutes for the platform to recover, then retry. If it persists past 5 minutes check status.percher.app or contact support — this is a platform incident, not your code."
16982
+ },
16983
+ BUILD_TIMEOUT: {
16984
+ errorClass: "build_failed",
16985
+ phase: "build",
16986
+ cause: "build_timeout",
16987
+ title: "Build timed out",
16988
+ explanation: () => "The build ran longer than the 10-minute platform limit and was aborted.",
16989
+ suggestion: "Common causes: a slow `postinstall` hook, very large dependency tree, or a Dockerfile that pulls a huge base image. Try `bun install` locally and check what's slow. If it's legitimately a heavy build, contact support to discuss raising the limit for your account."
16990
+ },
16991
+ BUILD_FAILED: {
16992
+ errorClass: "build_failed",
16993
+ phase: "build",
16994
+ cause: "build_nonzero_exit",
16995
+ title: "Build failed",
16996
+ explanation: (raw) => raw ? `The build process returned a non-zero exit code: ${trimMessage(raw)}. Read the build log below for the exact error.` : "The build process returned a non-zero exit code. Read the build log below for the exact error.",
16997
+ suggestion: "Run your build locally to reproduce: `bun install && bun run build`. The error in the build log usually points to a missing dependency, a TypeScript/syntax error, or a misconfigured framework setting."
16998
+ },
16999
+ WORKER_ERROR: {
17000
+ errorClass: "infra_unavailable",
17001
+ phase: "infra",
17002
+ cause: "worker_internal_error",
17003
+ title: "Platform error inside the build worker",
17004
+ explanation: (raw) => raw ? `The build worker hit an internal error: ${trimMessage(raw)}. This is a Percher bug, not your code.` : "The build worker hit an internal error. This is a Percher bug, not your code.",
17005
+ suggestion: "Retry once — transient platform errors usually clear within a minute. If it repeats, please report it: include the deploy ID and we'll investigate. Your account is not at fault."
17006
+ },
17007
+ DEPLOY_STALLED: {
17008
+ errorClass: "infra_unavailable",
17009
+ phase: "infra",
17010
+ cause: "deploy_stalled",
17011
+ title: "Deploy stalled",
17012
+ explanation: () => "The platform abandoned this deploy — likely because the API or build worker restarted mid-build. Your code is fine; the build never finished.",
17013
+ suggestion: "Run `percher publish` again. The new deploy will start fresh. If you see this repeatedly across multiple publishes, check status.percher.app — it implies a recurring platform issue."
17014
+ }
17015
+ };
17016
+ function classifyByErrorCode(code, rawMessage) {
17017
+ if (!code)
17018
+ return null;
17019
+ const mapping = MAPPINGS[code];
17020
+ if (!mapping)
17021
+ return null;
17022
+ return {
17023
+ title: mapping.title,
17024
+ explanation: mapping.explanation(rawMessage),
17025
+ suggestion: mapping.suggestion,
17026
+ errorClass: mapping.errorClass,
17027
+ phase: mapping.phase,
17028
+ cause: mapping.cause,
17029
+ relevantFiles: [],
17030
+ missingEnvVars: []
17031
+ };
17032
+ }
17033
+ function trimMessage(raw) {
17034
+ const firstLine = raw.split(`
17035
+ `)[0] ?? "";
17036
+ return firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine;
17037
+ }
17038
+
17039
+ // ../core/dist/commands/publish-failure.js
16544
17040
  function tomlPathFor(cwd) {
16545
17041
  return join9(cwd, "percher.toml");
16546
17042
  }
@@ -16564,7 +17060,7 @@ async function buildFailureError(ctx, app, deployment) {
16564
17060
  buildLogFetchFailed = true;
16565
17061
  }
16566
17062
  const errorMessage = deployment.errorMessage ?? "";
16567
- const classified = classifyError("Deployment failed", fullLog) ?? classifyError(errorMessage, "");
17063
+ const classified = classifyByErrorCode(deployment.errorCode, errorMessage) ?? classifyError("Deployment failed", fullLog) ?? classifyError(errorMessage, "");
16568
17064
  const rawErrorMessage = errorMessage && !buildLogTail && !classified?.explanation.includes(errorMessage) ? errorMessage : undefined;
16569
17065
  const problems = deployment.problems && deployment.problems.length > 0 ? deployment.problems : undefined;
16570
17066
  if (classified) {
@@ -16640,6 +17136,12 @@ async function buildFailureResult(opts) {
16640
17136
  // ../core/dist/commands/publish-node.js
16641
17137
  import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
16642
17138
  import { join as join10 } from "node:path";
17139
+
17140
+ // ../shared/dist/runtime.js
17141
+ var DEFAULT_NODE_VERSION_MAJOR = "24";
17142
+ var DEFAULT_NODE_VERSION_LABEL = `${DEFAULT_NODE_VERSION_MAJOR} LTS`;
17143
+
17144
+ // ../core/dist/commands/publish-node.js
16643
17145
  function resolveNodeVersion(cwd) {
16644
17146
  const nvmrc = join10(cwd, ".nvmrc");
16645
17147
  if (existsSync7(nvmrc)) {
@@ -16659,14 +17161,14 @@ function resolveNodeVersion(cwd) {
16659
17161
  return { value: engines, source: "engines.node" };
16660
17162
  }
16661
17163
  } catch {}
16662
- return { value: "22 LTS", source: "default" };
17164
+ return { value: DEFAULT_NODE_VERSION_LABEL, source: "default" };
16663
17165
  }
16664
17166
 
16665
17167
  // ../core/dist/commands/wait-deploy.js
16666
17168
  var waitForDeployInputSchema = exports_external.object({
16667
17169
  app: exports_external.string().describe("App name (the value of [app].name in percher.toml)"),
16668
17170
  deployId: exports_external.string().describe("Deploy id returned by percher_publish (e.g. dep_abc123)"),
16669
- timeoutSeconds: exports_external.number().int().min(1).max(120).optional().describe("Maximum wall-clock seconds to wait before returning still_running (default 30, max 120). Keeping this short is the point — the agent can resume in another short call without holding a long-lived connection."),
17171
+ 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.`),
16670
17172
  pollIntervalSeconds: exports_external.number().int().min(1).max(30).optional().describe("Seconds between polls (default 2).")
16671
17173
  });
16672
17174
  async function resolveReplaced(opts) {
@@ -16932,121 +17434,185 @@ ${keyList}
16932
17434
  Fix: bunx percher env set KEY=value
16933
17435
  (Re-run with --force to suppress this check.)`);
16934
17436
  }
16935
- const uploadStart = Date.now();
16936
- ctx.status("[3/4] Uploading...");
16937
- let deployment;
16938
17437
  try {
16939
- const deployResponse = await ctx.client.apps.deploy(app.id, {
16940
- tarball: tarball.stream,
16941
- type: input.preview ? "preview" : undefined,
16942
- note: input.message,
16943
- noCache: input.noCache
16944
- });
16945
- if (isDeployAlreadyInProgress(deployResponse)) {
16946
- return buildAlreadyInProgressResult({
16947
- active: deployResponse,
16948
- app,
16949
- tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
16950
- cwd: ctx.cwd
16951
- });
16952
- }
16953
- deployment = deployResponse;
16954
- } catch (err) {
16955
- if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
16956
- return buildRetryLimitReachedResult({
16957
- err,
16958
- app,
16959
- tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
16960
- cwd: ctx.cwd
16961
- });
16962
- }
16963
- if (err instanceof PercherApiError && err.code === "REQUIRED_ENV_MISSING") {
16964
- const extra = err.extra ?? {};
16965
- const keys = extra.keys ?? [];
16966
- const source = extra.source ?? "contract";
16967
- 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";
17438
+ const preflight = await ctx.client.platform.preflight();
17439
+ if (!preflight.accept) {
17440
+ const reasonText = preflight.reason === "worker_circuit_breaker_open" ? "platform circuit breaker is open — workers are restarting" : preflight.reason === "worker_unhealthy" ? "build worker is currently unreachable" : "platform is currently unavailable";
16968
17441
  return {
16969
17442
  status: "failed",
16970
17443
  app,
16971
17444
  fileCount: tarball.fileCount,
16972
17445
  bytes: tarball.bytes,
16973
17446
  error: {
16974
- title: "Required env keys not set",
16975
- explanation: `${keys.length} env ${keys.length === 1 ? "key is" : "keys are"} ${sourceText} but missing from the env store: ${keys.join(", ")}.`,
16976
- suggestion: `Run \`bunx percher env set ${keys[0] ?? "KEY"}=value\` (and similarly for the rest) and re-publish.`,
16977
- errorClass: "missing_env",
16978
- phase: "config",
16979
- missingEnvVars: keys
17447
+ title: "Platform unavailable",
17448
+ explanation: `Preflight refused the upload: ${reasonText}.`,
17449
+ suggestion: "Wait ~30s and retry — the platform self-heals automatically and the auto-retry loop will pick it up on the next attempt.",
17450
+ errorClass: "infra_unavailable",
17451
+ phase: "config"
16980
17452
  },
16981
- recovery: recoveryEnv({
16982
- app: app.name,
16983
- keys,
16984
- reasonCode: "missing_env"
16985
- }),
16986
- summary: `Missing required env ${keys.length === 1 ? "key" : "keys"}: ${keys.join(", ")}.`,
16987
- configPath: tomlPathFor2(ctx.cwd),
16988
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
16989
- };
16990
- }
16991
- if (err instanceof PercherApiError && err.code === "ENV_KEY_UNDECLARED") {
16992
- const extra = err.extra ?? {};
16993
- const apiProblems = extra.problems ?? [];
16994
- const problems = apiProblems.map((p) => ({
16995
- file: p.file,
16996
- line: p.line,
16997
- 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)"}`
16998
- }));
16999
- const uniqueKeys = [...new Set(apiProblems.map((p) => p.key))];
17000
- return {
17001
- status: "failed",
17002
- app,
17003
- fileCount: tarball.fileCount,
17004
- bytes: tarball.bytes,
17005
- error: {
17006
- title: "Undeclared env key",
17007
- explanation: `Source references ${uniqueKeys.length} env ${uniqueKeys.length === 1 ? "key" : "keys"} that aren't classified in percher.toml's [env] table: ${uniqueKeys.join(", ")}.`,
17008
- 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.",
17009
- errorClass: "config_invalid",
17010
- phase: "config",
17011
- relevantFiles: ["percher.toml"]
17453
+ recovery: {
17454
+ retryable: true,
17455
+ nextAction: "retry",
17456
+ suggestedTool: "percher_publish",
17457
+ args: {},
17458
+ reasonCode: "infra_unavailable"
17012
17459
  },
17013
- recovery: recoveryFixConfig({
17014
- problems,
17015
- reasonCode: "env_key_undeclared"
17016
- }),
17017
- summary: `${uniqueKeys.length} undeclared env ${uniqueKeys.length === 1 ? "key" : "keys"} in source: ${uniqueKeys.join(", ")}.`,
17460
+ summary: `Preflight refused — ${reasonText}.`,
17018
17461
  configPath: tomlPathFor2(ctx.cwd),
17019
17462
  bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
17020
17463
  };
17021
17464
  }
17022
- if (err instanceof PercherApiError && err.code === "DAILY_QUOTA_EXCEEDED") {
17023
- const extra = err.extra ?? {};
17024
- const kind = extra.kind ?? "live";
17025
- const used = extra.used ?? 0;
17026
- const limit = extra.limit ?? 0;
17027
- const resetAt = extra.resetAt ?? "";
17028
- const otherKind = kind === "live" ? "preview" : "live";
17029
- return {
17030
- status: "failed",
17031
- app,
17032
- fileCount: tarball.fileCount,
17033
- bytes: tarball.bytes,
17034
- error: {
17035
- title: "Daily deploy quota reached",
17036
- explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
17037
- suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/settings to raise this cap.`
17038
- },
17039
- recovery: recoveryAsk({
17040
- 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.`,
17041
- options: ["wait", `deploy ${otherKind}`, "upgrade"],
17042
- reasonCode: "quota_exceeded"
17465
+ if (preflight.queueWaitEstimateSec > 60) {
17466
+ ctx.status(`Build queue wait: ~${preflight.queueWaitEstimateSec}s — your build is queued behind ${preflight.queueDepth} other(s).`);
17467
+ }
17468
+ } catch {}
17469
+ const uploadStart = Date.now();
17470
+ ctx.status("[3/4] Uploading...");
17471
+ const tarballBytes = new Uint8Array(await new Response(tarball.stream).arrayBuffer());
17472
+ let cacheReusedDeployment = null;
17473
+ const contentHash = tarball.contentHash;
17474
+ if (!input.noCache) {
17475
+ try {
17476
+ const probe = await ctx.client.platform.cacheProbe({
17477
+ appId: app.id,
17478
+ tarballHash: contentHash
17479
+ });
17480
+ if (probe.hit) {
17481
+ ctx.status(`Build cache: identical bundle deployed previously (${probe.lastDeployId}) — redeploying without re-upload.`);
17482
+ cacheReusedDeployment = await ctx.client.apps.rerunDeploy(app.id, probe.lastDeployId);
17483
+ ctx.status("[4/4] Building & deploying (cache reuse)...");
17484
+ }
17485
+ } catch {}
17486
+ }
17487
+ const idempotencyKey = crypto.randomUUID();
17488
+ let deployment;
17489
+ if (cacheReusedDeployment) {
17490
+ deployment = cacheReusedDeployment;
17491
+ } else {
17492
+ try {
17493
+ const { result: deployResponse, attempts: deployAttempts } = await runDeployWithRetry({
17494
+ call: (attempt) => ctx.client.apps.deploy(app.id, {
17495
+ tarball: tarballBytes,
17496
+ type: input.preview ? "preview" : undefined,
17497
+ note: input.message,
17498
+ noCache: input.noCache,
17499
+ idempotencyKey,
17500
+ tarballHash: contentHash,
17501
+ retriedAttempts: attempt - 1
17043
17502
  }),
17044
- summary: `Daily ${kind}-deploy quota reached (${used}/${limit}). Resets ${resetAt}.`,
17045
- configPath: tomlPathFor2(ctx.cwd),
17046
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
17047
- };
17503
+ onRetry: ({ attempt, decision }) => {
17504
+ ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
17505
+ }
17506
+ });
17507
+ if (deployAttempts > 1) {
17508
+ ctx.status(`Recovered after ${deployAttempts - 1} retry${deployAttempts > 2 ? "ies" : ""}.`);
17509
+ }
17510
+ if (isDeployAlreadyInProgress(deployResponse)) {
17511
+ return buildAlreadyInProgressResult({
17512
+ active: deployResponse,
17513
+ app,
17514
+ tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
17515
+ cwd: ctx.cwd
17516
+ });
17517
+ }
17518
+ deployment = deployResponse;
17519
+ } catch (err) {
17520
+ if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
17521
+ return buildRetryLimitReachedResult({
17522
+ err,
17523
+ app,
17524
+ tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
17525
+ cwd: ctx.cwd
17526
+ });
17527
+ }
17528
+ if (err instanceof PercherApiError && err.code === "REQUIRED_ENV_MISSING") {
17529
+ const extra = err.extra ?? {};
17530
+ const keys = extra.keys ?? [];
17531
+ const source = extra.source ?? "contract";
17532
+ 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";
17533
+ return {
17534
+ status: "failed",
17535
+ app,
17536
+ fileCount: tarball.fileCount,
17537
+ bytes: tarball.bytes,
17538
+ error: {
17539
+ title: "Required env keys not set",
17540
+ explanation: `${keys.length} env ${keys.length === 1 ? "key is" : "keys are"} ${sourceText} but missing from the env store: ${keys.join(", ")}.`,
17541
+ suggestion: `Run \`bunx percher env set ${keys[0] ?? "KEY"}=value\` (and similarly for the rest) and re-publish.`,
17542
+ errorClass: "missing_env",
17543
+ phase: "config",
17544
+ missingEnvVars: keys
17545
+ },
17546
+ recovery: recoveryEnv({
17547
+ app: app.name,
17548
+ keys,
17549
+ reasonCode: "missing_env"
17550
+ }),
17551
+ summary: `Missing required env ${keys.length === 1 ? "key" : "keys"}: ${keys.join(", ")}.`,
17552
+ configPath: tomlPathFor2(ctx.cwd),
17553
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
17554
+ };
17555
+ }
17556
+ if (err instanceof PercherApiError && err.code === "ENV_KEY_UNDECLARED") {
17557
+ const extra = err.extra ?? {};
17558
+ const apiProblems = extra.problems ?? [];
17559
+ const problems = apiProblems.map((p) => ({
17560
+ file: p.file,
17561
+ line: p.line,
17562
+ 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)"}`
17563
+ }));
17564
+ const uniqueKeys = [...new Set(apiProblems.map((p) => p.key))];
17565
+ return {
17566
+ status: "failed",
17567
+ app,
17568
+ fileCount: tarball.fileCount,
17569
+ bytes: tarball.bytes,
17570
+ error: {
17571
+ title: "Undeclared env key",
17572
+ explanation: `Source references ${uniqueKeys.length} env ${uniqueKeys.length === 1 ? "key" : "keys"} that aren't classified in percher.toml's [env] table: ${uniqueKeys.join(", ")}.`,
17573
+ 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.",
17574
+ errorClass: "config_invalid",
17575
+ phase: "config",
17576
+ relevantFiles: ["percher.toml"]
17577
+ },
17578
+ recovery: recoveryFixConfig({
17579
+ problems,
17580
+ reasonCode: "env_key_undeclared"
17581
+ }),
17582
+ summary: `${uniqueKeys.length} undeclared env ${uniqueKeys.length === 1 ? "key" : "keys"} in source: ${uniqueKeys.join(", ")}.`,
17583
+ configPath: tomlPathFor2(ctx.cwd),
17584
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
17585
+ };
17586
+ }
17587
+ if (err instanceof PercherApiError && err.code === "DAILY_QUOTA_EXCEEDED") {
17588
+ const extra = err.extra ?? {};
17589
+ const kind = extra.kind ?? "live";
17590
+ const used = extra.used ?? 0;
17591
+ const limit = extra.limit ?? 0;
17592
+ const resetAt = extra.resetAt ?? "";
17593
+ const otherKind = kind === "live" ? "preview" : "live";
17594
+ return {
17595
+ status: "failed",
17596
+ app,
17597
+ fileCount: tarball.fileCount,
17598
+ bytes: tarball.bytes,
17599
+ error: {
17600
+ title: "Daily deploy quota reached",
17601
+ explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
17602
+ suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/settings to raise this cap.`
17603
+ },
17604
+ recovery: recoveryAsk({
17605
+ 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.`,
17606
+ options: ["wait", `deploy ${otherKind}`, "upgrade"],
17607
+ reasonCode: "quota_exceeded"
17608
+ }),
17609
+ summary: `Daily ${kind}-deploy quota reached (${used}/${limit}). Resets ${resetAt}.`,
17610
+ configPath: tomlPathFor2(ctx.cwd),
17611
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes }
17612
+ };
17613
+ }
17614
+ throw err;
17048
17615
  }
17049
- throw err;
17050
17616
  }
17051
17617
  const replacedPreview = deployment.replacedPreview === true;
17052
17618
  if (replacedPreview) {
@@ -17076,7 +17642,7 @@ ${keyList}
17076
17642
  }
17077
17643
  ctx.status("[4/4] Building & deploying...");
17078
17644
  const buildStart = Date.now();
17079
- const timeoutMs = 5 * 60 * 1000;
17645
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
17080
17646
  let lastStatus;
17081
17647
  let nextHeartbeatMs = 15000;
17082
17648
  let timedOut = null;
@@ -17100,6 +17666,11 @@ ${keyList}
17100
17666
  nextHeartbeatMs = elapsedMs + 15000;
17101
17667
  }
17102
17668
  },
17669
+ onEvent: (event) => {
17670
+ const line = renderDeployEvent(event);
17671
+ if (line)
17672
+ ctx.status(line);
17673
+ },
17103
17674
  onTimeout: (d) => {
17104
17675
  timedOut = { deployment: d };
17105
17676
  throw new class PollTimeoutSentinel extends Error {
@@ -17185,7 +17756,9 @@ ${keyList}
17185
17756
  replacedPreview,
17186
17757
  firstDeploy: firstDeploy || undefined,
17187
17758
  cacheHit,
17759
+ cacheReused: cacheReusedDeployment !== null || undefined,
17188
17760
  missingBuildEnvKeys: missingBuildEnvKeys.length > 0 ? missingBuildEnvKeys : undefined,
17761
+ traceId: deployment.id,
17189
17762
  recovery: RECOVERY_NONE,
17190
17763
  summary: buildLiveSummary({
17191
17764
  appName: app.name,
@@ -17247,13 +17820,22 @@ async function handleUnauthorized(ctx, config, tarball, input) {
17247
17820
  let app;
17248
17821
  ({ app, firstDeploy: firstDeployRetry } = await ensureApp2(ctx, config));
17249
17822
  ctx.status("Uploading...");
17823
+ const freshTarballBytes = new Uint8Array(await new Response(freshTarball.stream).arrayBuffer());
17824
+ const freshIdempotencyKey = crypto.randomUUID();
17250
17825
  let deployment;
17251
17826
  try {
17252
- const deployResponse = await ctx.client.apps.deploy(app.id, {
17253
- tarball: freshTarball.stream,
17254
- type: input.preview ? "preview" : undefined,
17255
- note: input.message,
17256
- noCache: input.noCache
17827
+ const { result: deployResponse } = await runDeployWithRetry({
17828
+ call: (attempt) => ctx.client.apps.deploy(app.id, {
17829
+ tarball: freshTarballBytes,
17830
+ type: input.preview ? "preview" : undefined,
17831
+ note: input.message,
17832
+ noCache: input.noCache,
17833
+ idempotencyKey: freshIdempotencyKey,
17834
+ retriedAttempts: attempt - 1
17835
+ }),
17836
+ onRetry: ({ attempt, decision }) => {
17837
+ ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
17838
+ }
17257
17839
  });
17258
17840
  if (isDeployAlreadyInProgress(deployResponse)) {
17259
17841
  return buildAlreadyInProgressResult({
@@ -17296,7 +17878,7 @@ async function handleUnauthorized(ctx, config, tarball, input) {
17296
17878
  bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes }
17297
17879
  };
17298
17880
  }
17299
- const timeoutMs = 300000;
17881
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
17300
17882
  const start = Date.now();
17301
17883
  while (deployment.status !== "live" && deployment.status !== "failed" && deployment.status !== "replaced") {
17302
17884
  if (Date.now() - start > timeoutMs) {
@@ -17364,6 +17946,7 @@ async function handleUnauthorized(ctx, config, tarball, input) {
17364
17946
  firstDeploy: firstDeployRetry || undefined,
17365
17947
  recovery: RECOVERY_NONE,
17366
17948
  cacheHit: deployment.cacheHit,
17949
+ traceId: deployment.id,
17367
17950
  summary: buildLiveSummary({
17368
17951
  appName: app.name,
17369
17952
  url: url ?? app.url,
@@ -17646,7 +18229,7 @@ Context: ${p.context}` : ""}`
17646
18229
  throw err;
17647
18230
  }
17648
18231
  const buildStart = Date.now();
17649
- const timeoutMs = 5 * 60 * 1000;
18232
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
17650
18233
  let lastStatus;
17651
18234
  let nextHeartbeatMs = 15000;
17652
18235
  let deployment = initial;
@@ -17696,10 +18279,10 @@ Context: ${p.context}` : ""}`
17696
18279
  const totalSeconds = (Date.now() - t0) / 1000;
17697
18280
  const url = deployment.previewUrl ?? deployment.url ?? app.url;
17698
18281
  if (deployment.status === "failed") {
17699
- const recovery = recoveryFromErrorClass("build_failed", {
17700
- deployId: deployment.id,
17701
- appName: app.name,
17702
- problems: deployment.problems
18282
+ const { error, recovery, summary } = await classifyDeploymentFailure({
18283
+ ctx,
18284
+ app,
18285
+ deployment
17703
18286
  });
17704
18287
  return {
17705
18288
  status: "failed",
@@ -17707,14 +18290,9 @@ Context: ${p.context}` : ""}`
17707
18290
  deployment,
17708
18291
  fileCount: 0,
17709
18292
  bytes: 0,
17710
- error: {
17711
- title: "Redeploy failed",
17712
- explanation: deployment.errorMessage ?? "The redeploy did not succeed.",
17713
- suggestion: `Inspect with \`bunx percher deploys inspect ${deployment.id}\`.`,
17714
- problems: deployment.problems
17715
- },
18293
+ error,
17716
18294
  recovery,
17717
- summary: `${appName} redeploy failed (${deployment.id}).`,
18295
+ summary,
17718
18296
  configPath: "",
17719
18297
  bundle: { fileCount: 0, bytes: 0 }
17720
18298
  };
@@ -17956,7 +18534,7 @@ async function rollback(ctx, input, internals = {}) {
17956
18534
  }
17957
18535
  let deployment = await ctx.client.apps.rollback(name, input.commitSha);
17958
18536
  const pollInterval = internals.pollIntervalMs ?? 2000;
17959
- const timeoutMs = internals.timeoutMs ?? 5 * 60 * 1000;
18537
+ const timeoutMs = internals.timeoutMs ?? TIMEOUTS.clientPublishPoll;
17960
18538
  const startedAt = Date.now();
17961
18539
  while (deployment.status !== "live" && deployment.status !== "failed") {
17962
18540
  if (Date.now() - startedAt > timeoutMs) {
@@ -18076,8 +18654,8 @@ function resolveSource(backups, source) {
18076
18654
  }
18077
18655
  return sorted.find((b) => b.backup_id === source) ?? null;
18078
18656
  }
18079
- function humanizeStatus(status) {
18080
- switch (status) {
18657
+ function humanizeStatus(status2) {
18658
+ switch (status2) {
18081
18659
  case "preparing":
18082
18660
  return "Preparing";
18083
18661
  case "quiescing":
@@ -18091,7 +18669,7 @@ function humanizeStatus(status) {
18091
18669
  case "rolling_back":
18092
18670
  return "Rolling back";
18093
18671
  default:
18094
- return status;
18672
+ return status2;
18095
18673
  }
18096
18674
  }
18097
18675
  // ../core/dist/commands/deploys.js
@@ -18195,6 +18773,15 @@ var doctorInputSchema = exports_external.object({
18195
18773
  mode: exports_external.enum(["auto", "deploy", "runtime", "config", "env", "account"]).optional(),
18196
18774
  deployId: exports_external.string().optional()
18197
18775
  });
18776
+ var STUCK_DEPLOY_THRESHOLD_MS = 15 * 60 * 1000;
18777
+ function isStuckDeploy(status2, createdAt, now = Date.now()) {
18778
+ if (status2 !== "building" && status2 !== "queued")
18779
+ return false;
18780
+ const created = Date.parse(createdAt);
18781
+ if (Number.isNaN(created))
18782
+ return false;
18783
+ return now - created > STUCK_DEPLOY_THRESHOLD_MS;
18784
+ }
18198
18785
  async function doctor(ctx, input = {}) {
18199
18786
  const checks = [];
18200
18787
  const hasToken = !!ctx.client.token;
@@ -18204,7 +18791,7 @@ async function doctor(ctx, input = {}) {
18204
18791
  message: hasToken ? "Token configured" : "No token found. Run: percher login"
18205
18792
  });
18206
18793
  if (!hasToken) {
18207
- return summarize(checks, { appProvided: !!input.app });
18794
+ return summarize2(checks, { appProvided: !!input.app });
18208
18795
  }
18209
18796
  try {
18210
18797
  const start = Date.now();
@@ -18226,7 +18813,7 @@ async function doctor(ctx, input = {}) {
18226
18813
  status: "fail",
18227
18814
  message: `Cannot reach ${ctx.client.apiUrl}: ${err.message}`
18228
18815
  });
18229
- return summarize(checks, { appProvided: !!input.app });
18816
+ return summarize2(checks, { appProvided: !!input.app });
18230
18817
  }
18231
18818
  const tomlPath = join13(ctx.cwd, "percher.toml");
18232
18819
  const tomlExists = existsSync9(tomlPath);
@@ -18267,7 +18854,7 @@ async function doctor(ctx, input = {}) {
18267
18854
  if (!appName) {
18268
18855
  const reason = tomlExists && !tomlValid ? "percher.toml is invalid — fix the issues above and re-run" : "No app specified and no percher.toml found";
18269
18856
  checks.push({ name: "App", status: "skip", message: reason });
18270
- return summarize(checks, { appProvided: !!input.app });
18857
+ return summarize2(checks, { appProvided: !!input.app });
18271
18858
  }
18272
18859
  let appStatus;
18273
18860
  let lastDeployStatus;
@@ -18344,6 +18931,13 @@ async function doctor(ctx, input = {}) {
18344
18931
  status: deployOk ? "pass" : "warn",
18345
18932
  message: `${diag.lastDeploy.status} (${diag.lastDeploy.createdAt})`
18346
18933
  });
18934
+ if (isStuckDeploy(diag.lastDeploy.status, diag.lastDeploy.createdAt)) {
18935
+ checks.push({
18936
+ name: "Deploy stuck",
18937
+ status: "warn",
18938
+ message: "Last deploy has been in-progress > 15 min — likely platform restart mid-build. Run `percher publish` again; the platform will clear the stuck row within ~2 min."
18939
+ });
18940
+ }
18347
18941
  }
18348
18942
  if (diag.lastCrash) {
18349
18943
  checks.push({
@@ -18455,7 +19049,7 @@ async function doctor(ctx, input = {}) {
18455
19049
  message: err.message
18456
19050
  });
18457
19051
  }
18458
- return summarize(checks, {
19052
+ return summarize2(checks, {
18459
19053
  appName,
18460
19054
  appProvided: !!input.app,
18461
19055
  appStatus,
@@ -18476,12 +19070,12 @@ async function doctor(ctx, input = {}) {
18476
19070
  }
18477
19071
  var TRANSITIONAL_DEPLOY_STATUSES = new Set(["queued", "building", "deploying"]);
18478
19072
  var TRANSITIONAL_APP_STATUSES = new Set(["provisioning"]);
18479
- function summarize(checks, vctx = {}) {
19073
+ function summarize2(checks, vctx = {}) {
18480
19074
  const passed = checks.filter((c) => c.status === "pass").length;
18481
19075
  const failed = checks.filter((c) => c.status === "fail").length;
18482
19076
  const warned = checks.filter((c) => c.status === "warn").length;
18483
19077
  const total = checks.filter((c) => c.status !== "skip").length;
18484
- const verdict = deriveVerdict(checks, vctx);
19078
+ const verdict = deriveVerdict2(checks, vctx);
18485
19079
  return {
18486
19080
  checks,
18487
19081
  passed,
@@ -18494,7 +19088,7 @@ function summarize(checks, vctx = {}) {
18494
19088
  summary: verdict.summary
18495
19089
  };
18496
19090
  }
18497
- function deriveVerdict(checks, vctx) {
19091
+ function deriveVerdict2(checks, vctx) {
18498
19092
  const findCheck = (name) => checks.find((c) => c.name === name);
18499
19093
  const auth = findCheck("Auth token");
18500
19094
  if (auth?.status === "fail") {
@@ -18603,9 +19197,9 @@ function deriveVerdict(checks, vctx) {
18603
19197
  summary: r.summary
18604
19198
  };
18605
19199
  }
18606
- const status = r.recovery.nextAction === "wait_deploy" ? "in_progress" : "needs_action";
19200
+ const status2 = r.recovery.nextAction === "wait_deploy" ? "in_progress" : "needs_action";
18607
19201
  return {
18608
- status,
19202
+ status: status2,
18609
19203
  diagnosis: {
18610
19204
  title: "Last deploy was replaced",
18611
19205
  explanation: r.summary,
@@ -18920,7 +19514,7 @@ async function doPush(ctx, config, callbacks) {
18920
19514
  }
18921
19515
  let deployment = deployResponse;
18922
19516
  const startedAt = Date.now();
18923
- const timeoutMs = 5 * 60 * 1000;
19517
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
18924
19518
  const pollInterval = 2000;
18925
19519
  let lastStatus = "";
18926
19520
  while (deployment.status !== "live" && deployment.status !== "failed") {
@@ -19062,7 +19656,7 @@ async function data(ctx, input = {}) {
19062
19656
  hint: "Pass an app name or run from a directory with percher.toml."
19063
19657
  });
19064
19658
  }
19065
- const status = await ctx.client.data.getStatus(name);
19659
+ const status2 = await ctx.client.data.getStatus(name);
19066
19660
  const adminLink = await ctx.client.data.getAdminLink(name);
19067
19661
  let collections = [];
19068
19662
  let stats = { collections: 0, totalRecords: 0 };
@@ -19080,9 +19674,9 @@ async function data(ctx, input = {}) {
19080
19674
  }
19081
19675
  }
19082
19676
  return {
19083
- mode: status.mode,
19084
- status: status.status,
19085
- version: status.version ?? null,
19677
+ mode: status2.mode,
19678
+ status: status2.status,
19679
+ version: status2.version ?? null,
19086
19680
  collections,
19087
19681
  stats,
19088
19682
  adminUrl: adminLink.adminUrl ?? null,
@@ -19801,7 +20395,8 @@ var PLAN_CATALOG = {
19801
20395
  kvStorageMb: 10,
19802
20396
  uptimeIntervalSec: 0,
19803
20397
  dailyLiveDeploys: 50,
19804
- dailyPreviewDeploys: 25
20398
+ dailyPreviewDeploys: 25,
20399
+ userConcurrentDeploys: 2
19805
20400
  }
19806
20401
  },
19807
20402
  starter: {
@@ -19831,7 +20426,8 @@ var PLAN_CATALOG = {
19831
20426
  kvStorageMb: 50,
19832
20427
  uptimeIntervalSec: 300,
19833
20428
  dailyLiveDeploys: 100,
19834
- dailyPreviewDeploys: 50
20429
+ dailyPreviewDeploys: 50,
20430
+ userConcurrentDeploys: 3
19835
20431
  }
19836
20432
  },
19837
20433
  maker: {
@@ -19861,7 +20457,8 @@ var PLAN_CATALOG = {
19861
20457
  kvStorageMb: 200,
19862
20458
  uptimeIntervalSec: 60,
19863
20459
  dailyLiveDeploys: 200,
19864
- dailyPreviewDeploys: 100
20460
+ dailyPreviewDeploys: 100,
20461
+ userConcurrentDeploys: 5
19865
20462
  }
19866
20463
  },
19867
20464
  pro: {
@@ -19891,7 +20488,8 @@ var PLAN_CATALOG = {
19891
20488
  kvStorageMb: 1000,
19892
20489
  uptimeIntervalSec: 30,
19893
20490
  dailyLiveDeploys: 1000,
19894
- dailyPreviewDeploys: 500
20491
+ dailyPreviewDeploys: 500,
20492
+ userConcurrentDeploys: 20
19895
20493
  }
19896
20494
  }
19897
20495
  };
@@ -19901,7 +20499,7 @@ import { existsSync as existsSync16, mkdirSync as mkdirSync5, readFileSync as re
19901
20499
  import { dirname as dirname3, join as join21 } from "node:path";
19902
20500
 
19903
20501
  // ../core/dist/ai-files-manifest.js
19904
- import { createHash } from "node:crypto";
20502
+ import { createHash as createHash2 } from "node:crypto";
19905
20503
  import { existsSync as existsSync15, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync10, writeFileSync as writeFileSync6 } from "node:fs";
19906
20504
  import { dirname as dirname2, join as join20, resolve as resolve5 } from "node:path";
19907
20505
  var MANIFEST_REL = ".percher/ai-files-state.json";
@@ -19909,7 +20507,7 @@ var EMPTY_MANIFEST = { version: 1, files: {} };
19909
20507
  function hashContent(content) {
19910
20508
  const normalised = content.replace(/\r\n/g, `
19911
20509
  `);
19912
- return createHash("sha256").update(normalised, "utf8").digest("hex");
20510
+ return createHash2("sha256").update(normalised, "utf8").digest("hex");
19913
20511
  }
19914
20512
  function manifestPath(projectRoot) {
19915
20513
  return join20(projectRoot, MANIFEST_REL);
@@ -20371,12 +20969,12 @@ function formatDeployTimestamp(iso) {
20371
20969
  const pad = (n) => n.toString().padStart(2, "0");
20372
20970
  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
20373
20971
  }
20374
- function formatStatus(status) {
20375
- if (status === "live")
20376
- return import_picocolors.default.green(status);
20377
- if (status === "failed")
20378
- return import_picocolors.default.red(status);
20379
- return import_picocolors.default.yellow(status);
20972
+ function formatStatus(status2) {
20973
+ if (status2 === "live")
20974
+ return import_picocolors.default.green(status2);
20975
+ if (status2 === "failed")
20976
+ return import_picocolors.default.red(status2);
20977
+ return import_picocolors.default.yellow(status2);
20380
20978
  }
20381
20979
  function firstNonFlagAfter(argv, from, valueFlags = new Set) {
20382
20980
  let i = from + 1;
@@ -20657,8 +21255,8 @@ function registerAccountCommands(cli, ctx) {
20657
21255
 
20658
21256
  // src/commands/apps.ts
20659
21257
  var import_picocolors3 = __toESM(require_picocolors(), 1);
20660
- function assertNever(status, summary) {
20661
- throw new Error(`Unhandled publish status: ${JSON.stringify(status)}${summary ? ` (${summary})` : ""}`);
21258
+ function assertNever(status2, summary) {
21259
+ throw new Error(`Unhandled publish status: ${JSON.stringify(status2)}${summary ? ` (${summary})` : ""}`);
20662
21260
  }
20663
21261
  async function handlePublishResult(ctx, result, options = {}) {
20664
21262
  switch (result.status) {
@@ -20760,7 +21358,12 @@ async function handlePublishResult(ctx, result, options = {}) {
20760
21358
  case "live": {
20761
21359
  const timing = result.timing ? ` (${result.timing.totalSeconds}s)` : "";
20762
21360
  await writeLine(ctx, `${import_picocolors3.default.green("✓")} Published to ${result.url}${timing}`);
20763
- if (result.cacheHit === true) {
21361
+ if (result.traceId) {
21362
+ await writeLine(ctx, import_picocolors3.default.dim(` Trace: ${result.traceId}`));
21363
+ }
21364
+ if (result.cacheReused) {
21365
+ await writeLine(ctx, import_picocolors3.default.dim(" Build cache: reused — identical bundle, redeployed without re-upload (--no-cache to force a fresh build)"));
21366
+ } else if (result.cacheHit === true) {
20764
21367
  await writeLine(ctx, import_picocolors3.default.dim(" Build cache: hit (reused image — re-run with --no-cache if your changes aren't reflected)"));
20765
21368
  } else if (result.cacheHit === false) {
20766
21369
  await writeLine(ctx, import_picocolors3.default.dim(" Build cache: miss (fresh build)"));
@@ -21116,11 +21719,11 @@ function registerDeploysCommands(cli, ctx, argv) {
21116
21719
  await writeLine(ctx, `${import_picocolors6.default.bold("ID".padEnd(22))} ${import_picocolors6.default.bold("STATUS".padEnd(10))} ${import_picocolors6.default.bold("CREATED".padEnd(20))} ${import_picocolors6.default.bold("ERROR")}`);
21117
21720
  for (const d of list) {
21118
21721
  const id = d.id.padEnd(22);
21119
- const status = d.status.padEnd(10);
21722
+ const status2 = d.status.padEnd(10);
21120
21723
  const created = formatDeployTimestamp(d.createdAt).padEnd(20);
21121
21724
  const err = d.errorMessage ? d.errorMessage.split(`
21122
21725
  `)[0]?.slice(0, 60) ?? "—" : import_picocolors6.default.dim("—");
21123
- const colored = d.status === "live" ? import_picocolors6.default.green(status) : d.status === "failed" ? import_picocolors6.default.red(status) : import_picocolors6.default.yellow(status);
21726
+ const colored = d.status === "live" ? import_picocolors6.default.green(status2) : d.status === "failed" ? import_picocolors6.default.red(status2) : import_picocolors6.default.yellow(status2);
21124
21727
  await writeLine(ctx, `${import_picocolors6.default.dim(id)} ${colored} ${import_picocolors6.default.dim(created)} ${err}`);
21125
21728
  }
21126
21729
  });
@@ -21338,6 +21941,49 @@ function registerGithubCommands(cli, ctx) {
21338
21941
  cli.command("github setup-webhook", "Regenerate the GitHub webhook secret for an app (use after a failed `github connect`)").option("--app <name>", "App name (overrides percher.toml)").action(async () => {});
21339
21942
  }
21340
21943
 
21944
+ // src/commands/status.ts
21945
+ var import_picocolors9 = __toESM(require_picocolors(), 1);
21946
+ function registerStatusCommands(cli, ctx) {
21947
+ cli.command("status", "Show Percher platform status (API + worker health)").option("--json", "Output JSON instead of formatted text").action(async (options) => {
21948
+ const result = await status(ctx, { json: Boolean(options.json) });
21949
+ if (options.json) {
21950
+ await writeLine(ctx, JSON.stringify(result, null, 2));
21951
+ if (result.verdict === "unreachable")
21952
+ throw new SilentExitError(1);
21953
+ return;
21954
+ }
21955
+ await renderHumanStatus(ctx, result);
21956
+ if (result.verdict === "unreachable")
21957
+ throw new SilentExitError(1);
21958
+ });
21959
+ }
21960
+ async function renderHumanStatus(ctx, result) {
21961
+ const symbol = result.verdict === "operational" ? import_picocolors9.default.green("✓") : result.verdict === "worker-degraded" ? import_picocolors9.default.yellow("⚠") : import_picocolors9.default.red("✗");
21962
+ await writeLine(ctx, `${symbol} ${result.summary}`);
21963
+ if (!result.health)
21964
+ return;
21965
+ const worker = result.health.worker;
21966
+ if (!worker) {
21967
+ await writeLine(ctx, import_picocolors9.default.dim(" worker: not configured (local Docker socket)"));
21968
+ return;
21969
+ }
21970
+ if (worker.health) {
21971
+ const ok = worker.health.ok;
21972
+ const symbol2 = ok ? import_picocolors9.default.green("✓") : import_picocolors9.default.red("✗");
21973
+ const lastOk = worker.health.lastOkAt ?? "never";
21974
+ await writeLine(ctx, ` ${symbol2} worker health: ${ok ? "ok" : `failing (${worker.health.consecutiveErrs} consecutive)`} last-ok=${import_picocolors9.default.dim(lastOk)}`);
21975
+ }
21976
+ if (worker.circuitBreaker) {
21977
+ const breaker = worker.circuitBreaker;
21978
+ const symbol2 = breaker.open ? import_picocolors9.default.red("✗") : import_picocolors9.default.green("✓");
21979
+ const state = breaker.open ? "open (deploys fail fast)" : "closed";
21980
+ await writeLine(ctx, ` ${symbol2} circuit breaker: ${state} ${breaker.failuresInWindow}/${breaker.threshold} failures in last ${Math.round(breaker.windowMs / 1000)}s`);
21981
+ }
21982
+ if (result.health.version) {
21983
+ await writeLine(ctx, import_picocolors9.default.dim(` api version: ${result.health.version}`));
21984
+ }
21985
+ }
21986
+
21341
21987
  // src/open.ts
21342
21988
  import { spawn } from "node:child_process";
21343
21989
  async function openUrl(url) {
@@ -21397,40 +22043,40 @@ async function createCliContext() {
21397
22043
  }
21398
22044
 
21399
22045
  // src/output.ts
21400
- var import_picocolors9 = __toESM(require_picocolors(), 1);
22046
+ var import_picocolors10 = __toESM(require_picocolors(), 1);
21401
22047
  async function renderError(stderr, error) {
21402
22048
  const writer = stderr.getWriter();
21403
22049
  const encoder = new TextEncoder;
21404
22050
  const lines = [];
21405
22051
  if (error instanceof PercherTomlError) {
21406
- lines.push(`${import_picocolors9.default.red(`error (${error.code}):`)} ${error.message}`);
22052
+ lines.push(`${import_picocolors10.default.red(`error (${error.code}):`)} ${error.message}`);
21407
22053
  if (error.tomlLine !== undefined) {
21408
- lines.push(import_picocolors9.default.dim(` at percher.toml line ${error.tomlLine}`));
22054
+ lines.push(import_picocolors10.default.dim(` at percher.toml line ${error.tomlLine}`));
21409
22055
  }
21410
22056
  for (const issue of error.issues ?? []) {
21411
- lines.push(`${import_picocolors9.default.dim(" · ") + import_picocolors9.default.yellow(issue.path.join("."))}: ${issue.message}`);
22057
+ lines.push(`${import_picocolors10.default.dim(" · ") + import_picocolors10.default.yellow(issue.path.join("."))}: ${issue.message}`);
21412
22058
  }
21413
22059
  } else if (error instanceof PercherApiError) {
21414
22060
  if (error.status === 502 || error.status === 503) {
21415
- lines.push(`${import_picocolors9.default.red("error:")} Percher API is temporarily unavailable (HTTP ${error.status}). Try again in a few seconds.`);
22061
+ lines.push(`${import_picocolors10.default.red("error:")} Percher API is temporarily unavailable (HTTP ${error.status}). Try again in a few seconds.`);
21416
22062
  } else {
21417
- lines.push(`${import_picocolors9.default.red("error:")} ${error.message}`);
21418
- lines.push(import_picocolors9.default.dim(` ${error.code} (HTTP ${error.status})`));
22063
+ lines.push(`${import_picocolors10.default.red("error:")} ${error.message}`);
22064
+ lines.push(import_picocolors10.default.dim(` ${error.code} (HTTP ${error.status})`));
21419
22065
  }
21420
22066
  if (error.requestId)
21421
- lines.push(import_picocolors9.default.dim(` request: ${error.requestId}`));
22067
+ lines.push(import_picocolors10.default.dim(` request: ${error.requestId}`));
21422
22068
  } else if (error instanceof PercherCoreError) {
21423
- lines.push(`${import_picocolors9.default.red(`error (${error.code}):`)} ${error.message}`);
22069
+ lines.push(`${import_picocolors10.default.red(`error (${error.code}):`)} ${error.message}`);
21424
22070
  if (error.hint)
21425
- lines.push(import_picocolors9.default.dim(` ${error.hint}`));
22071
+ lines.push(import_picocolors10.default.dim(` ${error.hint}`));
21426
22072
  } else if (error instanceof Error) {
21427
- lines.push(`${import_picocolors9.default.red("unexpected error:")} ${error.message}`);
22073
+ lines.push(`${import_picocolors10.default.red("unexpected error:")} ${error.message}`);
21428
22074
  if (error.stack)
21429
- lines.push(import_picocolors9.default.dim(error.stack.split(`
22075
+ lines.push(import_picocolors10.default.dim(error.stack.split(`
21430
22076
  `).slice(1, 4).join(`
21431
22077
  `)));
21432
22078
  } else {
21433
- lines.push(`${import_picocolors9.default.red("unexpected error:")} ${String(error)}`);
22079
+ lines.push(`${import_picocolors10.default.red("unexpected error:")} ${String(error)}`);
21434
22080
  }
21435
22081
  try {
21436
22082
  await writer.write(encoder.encode(`${lines.join(`
@@ -21443,7 +22089,7 @@ async function renderError(stderr, error) {
21443
22089
  // package.json
21444
22090
  var package_default = {
21445
22091
  name: "percher",
21446
- version: "0.3.0",
22092
+ version: "0.4.0",
21447
22093
  description: "CLI for the Percher hosting platform — publish apps with one command",
21448
22094
  type: "module",
21449
22095
  bin: {
@@ -21466,7 +22112,7 @@ var package_default = {
21466
22112
  },
21467
22113
  dependencies: {
21468
22114
  "@percher/client": "^0.2.7",
21469
- "@percher/core": "^0.3.0",
22115
+ "@percher/core": "^0.4.0",
21470
22116
  "@percher/toml": "^0.1.3",
21471
22117
  cac: "^7.0.0",
21472
22118
  picocolors: "^1.1.1"
@@ -21476,7 +22122,7 @@ var package_default = {
21476
22122
  },
21477
22123
  engines: {
21478
22124
  bun: ">=1.0.0",
21479
- node: ">=20.19.0"
22125
+ node: ">=22.0.0"
21480
22126
  },
21481
22127
  license: "MIT",
21482
22128
  author: "Mattias Malmborg",
@@ -21531,7 +22177,7 @@ async function showTelemetryNotice() {
21531
22177
  }
21532
22178
 
21533
22179
  // src/version-check.ts
21534
- var import_picocolors10 = __toESM(require_picocolors(), 1);
22180
+ var import_picocolors11 = __toESM(require_picocolors(), 1);
21535
22181
  async function checkForUpdates() {
21536
22182
  try {
21537
22183
  const res = await fetch("https://registry.npmjs.org/percher/latest", {
@@ -21542,9 +22188,9 @@ async function checkForUpdates() {
21542
22188
  const data2 = await res.json();
21543
22189
  const latest = data2.version;
21544
22190
  if (latest && latest !== VERSION) {
21545
- console.error(import_picocolors10.default.yellow(`
22191
+ console.error(import_picocolors11.default.yellow(`
21546
22192
  Update available: ${VERSION} → ${latest}`));
21547
- console.error(import_picocolors10.default.dim(`Run: bunx percher@latest
22193
+ console.error(import_picocolors11.default.dim(`Run: bunx percher@latest
21548
22194
  `));
21549
22195
  }
21550
22196
  } catch {}
@@ -21561,6 +22207,7 @@ async function run(argv, ctxOverride) {
21561
22207
  registerAuthCommands(cli, ctx);
21562
22208
  registerAccountCommands(cli, ctx);
21563
22209
  registerGithubCommands(cli, ctx);
22210
+ registerStatusCommands(cli, ctx);
21564
22211
  cli.help();
21565
22212
  cli.version(VERSION);
21566
22213
  const command = argv[0] ?? "help";