@percher/core 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.
Files changed (70) hide show
  1. package/dist/commands/data.d.ts +2 -2
  2. package/dist/commands/dev.d.ts.map +1 -1
  3. package/dist/commands/dev.js +2 -1
  4. package/dist/commands/dev.js.map +1 -1
  5. package/dist/commands/doctor.d.ts +12 -2
  6. package/dist/commands/doctor.d.ts.map +1 -1
  7. package/dist/commands/doctor.js +30 -0
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/publish-failure.d.ts.map +1 -1
  10. package/dist/commands/publish-failure.js +11 -3
  11. package/dist/commands/publish-failure.js.map +1 -1
  12. package/dist/commands/publish-node.d.ts +5 -2
  13. package/dist/commands/publish-node.d.ts.map +1 -1
  14. package/dist/commands/publish-node.js +7 -3
  15. package/dist/commands/publish-node.js.map +1 -1
  16. package/dist/commands/publish.d.ts +18 -0
  17. package/dist/commands/publish.d.ts.map +1 -1
  18. package/dist/commands/publish.js +292 -137
  19. package/dist/commands/publish.js.map +1 -1
  20. package/dist/commands/push.d.ts.map +1 -1
  21. package/dist/commands/push.js +23 -5
  22. package/dist/commands/push.js.map +1 -1
  23. package/dist/commands/redeploy.d.ts.map +1 -1
  24. package/dist/commands/redeploy.js +18 -14
  25. package/dist/commands/redeploy.js.map +1 -1
  26. package/dist/commands/rollback.d.ts +2 -1
  27. package/dist/commands/rollback.d.ts.map +1 -1
  28. package/dist/commands/rollback.js +2 -1
  29. package/dist/commands/rollback.js.map +1 -1
  30. package/dist/commands/status.d.ts +33 -0
  31. package/dist/commands/status.d.ts.map +1 -0
  32. package/dist/commands/status.js +48 -0
  33. package/dist/commands/status.js.map +1 -0
  34. package/dist/commands/wait-deploy.d.ts.map +1 -1
  35. package/dist/commands/wait-deploy.js +4 -3
  36. package/dist/commands/wait-deploy.js.map +1 -1
  37. package/dist/error-classifier.js +1 -1
  38. package/dist/error-classifier.js.map +1 -1
  39. package/dist/event-renderer.d.ts +17 -0
  40. package/dist/event-renderer.d.ts.map +1 -0
  41. package/dist/event-renderer.js +130 -0
  42. package/dist/event-renderer.js.map +1 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/plans.d.ts +9 -0
  48. package/dist/plans.d.ts.map +1 -1
  49. package/dist/plans.js +5 -0
  50. package/dist/plans.js.map +1 -1
  51. package/dist/poll-deployment.d.ts +13 -1
  52. package/dist/poll-deployment.d.ts.map +1 -1
  53. package/dist/poll-deployment.js +37 -1
  54. package/dist/poll-deployment.js.map +1 -1
  55. package/dist/publish-retry.d.ts +29 -0
  56. package/dist/publish-retry.d.ts.map +1 -0
  57. package/dist/publish-retry.js +224 -0
  58. package/dist/publish-retry.js.map +1 -0
  59. package/dist/recovery.d.ts.map +1 -1
  60. package/dist/recovery.js +2 -1
  61. package/dist/recovery.js.map +1 -1
  62. package/dist/structured-error-codes.d.ts +30 -0
  63. package/dist/structured-error-codes.d.ts.map +1 -0
  64. package/dist/structured-error-codes.js +86 -0
  65. package/dist/structured-error-codes.js.map +1 -0
  66. package/dist/tarball.d.ts +11 -0
  67. package/dist/tarball.d.ts.map +1 -1
  68. package/dist/tarball.js +30 -9
  69. package/dist/tarball.js.map +1 -1
  70. package/package.json +2 -1
@@ -1,10 +1,13 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { PercherApiError, PercherClient, isDeployAlreadyInProgress, } from "@percher/client";
4
+ import { TIMEOUTS } from "@percher/shared/timeouts";
4
5
  import { parseFile } from "@percher/toml";
5
6
  import { z } from "zod/v3";
6
7
  import { classifyError } from "../error-classifier";
8
+ import { renderDeployEvent } from "../event-renderer";
7
9
  import { pollDeployment } from "../poll-deployment";
10
+ import { runDeployWithRetry } from "../publish-retry";
8
11
  import { RECOVERY_NEEDS_LOGIN, RECOVERY_NONE, recoveryAsk, recoveryDoctor, recoveryEnv, recoveryFixConfig, recoveryFromErrorClass, recoveryWait, } from "../recovery";
9
12
  import { createTarball } from "../tarball";
10
13
  import { scanForMissingBuildEnvRefs } from "./env-scan";
@@ -294,148 +297,256 @@ async function publishInner(ctx, input) {
294
297
  const keyList = missingBuildEnvKeys.map((k) => ` • ${k}`).join("\n");
295
298
  ctx.status(`⚠ Source references these vars but they're not in the env store:\n${keyList}\n They will compile to \`undefined\` in the client bundle.\n Note: scans the full project directory, not just deployed files.\n Fix: bunx percher env set KEY=value\n (Re-run with --force to suppress this check.)`);
296
299
  }
297
- // ── 4. Upload ──────────────────────────────────────────────────────
298
- const uploadStart = Date.now();
299
- ctx.status("[3/4] Uploading...");
300
- let deployment;
300
+ // ── 3.7. Preflight ─────────────────────────────────────────────────
301
+ // Phase 7.3 — cheap (~100 ms) probe of platform readiness so we don't
302
+ // burn the user's bandwidth uploading a tarball that's bound for a
303
+ // queue we've already lost contact with. On `accept: false` we surface
304
+ // a `retry` recovery and bail before reading the tarball stream.
305
+ // On a slow queue we emit a heads-up so the agent can manage user
306
+ // expectations ("queued behind 2 others, ~3min wait").
301
307
  try {
302
- const deployResponse = await ctx.client.apps.deploy(app.id, {
303
- tarball: tarball.stream,
304
- type: input.preview ? "preview" : undefined,
305
- note: input.message,
306
- noCache: input.noCache,
307
- });
308
- // FUTURE12 Phase 6d — server-side already_in_progress short-
309
- // circuit. The API returned no new deploy row because there's
310
- // already one in flight for this app; emit a wait_deploy
311
- // recovery pointing at the active deployId so the agent calls
312
- // percher_wait_for_deploy instead of queueing a duplicate.
313
- // Status code: 200 (informational), not an error.
314
- if (isDeployAlreadyInProgress(deployResponse)) {
315
- return buildAlreadyInProgressResult({
316
- active: deployResponse,
317
- app,
318
- tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
319
- cwd: ctx.cwd,
320
- });
321
- }
322
- deployment = deployResponse;
323
- }
324
- catch (err) {
325
- // FUTURE12 Phase 6d — retry-state guardrail. The user already
326
- // burned their one auto-retry inside the 10-minute window after
327
- // an `infra_unavailable` failure. Recovery is `ask_user` with
328
- // retryable=false so an agent doesn't loop indefinitely against
329
- // a non-transient outage.
330
- if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
331
- return buildRetryLimitReachedResult({
332
- err,
333
- app,
334
- tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
335
- cwd: ctx.cwd,
336
- });
337
- }
338
- // Daily-quota gate (Fas 4) returns 429 DAILY_QUOTA_EXCEEDED with
339
- // structured `extra` fields (kind/used/limit/resetAt). Surface
340
- // those to the agent as ask_user — retry only makes sense after
341
- // resetAt, which the message includes verbatim.
342
- // FUTURE12 Phase 6c — pre-queue env gate. Two codes, both 422:
343
- // REQUIRED_ENV_MISSING → recoveryEnv (set the keys, re-publish)
344
- // ENV_KEY_UNDECLARED → recoveryFixConfig (declare in [env])
345
- // Distinct from build-failure missing_env: the gate fires BEFORE
346
- // the deploy queues, so there's no deployId/buildLog to point at —
347
- // the only artifact is the structured `extra` payload.
348
- if (err instanceof PercherApiError && err.code === "REQUIRED_ENV_MISSING") {
349
- const extra = err.extra ?? {};
350
- const keys = extra.keys ?? [];
351
- const source = extra.source ?? "contract";
352
- const sourceText = source === "contract"
353
- ? "declared in your [env].required"
354
- : source === "discovered"
355
- ? "learned from a previous build failure"
356
- : "both declared and learned from a previous build failure";
308
+ const preflight = await ctx.client.platform.preflight();
309
+ if (!preflight.accept) {
310
+ const reasonText = preflight.reason === "worker_circuit_breaker_open"
311
+ ? "platform circuit breaker is open — workers are restarting"
312
+ : preflight.reason === "worker_unhealthy"
313
+ ? "build worker is currently unreachable"
314
+ : "platform is currently unavailable";
357
315
  return {
358
316
  status: "failed",
359
317
  app,
360
318
  fileCount: tarball.fileCount,
361
319
  bytes: tarball.bytes,
362
320
  error: {
363
- title: "Required env keys not set",
364
- explanation: `${keys.length} env ${keys.length === 1 ? "key is" : "keys are"} ${sourceText} but missing from the env store: ${keys.join(", ")}.`,
365
- suggestion: `Run \`bunx percher env set ${keys[0] ?? "KEY"}=value\` (and similarly for the rest) and re-publish.`,
366
- errorClass: "missing_env",
321
+ title: "Platform unavailable",
322
+ explanation: `Preflight refused the upload: ${reasonText}.`,
323
+ suggestion: "Wait ~30s and retry the platform self-heals automatically and the auto-retry loop will pick it up on the next attempt.",
324
+ errorClass: "infra_unavailable",
367
325
  phase: "config",
368
- missingEnvVars: keys,
369
326
  },
370
- recovery: recoveryEnv({
371
- app: app.name,
372
- keys,
373
- reasonCode: "missing_env",
374
- }),
375
- summary: `Missing required env ${keys.length === 1 ? "key" : "keys"}: ${keys.join(", ")}.`,
376
- configPath: tomlPathFor(ctx.cwd),
377
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
378
- };
379
- }
380
- if (err instanceof PercherApiError && err.code === "ENV_KEY_UNDECLARED") {
381
- const extra = err.extra ?? {};
382
- const apiProblems = extra.problems ?? [];
383
- const problems = apiProblems.map((p) => ({
384
- file: p.file,
385
- line: p.line,
386
- 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)"}`,
387
- }));
388
- const uniqueKeys = [...new Set(apiProblems.map((p) => p.key))];
389
- return {
390
- status: "failed",
391
- app,
392
- fileCount: tarball.fileCount,
393
- bytes: tarball.bytes,
394
- error: {
395
- title: "Undeclared env key",
396
- explanation: `Source references ${uniqueKeys.length} env ${uniqueKeys.length === 1 ? "key" : "keys"} that aren't classified in percher.toml's [env] table: ${uniqueKeys.join(", ")}.`,
397
- 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.",
398
- errorClass: "config_invalid",
399
- phase: "config",
400
- relevantFiles: ["percher.toml"],
327
+ recovery: {
328
+ retryable: true,
329
+ nextAction: "retry",
330
+ suggestedTool: "percher_publish",
331
+ args: {},
332
+ reasonCode: "infra_unavailable",
401
333
  },
402
- recovery: recoveryFixConfig({
403
- problems,
404
- reasonCode: "env_key_undeclared",
405
- }),
406
- summary: `${uniqueKeys.length} undeclared env ${uniqueKeys.length === 1 ? "key" : "keys"} in source: ${uniqueKeys.join(", ")}.`,
334
+ summary: `Preflight refused — ${reasonText}.`,
407
335
  configPath: tomlPathFor(ctx.cwd),
408
336
  bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
409
337
  };
410
338
  }
411
- if (err instanceof PercherApiError && err.code === "DAILY_QUOTA_EXCEEDED") {
412
- const extra = err.extra ?? {};
413
- const kind = extra.kind ?? "live";
414
- const used = extra.used ?? 0;
415
- const limit = extra.limit ?? 0;
416
- const resetAt = extra.resetAt ?? "";
417
- const otherKind = kind === "live" ? "preview" : "live";
418
- return {
419
- status: "failed",
420
- app,
421
- fileCount: tarball.fileCount,
422
- bytes: tarball.bytes,
423
- error: {
424
- title: "Daily deploy quota reached",
425
- explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
426
- suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/settings to raise this cap.`,
427
- },
428
- recovery: recoveryAsk({
429
- 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.`,
430
- options: ["wait", `deploy ${otherKind}`, "upgrade"],
431
- reasonCode: "quota_exceeded",
339
+ if (preflight.queueWaitEstimateSec > 60) {
340
+ ctx.status(`Build queue wait: ~${preflight.queueWaitEstimateSec}s your build is queued behind ${preflight.queueDepth} other(s).`);
341
+ }
342
+ }
343
+ catch {
344
+ // Preflight is advisory a network error here just means the CLI
345
+ // proceeds and lets the retry loop (Phase 7.2) handle whatever
346
+ // surfaces during the actual upload.
347
+ }
348
+ // ── 4. Upload ──────────────────────────────────────────────────────
349
+ const uploadStart = Date.now();
350
+ ctx.status("[3/4] Uploading...");
351
+ // Buffer the tarball so the retry loop (Phase 7.2) can resend the
352
+ // same bytes after a transient failure. createTarball returns a
353
+ // streaming source that's consumed-once; collecting it eagerly
354
+ // lets every attempt share the same payload (and the same
355
+ // Idempotency-Key) without re-walking the filesystem.
356
+ const tarballBytes = new Uint8Array(await new Response(tarball.stream).arrayBuffer());
357
+ // Phase 7.8 content-hash the bundle and probe whether we've
358
+ // already deployed identical bytes for this app. On hit we skip the
359
+ // upload entirely and call /rerun on the previous deploy — the
360
+ // existing image_cache short-circuits the build, so the new deploy
361
+ // is live in seconds instead of a 90s build cycle. `--no-cache`
362
+ // bypasses the probe so users with a cache-correctness suspicion
363
+ // can force a fresh upload + build.
364
+ let cacheReusedDeployment = null;
365
+ // Phase 7.8 follow-up — content-hash from createTarball() (sorted
366
+ // entries, deterministic). Hashing the gzipped bytes would not
367
+ // work: tar/gzip headers carry mtime/OS bytes that vary per run,
368
+ // so the same source produced a different hash on every publish.
369
+ // Codex P2, 2026-05-08. Sent as `X-Tarball-Hash` on the upload so
370
+ // the API stores it on `deployments.tarball_hash` for the probe.
371
+ const contentHash = tarball.contentHash;
372
+ if (!input.noCache) {
373
+ try {
374
+ const probe = await ctx.client.platform.cacheProbe({
375
+ appId: app.id,
376
+ tarballHash: contentHash,
377
+ });
378
+ if (probe.hit) {
379
+ ctx.status(`Build cache: identical bundle deployed previously (${probe.lastDeployId}) — redeploying without re-upload.`);
380
+ cacheReusedDeployment = await ctx.client.apps.rerunDeploy(app.id, probe.lastDeployId);
381
+ ctx.status("[4/4] Building & deploying (cache reuse)...");
382
+ }
383
+ }
384
+ catch {
385
+ // Cache-probe is advisory — a network error or 404 here just
386
+ // means the CLI proceeds with a normal upload.
387
+ }
388
+ }
389
+ const idempotencyKey = crypto.randomUUID();
390
+ let deployment;
391
+ if (cacheReusedDeployment) {
392
+ deployment = cacheReusedDeployment;
393
+ }
394
+ else {
395
+ try {
396
+ const { result: deployResponse, attempts: deployAttempts } = await runDeployWithRetry({
397
+ call: (attempt) => ctx.client.apps.deploy(app.id, {
398
+ tarball: tarballBytes,
399
+ type: input.preview ? "preview" : undefined,
400
+ note: input.message,
401
+ noCache: input.noCache,
402
+ idempotencyKey,
403
+ tarballHash: contentHash,
404
+ // Phase 7.5 — claim the count of CLI-side retries that
405
+ // preceded this attempt so the API persists it on the row;
406
+ // swap-stage promotes the live transition to
407
+ // `retry_recovered` when > 0. `attempt` is 1-indexed; first
408
+ // try has 0 retries and the client omits the header.
409
+ retriedAttempts: attempt - 1,
432
410
  }),
433
- summary: `Daily ${kind}-deploy quota reached (${used}/${limit}). Resets ${resetAt}.`,
434
- configPath: tomlPathFor(ctx.cwd),
435
- bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
436
- };
411
+ onRetry: ({ attempt, decision }) => {
412
+ ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
413
+ },
414
+ });
415
+ if (deployAttempts > 1) {
416
+ ctx.status(`Recovered after ${deployAttempts - 1} retry${deployAttempts > 2 ? "ies" : ""}.`);
417
+ }
418
+ // FUTURE12 Phase 6d — server-side already_in_progress short-
419
+ // circuit. The API returned no new deploy row because there's
420
+ // already one in flight for this app; emit a wait_deploy
421
+ // recovery pointing at the active deployId so the agent calls
422
+ // percher_wait_for_deploy instead of queueing a duplicate.
423
+ // Status code: 200 (informational), not an error.
424
+ if (isDeployAlreadyInProgress(deployResponse)) {
425
+ return buildAlreadyInProgressResult({
426
+ active: deployResponse,
427
+ app,
428
+ tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
429
+ cwd: ctx.cwd,
430
+ });
431
+ }
432
+ deployment = deployResponse;
433
+ }
434
+ catch (err) {
435
+ // FUTURE12 Phase 6d — retry-state guardrail. The user already
436
+ // burned their one auto-retry inside the 10-minute window after
437
+ // an `infra_unavailable` failure. Recovery is `ask_user` with
438
+ // retryable=false so an agent doesn't loop indefinitely against
439
+ // a non-transient outage.
440
+ if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
441
+ return buildRetryLimitReachedResult({
442
+ err,
443
+ app,
444
+ tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
445
+ cwd: ctx.cwd,
446
+ });
447
+ }
448
+ // Daily-quota gate (Fas 4) returns 429 DAILY_QUOTA_EXCEEDED with
449
+ // structured `extra` fields (kind/used/limit/resetAt). Surface
450
+ // those to the agent as ask_user — retry only makes sense after
451
+ // resetAt, which the message includes verbatim.
452
+ // FUTURE12 Phase 6c — pre-queue env gate. Two codes, both 422:
453
+ // REQUIRED_ENV_MISSING → recoveryEnv (set the keys, re-publish)
454
+ // ENV_KEY_UNDECLARED → recoveryFixConfig (declare in [env])
455
+ // Distinct from build-failure missing_env: the gate fires BEFORE
456
+ // the deploy queues, so there's no deployId/buildLog to point at —
457
+ // the only artifact is the structured `extra` payload.
458
+ if (err instanceof PercherApiError && err.code === "REQUIRED_ENV_MISSING") {
459
+ const extra = err.extra ?? {};
460
+ const keys = extra.keys ?? [];
461
+ const source = extra.source ?? "contract";
462
+ const sourceText = source === "contract"
463
+ ? "declared in your [env].required"
464
+ : source === "discovered"
465
+ ? "learned from a previous build failure"
466
+ : "both declared and learned from a previous build failure";
467
+ return {
468
+ status: "failed",
469
+ app,
470
+ fileCount: tarball.fileCount,
471
+ bytes: tarball.bytes,
472
+ error: {
473
+ title: "Required env keys not set",
474
+ explanation: `${keys.length} env ${keys.length === 1 ? "key is" : "keys are"} ${sourceText} but missing from the env store: ${keys.join(", ")}.`,
475
+ suggestion: `Run \`bunx percher env set ${keys[0] ?? "KEY"}=value\` (and similarly for the rest) and re-publish.`,
476
+ errorClass: "missing_env",
477
+ phase: "config",
478
+ missingEnvVars: keys,
479
+ },
480
+ recovery: recoveryEnv({
481
+ app: app.name,
482
+ keys,
483
+ reasonCode: "missing_env",
484
+ }),
485
+ summary: `Missing required env ${keys.length === 1 ? "key" : "keys"}: ${keys.join(", ")}.`,
486
+ configPath: tomlPathFor(ctx.cwd),
487
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
488
+ };
489
+ }
490
+ if (err instanceof PercherApiError && err.code === "ENV_KEY_UNDECLARED") {
491
+ const extra = err.extra ?? {};
492
+ const apiProblems = extra.problems ?? [];
493
+ const problems = apiProblems.map((p) => ({
494
+ file: p.file,
495
+ line: p.line,
496
+ 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)"}`,
497
+ }));
498
+ const uniqueKeys = [...new Set(apiProblems.map((p) => p.key))];
499
+ return {
500
+ status: "failed",
501
+ app,
502
+ fileCount: tarball.fileCount,
503
+ bytes: tarball.bytes,
504
+ error: {
505
+ title: "Undeclared env key",
506
+ explanation: `Source references ${uniqueKeys.length} env ${uniqueKeys.length === 1 ? "key" : "keys"} that aren't classified in percher.toml's [env] table: ${uniqueKeys.join(", ")}.`,
507
+ 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.",
508
+ errorClass: "config_invalid",
509
+ phase: "config",
510
+ relevantFiles: ["percher.toml"],
511
+ },
512
+ recovery: recoveryFixConfig({
513
+ problems,
514
+ reasonCode: "env_key_undeclared",
515
+ }),
516
+ summary: `${uniqueKeys.length} undeclared env ${uniqueKeys.length === 1 ? "key" : "keys"} in source: ${uniqueKeys.join(", ")}.`,
517
+ configPath: tomlPathFor(ctx.cwd),
518
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
519
+ };
520
+ }
521
+ if (err instanceof PercherApiError && err.code === "DAILY_QUOTA_EXCEEDED") {
522
+ const extra = err.extra ?? {};
523
+ const kind = extra.kind ?? "live";
524
+ const used = extra.used ?? 0;
525
+ const limit = extra.limit ?? 0;
526
+ const resetAt = extra.resetAt ?? "";
527
+ const otherKind = kind === "live" ? "preview" : "live";
528
+ return {
529
+ status: "failed",
530
+ app,
531
+ fileCount: tarball.fileCount,
532
+ bytes: tarball.bytes,
533
+ error: {
534
+ title: "Daily deploy quota reached",
535
+ explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
536
+ suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/settings to raise this cap.`,
537
+ },
538
+ recovery: recoveryAsk({
539
+ 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.`,
540
+ options: ["wait", `deploy ${otherKind}`, "upgrade"],
541
+ reasonCode: "quota_exceeded",
542
+ }),
543
+ summary: `Daily ${kind}-deploy quota reached (${used}/${limit}). Resets ${resetAt}.`,
544
+ configPath: tomlPathFor(ctx.cwd),
545
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
546
+ };
547
+ }
548
+ throw err;
437
549
  }
438
- throw err;
439
550
  }
440
551
  // Surface the auto-replace one-shot signal — only the initial POST
441
552
  // response carries it, so capture before the polling loop reassigns
@@ -474,7 +585,7 @@ async function publishInner(ctx, input) {
474
585
  // ── 5. Poll ────────────────────────────────────────────────────────
475
586
  ctx.status("[4/4] Building & deploying...");
476
587
  const buildStart = Date.now();
477
- const timeoutMs = 5 * 60 * 1000;
588
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
478
589
  let lastStatus;
479
590
  let nextHeartbeatMs = 15_000;
480
591
  // Heartbeat tracking lives in the caller because pollDeployment is a
@@ -509,6 +620,16 @@ async function publishInner(ctx, input) {
509
620
  nextHeartbeatMs = elapsedMs + 15_000;
510
621
  }
511
622
  },
623
+ // Phase 6.1 — surface stage/sub-stage transitions as they're
624
+ // recorded so the user sees real progress instead of a generic
625
+ // "building..." spinner. renderDeployEvent returns null for
626
+ // hidden rows (e.g. `done/ok`, where the caller prints the
627
+ // final live URL line).
628
+ onEvent: (event) => {
629
+ const line = renderDeployEvent(event);
630
+ if (line)
631
+ ctx.status(line);
632
+ },
512
633
  // publish wants to return a structured PublishResult on timeout
513
634
  // (not throw), so capture the in-flight deployment and break out
514
635
  // via a sentinel object rather than letting the throw bubble.
@@ -616,7 +737,19 @@ async function publishInner(ctx, input) {
616
737
  replacedPreview,
617
738
  firstDeploy: firstDeploy || undefined,
618
739
  cacheHit,
740
+ cacheReused: cacheReusedDeployment !== null || undefined,
619
741
  missingBuildEnvKeys: missingBuildEnvKeys.length > 0 ? missingBuildEnvKeys : undefined,
742
+ // Phase 7.9 — operator-facing trace key. Codex P2 follow-up on
743
+ // b8f469a: previously a CLI-generated UUID, but the API/worker
744
+ // path used `deployId` as the wire trace key (build-fetch sets
745
+ // X-Trace-Id: <deployId>, recent-calls indexes on it, worker
746
+ // log echo prefixes lines with it). Aliasing the user-facing
747
+ // value to deployId means `Trace: dep_xxx` in the CLI matches
748
+ // /internal/recent-calls?traceId=dep_xxx and a worker
749
+ // journalctl grep — one key end-to-end. Cache-reuse paths
750
+ // already get a real deployId via /rerun, so this works there
751
+ // too without special-casing.
752
+ traceId: deployment.id,
620
753
  recovery: RECOVERY_NONE,
621
754
  summary: buildLiveSummary({
622
755
  appName: app.name,
@@ -682,17 +815,32 @@ async function handleUnauthorized(ctx, config, tarball, input) {
682
815
  let app;
683
816
  ({ app, firstDeploy: firstDeployRetry } = await ensureApp(ctx, config));
684
817
  ctx.status("Uploading...");
818
+ // Buffer for the retry loop (Phase 7.2) — same pattern as the main
819
+ // publishInner path; the post-login flow gets the same auto-retry
820
+ // protection without a parallel retry helper.
821
+ const freshTarballBytes = new Uint8Array(await new Response(freshTarball.stream).arrayBuffer());
822
+ const freshIdempotencyKey = crypto.randomUUID();
685
823
  let deployment;
686
824
  try {
687
- const deployResponse = await ctx.client.apps.deploy(app.id, {
688
- tarball: freshTarball.stream,
689
- // Preserve preview/note semantics across the post-login retry.
690
- // Without these, an interactive `percher publish --preview -m "x"`
691
- // that hits a 401 would silently downgrade to a live deploy with
692
- // no note after the user logs in. Codex P1, 2026-04-29.
693
- type: input.preview ? "preview" : undefined,
694
- note: input.message,
695
- noCache: input.noCache,
825
+ const { result: deployResponse } = await runDeployWithRetry({
826
+ call: (attempt) => ctx.client.apps.deploy(app.id, {
827
+ tarball: freshTarballBytes,
828
+ // Preserve preview/note semantics across the post-login retry.
829
+ // Without these, an interactive `percher publish --preview -m "x"`
830
+ // that hits a 401 would silently downgrade to a live deploy with
831
+ // no note after the user logs in. Codex P1, 2026-04-29.
832
+ type: input.preview ? "preview" : undefined,
833
+ note: input.message,
834
+ noCache: input.noCache,
835
+ idempotencyKey: freshIdempotencyKey,
836
+ // Phase 7.5 — same X-Publish-Attempts claim as the main
837
+ // publish path; ensures the post-login retry chain still
838
+ // yields a `retry_recovered` outcome when it eventually wins.
839
+ retriedAttempts: attempt - 1,
840
+ }),
841
+ onRetry: ({ attempt, decision }) => {
842
+ ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
843
+ },
696
844
  });
697
845
  // FUTURE12 Phase 6d — same already_in_progress short-circuit as
698
846
  // the main publishInner path. Without this branch, a user who
@@ -746,7 +894,7 @@ async function handleUnauthorized(ctx, config, tarball, input) {
746
894
  bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
747
895
  };
748
896
  }
749
- const timeoutMs = 5 * 60 * 1000;
897
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
750
898
  const start = Date.now();
751
899
  while (deployment.status !== "live" &&
752
900
  deployment.status !== "failed" &&
@@ -818,6 +966,13 @@ async function handleUnauthorized(ctx, config, tarball, input) {
818
966
  firstDeploy: firstDeployRetry || undefined,
819
967
  recovery: RECOVERY_NONE,
820
968
  cacheHit: deployment.cacheHit,
969
+ // Phase 7.9 — operator-facing trace key, same shape the primary
970
+ // success path returns. Codex P2 follow-up on daf063f: the
971
+ // post-login retry path was returning a PublishResult without
972
+ // `traceId`, so a user who started unauthenticated, logged in,
973
+ // and then succeeded got no `Trace: dep_xxx` line from the CLI
974
+ // and the correlation key was missing for that publish.
975
+ traceId: deployment.id,
821
976
  summary: buildLiveSummary({
822
977
  appName: app.name,
823
978
  url: url ?? app.url,