@percher/core 0.2.6 → 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 (226) hide show
  1. package/dist/ai-files-manifest.d.ts +28 -0
  2. package/dist/ai-files-manifest.d.ts.map +1 -0
  3. package/dist/ai-files-manifest.js +96 -0
  4. package/dist/ai-files-manifest.js.map +1 -0
  5. package/dist/commands/account.d.ts +51 -0
  6. package/dist/commands/account.d.ts.map +1 -0
  7. package/dist/commands/account.js +88 -0
  8. package/dist/commands/account.js.map +1 -0
  9. package/dist/commands/ai-files.d.ts +73 -0
  10. package/dist/commands/ai-files.d.ts.map +1 -0
  11. package/dist/commands/ai-files.js +179 -0
  12. package/dist/commands/ai-files.js.map +1 -0
  13. package/dist/commands/billing.d.ts +1 -1
  14. package/dist/commands/billing.d.ts.map +1 -1
  15. package/dist/commands/billing.js +1 -1
  16. package/dist/commands/billing.js.map +1 -1
  17. package/dist/commands/continue.d.ts +48 -0
  18. package/dist/commands/continue.d.ts.map +1 -0
  19. package/dist/commands/continue.js +121 -0
  20. package/dist/commands/continue.js.map +1 -0
  21. package/dist/commands/create.d.ts +1 -1
  22. package/dist/commands/create.d.ts.map +1 -1
  23. package/dist/commands/create.js +1 -1
  24. package/dist/commands/create.js.map +1 -1
  25. package/dist/commands/dashboard.d.ts +15 -0
  26. package/dist/commands/dashboard.d.ts.map +1 -0
  27. package/dist/commands/dashboard.js +33 -0
  28. package/dist/commands/dashboard.js.map +1 -0
  29. package/dist/commands/data-export.d.ts +21 -0
  30. package/dist/commands/data-export.d.ts.map +1 -0
  31. package/dist/commands/data-export.js +36 -0
  32. package/dist/commands/data-export.js.map +1 -0
  33. package/dist/commands/data.d.ts +3 -3
  34. package/dist/commands/data.d.ts.map +1 -1
  35. package/dist/commands/data.js +1 -1
  36. package/dist/commands/data.js.map +1 -1
  37. package/dist/commands/delete.d.ts +1 -1
  38. package/dist/commands/delete.d.ts.map +1 -1
  39. package/dist/commands/delete.js +1 -1
  40. package/dist/commands/delete.js.map +1 -1
  41. package/dist/commands/deploys.d.ts +2 -2
  42. package/dist/commands/deploys.d.ts.map +1 -1
  43. package/dist/commands/deploys.js +21 -5
  44. package/dist/commands/deploys.js.map +1 -1
  45. package/dist/commands/dev.d.ts +1 -9
  46. package/dist/commands/dev.d.ts.map +1 -1
  47. package/dist/commands/dev.js +79 -24
  48. package/dist/commands/dev.js.map +1 -1
  49. package/dist/commands/diagnose.d.ts +1 -1
  50. package/dist/commands/diagnose.d.ts.map +1 -1
  51. package/dist/commands/diagnose.js +1 -1
  52. package/dist/commands/diagnose.js.map +1 -1
  53. package/dist/commands/doctor.d.ts +75 -3
  54. package/dist/commands/doctor.d.ts.map +1 -1
  55. package/dist/commands/doctor.js +822 -10
  56. package/dist/commands/doctor.js.map +1 -1
  57. package/dist/commands/domains.d.ts +1 -1
  58. package/dist/commands/domains.d.ts.map +1 -1
  59. package/dist/commands/domains.js +1 -1
  60. package/dist/commands/domains.js.map +1 -1
  61. package/dist/commands/env-scan.d.ts +2 -0
  62. package/dist/commands/env-scan.d.ts.map +1 -0
  63. package/dist/commands/env-scan.js +92 -0
  64. package/dist/commands/env-scan.js.map +1 -0
  65. package/dist/commands/env.d.ts +1 -1
  66. package/dist/commands/env.d.ts.map +1 -1
  67. package/dist/commands/env.js +1 -1
  68. package/dist/commands/env.js.map +1 -1
  69. package/dist/commands/export.d.ts +1 -1
  70. package/dist/commands/export.js +1 -1
  71. package/dist/commands/generate.d.ts +1 -1
  72. package/dist/commands/generate.d.ts.map +1 -1
  73. package/dist/commands/generate.js +14 -9
  74. package/dist/commands/generate.js.map +1 -1
  75. package/dist/commands/github.d.ts +60 -0
  76. package/dist/commands/github.d.ts.map +1 -0
  77. package/dist/commands/github.js +112 -0
  78. package/dist/commands/github.js.map +1 -0
  79. package/dist/commands/import-project.d.ts +1 -1
  80. package/dist/commands/import-project.d.ts.map +1 -1
  81. package/dist/commands/import-project.js +1 -1
  82. package/dist/commands/import-project.js.map +1 -1
  83. package/dist/commands/init.d.ts +1 -1
  84. package/dist/commands/init.d.ts.map +1 -1
  85. package/dist/commands/init.js +1 -1
  86. package/dist/commands/init.js.map +1 -1
  87. package/dist/commands/insights.d.ts +1 -1
  88. package/dist/commands/insights.d.ts.map +1 -1
  89. package/dist/commands/insights.js +1 -1
  90. package/dist/commands/insights.js.map +1 -1
  91. package/dist/commands/login.d.ts +1 -1
  92. package/dist/commands/login.d.ts.map +1 -1
  93. package/dist/commands/login.js +1 -1
  94. package/dist/commands/login.js.map +1 -1
  95. package/dist/commands/logs.d.ts +1 -1
  96. package/dist/commands/logs.d.ts.map +1 -1
  97. package/dist/commands/logs.js +1 -1
  98. package/dist/commands/logs.js.map +1 -1
  99. package/dist/commands/mcp.d.ts +1 -1
  100. package/dist/commands/mcp.d.ts.map +1 -1
  101. package/dist/commands/mcp.js +1 -1
  102. package/dist/commands/mcp.js.map +1 -1
  103. package/dist/commands/open.d.ts +1 -1
  104. package/dist/commands/open.d.ts.map +1 -1
  105. package/dist/commands/open.js +1 -1
  106. package/dist/commands/open.js.map +1 -1
  107. package/dist/commands/publish-failure.d.ts +31 -0
  108. package/dist/commands/publish-failure.d.ts.map +1 -0
  109. package/dist/commands/publish-failure.js +150 -0
  110. package/dist/commands/publish-failure.js.map +1 -0
  111. package/dist/commands/publish-node.d.ts +16 -0
  112. package/dist/commands/publish-node.d.ts.map +1 -0
  113. package/dist/commands/publish-node.js +42 -0
  114. package/dist/commands/publish-node.js.map +1 -0
  115. package/dist/commands/publish.d.ts +105 -3
  116. package/dist/commands/publish.d.ts.map +1 -1
  117. package/dist/commands/publish.js +746 -158
  118. package/dist/commands/publish.js.map +1 -1
  119. package/dist/commands/push.d.ts +45 -8
  120. package/dist/commands/push.d.ts.map +1 -1
  121. package/dist/commands/push.js +233 -22
  122. package/dist/commands/push.js.map +1 -1
  123. package/dist/commands/redeploy.d.ts +28 -0
  124. package/dist/commands/redeploy.d.ts.map +1 -0
  125. package/dist/commands/redeploy.js +421 -0
  126. package/dist/commands/redeploy.js.map +1 -0
  127. package/dist/commands/rename.d.ts +1 -1
  128. package/dist/commands/rename.d.ts.map +1 -1
  129. package/dist/commands/rename.js +1 -1
  130. package/dist/commands/rename.js.map +1 -1
  131. package/dist/commands/reproduce.d.ts +64 -0
  132. package/dist/commands/reproduce.d.ts.map +1 -0
  133. package/dist/commands/reproduce.js +211 -0
  134. package/dist/commands/reproduce.js.map +1 -0
  135. package/dist/commands/reset-superuser.d.ts +1 -1
  136. package/dist/commands/reset-superuser.d.ts.map +1 -1
  137. package/dist/commands/reset-superuser.js +1 -1
  138. package/dist/commands/reset-superuser.js.map +1 -1
  139. package/dist/commands/restore.d.ts +79 -0
  140. package/dist/commands/restore.d.ts.map +1 -0
  141. package/dist/commands/restore.js +164 -0
  142. package/dist/commands/restore.js.map +1 -0
  143. package/dist/commands/resume.d.ts +1 -1
  144. package/dist/commands/resume.d.ts.map +1 -1
  145. package/dist/commands/resume.js +1 -1
  146. package/dist/commands/resume.js.map +1 -1
  147. package/dist/commands/rollback.d.ts +21 -8
  148. package/dist/commands/rollback.d.ts.map +1 -1
  149. package/dist/commands/rollback.js +12 -6
  150. package/dist/commands/rollback.js.map +1 -1
  151. package/dist/commands/status.d.ts +33 -0
  152. package/dist/commands/status.d.ts.map +1 -0
  153. package/dist/commands/status.js +48 -0
  154. package/dist/commands/status.js.map +1 -0
  155. package/dist/commands/unsuspend.d.ts +35 -0
  156. package/dist/commands/unsuspend.d.ts.map +1 -0
  157. package/dist/commands/unsuspend.js +27 -0
  158. package/dist/commands/unsuspend.js.map +1 -0
  159. package/dist/commands/versions.d.ts +1 -1
  160. package/dist/commands/versions.d.ts.map +1 -1
  161. package/dist/commands/versions.js +1 -1
  162. package/dist/commands/versions.js.map +1 -1
  163. package/dist/commands/wait-deploy.d.ts +92 -0
  164. package/dist/commands/wait-deploy.d.ts.map +1 -0
  165. package/dist/commands/wait-deploy.js +226 -0
  166. package/dist/commands/wait-deploy.js.map +1 -0
  167. package/dist/env-scan-source.d.ts +39 -0
  168. package/dist/env-scan-source.d.ts.map +1 -0
  169. package/dist/env-scan-source.js +332 -0
  170. package/dist/env-scan-source.js.map +1 -0
  171. package/dist/error-classifier.d.ts.map +1 -1
  172. package/dist/error-classifier.js +67 -4
  173. package/dist/error-classifier.js.map +1 -1
  174. package/dist/errors.d.ts +8 -1
  175. package/dist/errors.d.ts.map +1 -1
  176. package/dist/errors.js +2 -0
  177. package/dist/errors.js.map +1 -1
  178. package/dist/event-renderer.d.ts +17 -0
  179. package/dist/event-renderer.d.ts.map +1 -0
  180. package/dist/event-renderer.js +130 -0
  181. package/dist/event-renderer.js.map +1 -0
  182. package/dist/index.d.ts +16 -1
  183. package/dist/index.d.ts.map +1 -1
  184. package/dist/index.js +15 -0
  185. package/dist/index.js.map +1 -1
  186. package/dist/plans.d.ts +20 -0
  187. package/dist/plans.d.ts.map +1 -1
  188. package/dist/plans.js +15 -0
  189. package/dist/plans.js.map +1 -1
  190. package/dist/poll-deployment.d.ts +59 -0
  191. package/dist/poll-deployment.d.ts.map +1 -0
  192. package/dist/poll-deployment.js +93 -0
  193. package/dist/poll-deployment.js.map +1 -0
  194. package/dist/publish-retry.d.ts +29 -0
  195. package/dist/publish-retry.d.ts.map +1 -0
  196. package/dist/publish-retry.js +224 -0
  197. package/dist/publish-retry.js.map +1 -0
  198. package/dist/recovery.d.ts +356 -0
  199. package/dist/recovery.d.ts.map +1 -0
  200. package/dist/recovery.js +300 -0
  201. package/dist/recovery.js.map +1 -0
  202. package/dist/stream-utils.d.ts +21 -0
  203. package/dist/stream-utils.d.ts.map +1 -0
  204. package/dist/stream-utils.js +41 -0
  205. package/dist/stream-utils.js.map +1 -0
  206. package/dist/structured-error-codes.d.ts +30 -0
  207. package/dist/structured-error-codes.d.ts.map +1 -0
  208. package/dist/structured-error-codes.js +86 -0
  209. package/dist/structured-error-codes.js.map +1 -0
  210. package/dist/tarball.d.ts +11 -0
  211. package/dist/tarball.d.ts.map +1 -1
  212. package/dist/tarball.js +30 -9
  213. package/dist/tarball.js.map +1 -1
  214. package/dist/templates/ai-files/claude-md.d.ts +7 -0
  215. package/dist/templates/ai-files/claude-md.d.ts.map +1 -0
  216. package/dist/templates/ai-files/claude-md.js +78 -0
  217. package/dist/templates/ai-files/claude-md.js.map +1 -0
  218. package/dist/templates/ai-files/cursor-percher-mdc.d.ts +7 -0
  219. package/dist/templates/ai-files/cursor-percher-mdc.d.ts.map +1 -0
  220. package/dist/templates/ai-files/cursor-percher-mdc.js +111 -0
  221. package/dist/templates/ai-files/cursor-percher-mdc.js.map +1 -0
  222. package/dist/templates/ai-files/index.d.ts +8 -0
  223. package/dist/templates/ai-files/index.d.ts.map +1 -0
  224. package/dist/templates/ai-files/index.js +4 -0
  225. package/dist/templates/ai-files/index.js.map +1 -0
  226. package/package.json +6 -5
@@ -1,12 +1,21 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { PercherApiError, PercherClient } from "@percher/client";
3
+ import { PercherApiError, PercherClient, isDeployAlreadyInProgress, } from "@percher/client";
4
+ import { TIMEOUTS } from "@percher/shared/timeouts";
4
5
  import { parseFile } from "@percher/toml";
5
- import { z } from "zod";
6
+ import { z } from "zod/v3";
6
7
  import { classifyError } from "../error-classifier";
8
+ import { renderDeployEvent } from "../event-renderer";
9
+ import { pollDeployment } from "../poll-deployment";
10
+ import { runDeployWithRetry } from "../publish-retry";
11
+ import { RECOVERY_NEEDS_LOGIN, RECOVERY_NONE, recoveryAsk, recoveryDoctor, recoveryEnv, recoveryFixConfig, recoveryFromErrorClass, recoveryWait, } from "../recovery";
7
12
  import { createTarball } from "../tarball";
13
+ import { scanForMissingBuildEnvRefs } from "./env-scan";
8
14
  import { init } from "./init";
9
15
  import { login } from "./login";
16
+ import { buildFailureResult } from "./publish-failure";
17
+ import { resolveNodeVersion } from "./publish-node";
18
+ import { resolveReplaced } from "./wait-deploy";
10
19
  export const publishInputSchema = z.object({
11
20
  force: z.boolean().optional().describe("Skip size warnings"),
12
21
  preview: z.boolean().optional().describe("Deploy as preview (does not replace the live version)"),
@@ -18,10 +27,129 @@ export const publishInputSchema = z.object({
18
27
  .boolean()
19
28
  .optional()
20
29
  .describe("Show what would be deployed (file count, bytes, top files) without uploading or building. Useful for verifying the bundle before a real deploy."),
30
+ noCache: z
31
+ .boolean()
32
+ .optional()
33
+ .describe("Skip the image-cache lookup and force a fresh build. Use this when you suspect the cached image doesn't reflect your latest source — the new build's resulting image still gets cached, so future deploys benefit."),
34
+ waitForLive: z
35
+ .boolean()
36
+ .optional()
37
+ .describe("If true (default), publish blocks until the deploy is live or failed. If false, publish returns as soon as the deploy is queued and the agent can resume with percher_wait_for_deploy. Recommended for AI agents — gives back control quickly with a deployId so they can decide between waiting, asking the user, or working on something else."),
21
38
  });
22
39
  const MAX_BYTES = 500 * 1024 * 1024;
23
40
  const SUSPICIOUS_BYTES = 50 * 1024 * 1024;
24
41
  const SUSPICIOUS_FILES = 10_000;
42
+ function tomlPathFor(cwd) {
43
+ return join(cwd, "percher.toml");
44
+ }
45
+ /**
46
+ * FUTURE11 review P2 — sync publish path's `replaced` terminal result.
47
+ * `replaced` has two API code-paths: a newer live deploy succeeded, or
48
+ * reconcile saw the container is gone. We can't tell which from the
49
+ * row itself, so resolve by fetching the latest deploy and routing
50
+ * accordingly (live → none + url; in-flight → wait_deploy; otherwise
51
+ * ask_user). Matches FUTURE12 Phase 4's locked behavior for
52
+ * `replaced_by_newer`.
53
+ */
54
+ async function buildReplacedResult(opts) {
55
+ const { ctx, app, deployment, tarball, cwd } = opts;
56
+ const resolved = await resolveReplaced({
57
+ ctx,
58
+ app,
59
+ replacedDeployment: deployment,
60
+ });
61
+ return {
62
+ status: "replaced",
63
+ url: resolved.url,
64
+ app,
65
+ deployment,
66
+ fileCount: tarball.fileCount,
67
+ bytes: tarball.bytes,
68
+ recovery: resolved.recovery,
69
+ summary: resolved.summary,
70
+ configPath: tomlPathFor(cwd),
71
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
72
+ };
73
+ }
74
+ /**
75
+ * FUTURE12 Phase 6d — server told us a deploy is already in flight for
76
+ * this app. Build a successful PublishResult that points the agent at
77
+ * the existing deployId via `wait_deploy` (retryable: false — the agent
78
+ * MUST wait, not retry).
79
+ */
80
+ function buildAlreadyInProgressResult(opts) {
81
+ const { active, app, tarball, cwd } = opts;
82
+ const reasonCode = active.deployStatus === "queued"
83
+ ? "deploy_queued"
84
+ : active.deployStatus === "building"
85
+ ? "deploy_building"
86
+ : "deploy_deploying";
87
+ return {
88
+ status: "already_in_progress",
89
+ app,
90
+ fileCount: tarball.fileCount,
91
+ bytes: tarball.bytes,
92
+ recovery: recoveryWait({
93
+ app: app.name,
94
+ deployId: active.deployId,
95
+ reasonCode,
96
+ }),
97
+ summary: `${app.name} already has a deploy in progress (${active.deployId}, ${active.deployStatus}) — wait for it to finish instead of queueing a duplicate.`,
98
+ configPath: tomlPathFor(cwd),
99
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
100
+ };
101
+ }
102
+ /**
103
+ * FUTURE12 Phase 6d — server rejected this publish because the user
104
+ * already used their one auto-retry within the 10-minute window after
105
+ * an `infra_unavailable` failure. Build an `ask_user` recovery so the
106
+ * agent surfaces it to the human instead of looping.
107
+ */
108
+ function buildRetryLimitReachedResult(opts) {
109
+ const { err, app, tarball, cwd } = opts;
110
+ const extra = err.extra ?? {};
111
+ const resetAtStr = extra.resetAt ?? "";
112
+ const resetAtForPrompt = resetAtStr || "the cooldown window expires";
113
+ const prompt = `Percher already retried once after an infrastructure failure for ${app.name}. Wait until ${resetAtForPrompt} or surface to the user.`;
114
+ return {
115
+ status: "failed",
116
+ app,
117
+ fileCount: tarball.fileCount,
118
+ bytes: tarball.bytes,
119
+ error: {
120
+ title: "Retry limit reached",
121
+ explanation: "Percher already auto-retried once after an `infra_unavailable` failure for this app within the last 10 minutes. We won't auto-retry again — the next attempt should be a deliberate user decision.",
122
+ suggestion: `Wait until ${resetAtForPrompt}, then re-run \`percher publish\`. If the failure persists past that, the underlying issue is probably not transient — run \`percher doctor\` to diagnose.`,
123
+ },
124
+ recovery: recoveryAsk({
125
+ prompt,
126
+ options: ["wait before retrying", "inspect status"],
127
+ reasonCode: "retry_limit_reached",
128
+ retryable: false,
129
+ }),
130
+ summary: `Retry limit reached for ${app.name}${resetAtStr ? ` — wait until ${resetAtStr}` : ""}.`,
131
+ configPath: tomlPathFor(cwd),
132
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
133
+ };
134
+ }
135
+ /**
136
+ * Build the one-line summary echoed back to humans. Includes app name,
137
+ * framework when known, total seconds, and final URL — covers the
138
+ * "deployed `kvittakvitto` (Next.js, 47s) → kvittakvitto.percher.run"
139
+ * shape called out in the agent-DX plan.
140
+ */
141
+ function buildLiveSummary(opts) {
142
+ // Suffix: "(framework, Ns)" with optional "cached" tag when the
143
+ // image cache matched. Cache-hit deploys are typically <10s so the
144
+ // tag is the load-bearing signal in the summary, not the duration.
145
+ const cacheTag = opts.cacheHit ? "cached, " : "";
146
+ const fwSuffix = opts.framework
147
+ ? ` (${opts.framework}, ${cacheTag}${opts.totalSeconds}s)`
148
+ : ` (${cacheTag}${opts.totalSeconds}s)`;
149
+ const verb = opts.preview ? "preview deployed" : "deployed";
150
+ const replaced = opts.replacedPreview ? " — replaced previous preview" : "";
151
+ return `${opts.appName} ${verb}${fwSuffix} → ${opts.url}${replaced}`;
152
+ }
25
153
  /**
26
154
  * All-in-one publish command for AI agents.
27
155
  *
@@ -47,12 +175,23 @@ export async function publish(ctx, input = {}) {
47
175
  explanation: hint ?? message,
48
176
  suggestion: "This may be a network issue or a Percher bug. Check your connection and try again. If the problem persists, run percher_doctor for diagnostics.",
49
177
  },
178
+ // Network glitches surface here too, but we can't tell them apart
179
+ // from genuine bugs without inspecting the cause. `ask_user` keeps
180
+ // the agent honest — surface the explanation, don't auto-retry.
181
+ recovery: recoveryAsk({
182
+ prompt: `Unexpected publish failure: ${hint ?? message}. Run \`percher doctor\` for diagnostics or surface this to the user.`,
183
+ reasonCode: "unknown",
184
+ }),
185
+ summary: `Unexpected error: ${hint ?? message}`,
186
+ configPath: tomlPathFor(ctx.cwd),
187
+ bundle: { fileCount: 0, bytes: 0 },
50
188
  };
51
189
  }
52
190
  }
53
191
  async function publishInner(ctx, input) {
54
192
  const t0 = Date.now();
55
193
  // ── 1. Config ──────────────────────────────────────────────────────
194
+ ctx.status("[1/4] Reading config...");
56
195
  let config;
57
196
  try {
58
197
  config = await loadOrGenerate(ctx);
@@ -67,9 +206,22 @@ async function publishInner(ctx, input) {
67
206
  explanation: "percher.toml could not be auto-generated for this project.",
68
207
  suggestion: "Create a percher.toml manually with at least [app] name and runtime, then try again.",
69
208
  },
209
+ recovery: recoveryFixConfig({
210
+ problems: [
211
+ {
212
+ file: "percher.toml",
213
+ message: "percher.toml could not be auto-generated for this project. Create one manually with at least [app] name and runtime.",
214
+ },
215
+ ],
216
+ reasonCode: "config_missing",
217
+ }),
218
+ summary: "Could not generate percher.toml — create one manually and retry.",
219
+ configPath: tomlPathFor(ctx.cwd),
220
+ bundle: { fileCount: 0, bytes: 0 },
70
221
  };
71
222
  }
72
223
  // ── 2. Tarball ─────────────────────────────────────────────────────
224
+ ctx.status("[2/4] Packaging files...");
73
225
  const tarball = await createTarball({ cwd: ctx.cwd, config });
74
226
  const packageMs = Date.now() - t0;
75
227
  if (tarball.bytes > MAX_BYTES) {
@@ -82,6 +234,16 @@ async function publishInner(ctx, input) {
82
234
  explanation: `The project is ${(tarball.bytes / 1024 / 1024).toFixed(1)} MB, which exceeds the 500 MB limit.`,
83
235
  suggestion: `Add large files/folders to .gitignore. Biggest: ${tarball.topFiles.map((f) => f.path).join(", ")}`,
84
236
  },
237
+ // Not retryable, not config-invalid in the toml sense — just too big.
238
+ // The user has to make a manual decision (add to .gitignore, split repo,
239
+ // bump plan limits). `ask_user` is the honest action.
240
+ recovery: recoveryAsk({
241
+ prompt: `Repository is ${(tarball.bytes / 1024 / 1024).toFixed(1)} MB, which exceeds the 500 MB upload limit. Add large files/folders to .gitignore (biggest: ${tarball.topFiles.map((f) => f.path).join(", ")}) and try again.`,
242
+ reasonCode: "unknown",
243
+ }),
244
+ summary: `Repository too large (${(tarball.bytes / 1024 / 1024).toFixed(1)} MB exceeds 500 MB limit).`,
245
+ configPath: tomlPathFor(ctx.cwd),
246
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
85
247
  };
86
248
  }
87
249
  if (!input.force && (tarball.fileCount > SUSPICIOUS_FILES || tarball.bytes > SUSPICIOUS_BYTES)) {
@@ -103,27 +265,289 @@ async function publishInner(ctx, input) {
103
265
  status: "dry_run",
104
266
  fileCount: tarball.fileCount,
105
267
  bytes: tarball.bytes,
268
+ recovery: RECOVERY_NONE,
269
+ summary: `Dry run: ${tarball.fileCount} files, ${(tarball.bytes / 1024).toFixed(1)} KB — no deploy performed.`,
270
+ configPath: tomlPathFor(ctx.cwd),
271
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
106
272
  };
107
273
  }
108
274
  // ── 3. Auth + ensure app ───────────────────────────────────────────
109
275
  let app;
276
+ let firstDeploy = false;
110
277
  try {
111
- app = await ensureApp(ctx, config);
278
+ ({ app, firstDeploy } = await ensureApp(ctx, config));
112
279
  }
113
280
  catch (err) {
114
281
  if (err instanceof PercherApiError && err.status === 401) {
115
- return handleUnauthorized(ctx, config, tarball);
282
+ return handleUnauthorized(ctx, config, tarball, input);
116
283
  }
117
284
  throw err;
118
285
  }
286
+ // ── 3.5. Build-env scan ────────────────────────────────────────────
287
+ // Advisory-only: a transient error must never block a deploy.
288
+ let missingBuildEnvKeys = [];
289
+ try {
290
+ const existingEnvKeys = new Set(Object.keys(await ctx.client.env.list(app.id)));
291
+ missingBuildEnvKeys = scanForMissingBuildEnvRefs(ctx.cwd, existingEnvKeys);
292
+ }
293
+ catch {
294
+ // Degrade silently — scan failure is not a reason to abort publish.
295
+ }
296
+ if (missingBuildEnvKeys.length > 0 && !input.force) {
297
+ const keyList = missingBuildEnvKeys.map((k) => ` • ${k}`).join("\n");
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.)`);
299
+ }
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").
307
+ try {
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";
315
+ return {
316
+ status: "failed",
317
+ app,
318
+ fileCount: tarball.fileCount,
319
+ bytes: tarball.bytes,
320
+ error: {
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",
325
+ phase: "config",
326
+ },
327
+ recovery: {
328
+ retryable: true,
329
+ nextAction: "retry",
330
+ suggestedTool: "percher_publish",
331
+ args: {},
332
+ reasonCode: "infra_unavailable",
333
+ },
334
+ summary: `Preflight refused — ${reasonText}.`,
335
+ configPath: tomlPathFor(ctx.cwd),
336
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
337
+ };
338
+ }
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
+ }
119
348
  // ── 4. Upload ──────────────────────────────────────────────────────
120
349
  const uploadStart = Date.now();
121
- ctx.status("Uploading...");
122
- let deployment = await ctx.client.apps.deploy(app.id, {
123
- tarball: tarball.stream,
124
- type: input.preview ? "preview" : undefined,
125
- note: input.message,
126
- });
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,
410
+ }),
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;
549
+ }
550
+ }
127
551
  // Surface the auto-replace one-shot signal — only the initial POST
128
552
  // response carries it, so capture before the polling loop reassigns
129
553
  // `deployment` from getDeployment() (which never sees the flag).
@@ -132,53 +556,147 @@ async function publishInner(ctx, input) {
132
556
  ctx.status("Replaced previous preview...");
133
557
  }
134
558
  const uploadMs = Date.now() - uploadStart;
559
+ // FUTURE11 Phase 1 — async opt-in. Caller asked publish to return as
560
+ // soon as the deploy is queued so it can resume with
561
+ // percher_wait_for_deploy. The upload is finished and the server has
562
+ // the deploy row; the only thing left is the build, which is exactly
563
+ // the part agents shouldn't block on.
564
+ if (input.waitForLive === false) {
565
+ ctx.status(`Queued (${deployment.id}) — resume with percher_wait_for_deploy.`);
566
+ return {
567
+ status: "queued",
568
+ app,
569
+ deployment,
570
+ fileCount: tarball.fileCount,
571
+ bytes: tarball.bytes,
572
+ replacedPreview: replacedPreview || undefined,
573
+ firstDeploy: firstDeploy || undefined,
574
+ missingBuildEnvKeys: missingBuildEnvKeys.length > 0 ? missingBuildEnvKeys : undefined,
575
+ recovery: recoveryWait({
576
+ app: app.name,
577
+ deployId: deployment.id,
578
+ reasonCode: "deploy_queued",
579
+ }),
580
+ summary: `${app.name} deploy ${deployment.id} queued — resume with percher_wait_for_deploy.`,
581
+ configPath: tomlPathFor(ctx.cwd),
582
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
583
+ };
584
+ }
135
585
  // ── 5. Poll ────────────────────────────────────────────────────────
586
+ ctx.status("[4/4] Building & deploying...");
136
587
  const buildStart = Date.now();
137
- const timeoutMs = 5 * 60 * 1000;
588
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
138
589
  let lastStatus;
139
590
  let nextHeartbeatMs = 15_000;
140
- while (deployment.status !== "live" && deployment.status !== "failed") {
141
- const elapsedMs = Date.now() - buildStart;
142
- if (elapsedMs > timeoutMs) {
143
- return {
144
- status: "failed",
145
- app,
146
- deployment,
147
- fileCount: tarball.fileCount,
148
- bytes: tarball.bytes,
149
- error: {
150
- title: "Deploy timed out",
151
- explanation: "The deployment did not complete within 5 minutes.",
152
- suggestion: "Check the build logs with percher_logs. The build may need optimization or there may be an infrastructure issue.",
153
- },
154
- };
155
- }
156
- // Only emit when the status actually changes, or every ~15s as a
157
- // heartbeat with elapsed time. The previous loop printed
158
- // "building..." every 2s for 90s on a Next.js cold build, which is
159
- // 45 lines of zero signal. One change-event + occasional heartbeat
160
- // is far easier to read.
161
- if (deployment.status !== lastStatus) {
162
- ctx.status(`${deployment.status}...`);
163
- lastStatus = deployment.status;
164
- nextHeartbeatMs = elapsedMs + 15_000;
165
- }
166
- else if (elapsedMs >= nextHeartbeatMs) {
167
- ctx.status(`${deployment.status}... (${Math.round(elapsedMs / 1000)}s elapsed)`);
168
- nextHeartbeatMs = elapsedMs + 15_000;
169
- }
170
- await new Promise((r) => setTimeout(r, 2000));
171
- deployment = await ctx.client.apps.getDeployment(app.id, deployment.id);
591
+ // Heartbeat tracking lives in the caller because pollDeployment is a
592
+ // generic primitive. Only emit when the status actually changes, or
593
+ // every ~15s as a heartbeat with elapsed time. The previous loop
594
+ // printed "building..." every 2s for 90s on a Next.js cold build,
595
+ // which was 45 lines of zero signal.
596
+ let timedOut = null;
597
+ try {
598
+ deployment = await pollDeployment({
599
+ ctx,
600
+ appId: app.id,
601
+ initial: deployment,
602
+ intervalMs: 2000,
603
+ timeoutMs,
604
+ appName: app.name,
605
+ // FUTURE11 review P2: a concurrent newer deploy can mark this row
606
+ // `replaced` while we're still polling. The default terminal set
607
+ // (live/failed) wouldn't catch that, so we'd spin until the 5min
608
+ // timeout and surface a misleading "Deploy timed out". Treat
609
+ // replaced as terminal and branch on it after the poll returns.
610
+ terminal: ["live", "failed", "replaced"],
611
+ onTick: (d) => {
612
+ const elapsedMs = Date.now() - buildStart;
613
+ if (d.status !== lastStatus) {
614
+ ctx.status(`${d.status}...`);
615
+ lastStatus = d.status;
616
+ nextHeartbeatMs = elapsedMs + 15_000;
617
+ }
618
+ else if (elapsedMs >= nextHeartbeatMs) {
619
+ ctx.status(`${d.status}... (${Math.round(elapsedMs / 1000)}s elapsed)`);
620
+ nextHeartbeatMs = elapsedMs + 15_000;
621
+ }
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
+ },
633
+ // publish wants to return a structured PublishResult on timeout
634
+ // (not throw), so capture the in-flight deployment and break out
635
+ // via a sentinel object rather than letting the throw bubble.
636
+ onTimeout: (d) => {
637
+ timedOut = { deployment: d };
638
+ // biome-ignore lint/suspicious/noExplicitAny: sentinel re-thrown locally
639
+ throw new (class PollTimeoutSentinel extends Error {
640
+ })("__poll_timeout__");
641
+ },
642
+ });
643
+ }
644
+ catch (err) {
645
+ if (!timedOut)
646
+ throw err;
647
+ }
648
+ if (timedOut) {
649
+ const stuckDeployment = timedOut.deployment;
650
+ return {
651
+ status: "failed",
652
+ app,
653
+ deployment: stuckDeployment,
654
+ fileCount: tarball.fileCount,
655
+ bytes: tarball.bytes,
656
+ error: {
657
+ title: "Deploy timed out",
658
+ explanation: "The deployment did not complete within 5 minutes.",
659
+ suggestion: `Run \`percher doctor --app ${app.name}\` (CLI) or the percher_doctor tool (MCP) to diagnose the stall. Doctor will fetch the build log, classify the state, and return a concrete next step. The build may need optimization or there may be an infrastructure issue.`,
660
+ },
661
+ // FUTURE12 Phase 4: stalled deploy is ambiguous (still in
662
+ // flight, infra issue, slow build). Hand off to doctor with
663
+ // mode='deploy' so it can read deploy state + build log and
664
+ // decide between retry / inspect / ask_user — instead of
665
+ // pushing the agent straight at the raw build log.
666
+ recovery: recoveryDoctor({
667
+ app: app.name,
668
+ deployId: stuckDeployment.id,
669
+ mode: "deploy",
670
+ reasonCode: "deploy_stalled",
671
+ }),
672
+ summary: `Deploy timed out after 5 minutes (${stuckDeployment.id}).`,
673
+ configPath: tomlPathFor(ctx.cwd),
674
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
675
+ };
172
676
  }
173
677
  const buildMs = Date.now() - buildStart;
174
678
  // ── 6. Result ──────────────────────────────────────────────────────
679
+ if (deployment.status === "replaced") {
680
+ return await buildReplacedResult({
681
+ ctx,
682
+ app,
683
+ deployment,
684
+ tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
685
+ cwd: ctx.cwd,
686
+ });
687
+ }
175
688
  if (deployment.status === "failed") {
176
- return buildFailureResult({
689
+ const failResult = await buildFailureResult({
177
690
  ctx,
178
691
  app,
179
692
  deployment,
180
693
  tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
694
+ input,
181
695
  });
696
+ if (missingBuildEnvKeys.length > 0) {
697
+ failResult.missingBuildEnvKeys = missingBuildEnvKeys;
698
+ }
699
+ return failResult;
182
700
  }
183
701
  const totalSeconds = (Date.now() - t0) / 1000;
184
702
  const url = deployment.previewUrl ?? deployment.url ?? app.url;
@@ -194,6 +712,14 @@ async function publishInner(ctx, input) {
194
712
  /* non-critical */
195
713
  }
196
714
  }
715
+ // Read cache state directly off the polled deployment row. The row
716
+ // is the source of truth (deploy-pipeline writes deployments.cache_hit
717
+ // = true on the same code path that aliases the cached image). The
718
+ // earlier implementation fetched /events and inferred a hit from the
719
+ // subStage='cache_hit' event — that worked, but lost the signal on
720
+ // any /events fetch failure or post-login retry path. Codex P2
721
+ // follow-up on 9e5ceee.
722
+ const cacheHit = deployment.cacheHit;
197
723
  return {
198
724
  status: "live",
199
725
  url,
@@ -209,9 +735,36 @@ async function publishInner(ctx, input) {
209
735
  fileCount: tarball.fileCount,
210
736
  bytes: tarball.bytes,
211
737
  replacedPreview,
738
+ firstDeploy: firstDeploy || undefined,
739
+ cacheHit,
740
+ cacheReused: cacheReusedDeployment !== null || undefined,
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,
753
+ recovery: RECOVERY_NONE,
754
+ summary: buildLiveSummary({
755
+ appName: app.name,
756
+ url: url ?? app.url,
757
+ totalSeconds: Math.round(totalSeconds),
758
+ framework: config.app.framework,
759
+ preview: input.preview === true,
760
+ replacedPreview,
761
+ cacheHit: cacheHit === true,
762
+ }),
763
+ configPath: tomlPathFor(ctx.cwd),
764
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
212
765
  };
213
766
  }
214
- async function handleUnauthorized(ctx, config, tarball) {
767
+ async function handleUnauthorized(ctx, config, tarball, input) {
215
768
  // In MCP context we can't open a browser — return login instructions
216
769
  if (!ctx.interactiveLogin) {
217
770
  try {
@@ -226,6 +779,10 @@ async function handleUnauthorized(ctx, config, tarball) {
226
779
  loginCode: userCode,
227
780
  fileCount: tarball.fileCount,
228
781
  bytes: tarball.bytes,
782
+ recovery: RECOVERY_NEEDS_LOGIN,
783
+ summary: `Login required: open ${loginUrl} (code: ${userCode}).`,
784
+ configPath: tomlPathFor(ctx.cwd),
785
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
229
786
  };
230
787
  }
231
788
  catch {
@@ -238,6 +795,10 @@ async function handleUnauthorized(ctx, config, tarball) {
238
795
  explanation: "No valid Percher token found.",
239
796
  suggestion: "Run the percher_login tool first, or set PERCHER_TOKEN environment variable.",
240
797
  },
798
+ recovery: RECOVERY_NEEDS_LOGIN,
799
+ summary: "Login required — run percher_login or set PERCHER_TOKEN.",
800
+ configPath: tomlPathFor(ctx.cwd),
801
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
241
802
  };
242
803
  }
243
804
  }
@@ -250,14 +811,94 @@ async function handleUnauthorized(ctx, config, tarball) {
250
811
  });
251
812
  // Re-create tarball since stream is consumed
252
813
  const freshTarball = await createTarball({ cwd: ctx.cwd, config });
253
- const app = await ensureApp(ctx, config);
814
+ let firstDeployRetry = false;
815
+ let app;
816
+ ({ app, firstDeploy: firstDeployRetry } = await ensureApp(ctx, config));
254
817
  ctx.status("Uploading...");
255
- let deployment = await ctx.client.apps.deploy(app.id, {
256
- tarball: freshTarball.stream,
257
- });
258
- const timeoutMs = 5 * 60 * 1000;
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();
823
+ let deployment;
824
+ try {
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
+ },
844
+ });
845
+ // FUTURE12 Phase 6d — same already_in_progress short-circuit as
846
+ // the main publishInner path. Without this branch, a user who
847
+ // hits a 401 mid-publish and re-auths could land on the
848
+ // post-login deploy step and have it silently fall through into
849
+ // a 5-minute polling loop against the existing deploy's row.
850
+ if (isDeployAlreadyInProgress(deployResponse)) {
851
+ return buildAlreadyInProgressResult({
852
+ active: deployResponse,
853
+ app,
854
+ tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
855
+ cwd: ctx.cwd,
856
+ });
857
+ }
858
+ deployment = deployResponse;
859
+ }
860
+ catch (err) {
861
+ if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
862
+ return buildRetryLimitReachedResult({
863
+ err,
864
+ app,
865
+ tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
866
+ cwd: ctx.cwd,
867
+ });
868
+ }
869
+ throw err;
870
+ }
871
+ const replacedPreview = deployment.replacedPreview === true;
872
+ // FUTURE11 Phase 1 — same async opt-in as the main publishInner path.
873
+ // Without this, a caller that passed `waitForLive: false` and hit a
874
+ // 401 (interactive login flow) would silently fall through to the
875
+ // 5-minute polling loop after login, breaking the contract that
876
+ // waitForLive=false always returns as soon as the deploy is queued.
877
+ if (input.waitForLive === false) {
878
+ ctx.status(`Queued (${deployment.id}) — resume with percher_wait_for_deploy.`);
879
+ return {
880
+ status: "queued",
881
+ app,
882
+ deployment,
883
+ fileCount: freshTarball.fileCount,
884
+ bytes: freshTarball.bytes,
885
+ replacedPreview: replacedPreview || undefined,
886
+ firstDeploy: firstDeployRetry || undefined,
887
+ recovery: recoveryWait({
888
+ app: app.name,
889
+ deployId: deployment.id,
890
+ reasonCode: "deploy_queued",
891
+ }),
892
+ summary: `${app.name} deploy ${deployment.id} queued — resume with percher_wait_for_deploy.`,
893
+ configPath: tomlPathFor(ctx.cwd),
894
+ bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
895
+ };
896
+ }
897
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
259
898
  const start = Date.now();
260
- while (deployment.status !== "live" && deployment.status !== "failed") {
899
+ while (deployment.status !== "live" &&
900
+ deployment.status !== "failed" &&
901
+ deployment.status !== "replaced") {
261
902
  if (Date.now() - start > timeoutMs) {
262
903
  return {
263
904
  status: "failed",
@@ -268,20 +909,41 @@ async function handleUnauthorized(ctx, config, tarball) {
268
909
  error: {
269
910
  title: "Deploy timed out",
270
911
  explanation: "The deployment did not complete within 5 minutes.",
271
- suggestion: "Check the build logs with percher_logs.",
912
+ suggestion: `Run \`percher doctor --app ${app.name}\` (CLI) or the percher_doctor tool (MCP) to diagnose the stall. Doctor will read deploy state + build log and return a concrete next step.`,
272
913
  },
914
+ // FUTURE12 Phase 4: ambiguous stall — let doctor classify
915
+ // before the agent dives into the raw log.
916
+ recovery: recoveryDoctor({
917
+ app: app.name,
918
+ deployId: deployment.id,
919
+ mode: "deploy",
920
+ reasonCode: "deploy_stalled",
921
+ }),
922
+ summary: `Deploy timed out after 5 minutes (${deployment.id}).`,
923
+ configPath: tomlPathFor(ctx.cwd),
924
+ bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
273
925
  };
274
926
  }
275
927
  ctx.status(`${deployment.status}...`);
276
928
  await new Promise((r) => setTimeout(r, 2000));
277
929
  deployment = await ctx.client.apps.getDeployment(app.id, deployment.id);
278
930
  }
931
+ if (deployment.status === "replaced") {
932
+ return await buildReplacedResult({
933
+ ctx,
934
+ app,
935
+ deployment,
936
+ tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
937
+ cwd: ctx.cwd,
938
+ });
939
+ }
279
940
  if (deployment.status === "failed") {
280
941
  return buildFailureResult({
281
942
  ctx,
282
943
  app,
283
944
  deployment,
284
945
  tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
946
+ input,
285
947
  });
286
948
  }
287
949
  const totalSeconds = (Date.now() - start) / 1000;
@@ -300,6 +962,33 @@ async function handleUnauthorized(ctx, config, tarball) {
300
962
  },
301
963
  fileCount: freshTarball.fileCount,
302
964
  bytes: freshTarball.bytes,
965
+ replacedPreview,
966
+ firstDeploy: firstDeployRetry || undefined,
967
+ recovery: RECOVERY_NONE,
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,
976
+ summary: buildLiveSummary({
977
+ appName: app.name,
978
+ url: url ?? app.url,
979
+ totalSeconds: Math.round(totalSeconds),
980
+ framework: config.app.framework,
981
+ preview: input.preview === true,
982
+ replacedPreview,
983
+ // Read directly off the polled deployment row — same source of
984
+ // truth as the primary publish path. Codex P2 follow-up on
985
+ // 9e5ceee: the previous hardcoded `false` here meant the user
986
+ // who hit the post-login retry branch always saw "Build cache:
987
+ // miss" regardless of whether the build actually used the cache.
988
+ cacheHit: deployment.cacheHit ?? false,
989
+ }),
990
+ configPath: tomlPathFor(ctx.cwd),
991
+ bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
303
992
  };
304
993
  }
305
994
  // ── Helpers ────────────────────────────────────────────────────────────
@@ -313,54 +1002,21 @@ async function loadOrGenerate(ctx) {
313
1002
  }
314
1003
  async function ensureApp(ctx, config) {
315
1004
  try {
316
- return await ctx.client.apps.get(config.app.name);
1005
+ const app = await ctx.client.apps.get(config.app.name);
1006
+ return { app, firstDeploy: false };
317
1007
  }
318
1008
  catch (err) {
319
1009
  if (err.code === "APP_NOT_FOUND") {
320
- return ctx.client.apps.create({
1010
+ const app = await ctx.client.apps.create({
321
1011
  name: config.app.name,
322
1012
  runtime: config.app.runtime,
323
1013
  framework: config.app.framework,
324
1014
  });
1015
+ return { app, firstDeploy: true };
325
1016
  }
326
1017
  throw err;
327
1018
  }
328
1019
  }
329
- /**
330
- * Resolve which Node version will be used by the build, in priority order:
331
- * 1. user `.nvmrc` (literal contents trimmed)
332
- * 2. user `engines.node` from package.json
333
- * 3. server-side default ("Node 22 LTS" — kept in sync with
334
- * packages/api/src/nixpacks/build.ts:DEFAULT_NODE_VERSION)
335
- * Returns null when this isn't a Node project (no package.json + no .nvmrc).
336
- */
337
- function resolveNodeVersion(cwd) {
338
- const nvmrc = join(cwd, ".nvmrc");
339
- if (existsSync(nvmrc)) {
340
- try {
341
- const v = readFileSync(nvmrc, "utf8").trim();
342
- if (v)
343
- return { value: v, source: ".nvmrc" };
344
- }
345
- catch {
346
- /* fall through */
347
- }
348
- }
349
- const pkgPath = join(cwd, "package.json");
350
- if (!existsSync(pkgPath))
351
- return null;
352
- try {
353
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
354
- const engines = pkg?.engines?.node;
355
- if (typeof engines === "string" && engines.length > 0) {
356
- return { value: engines, source: "engines.node" };
357
- }
358
- }
359
- catch {
360
- /* fall through */
361
- }
362
- return { value: "22 LTS", source: "default" };
363
- }
364
1020
  function emitPreflight(ctx, config, tarball) {
365
1021
  const lines = [];
366
1022
  const fw = config.app.framework ?? (config.app.runtime === "docker" ? "Dockerfile" : config.app.runtime);
@@ -387,72 +1043,4 @@ function emitPreflight(ctx, config, tarball) {
387
1043
  ctx.status(line);
388
1044
  }
389
1045
  }
390
- async function buildFailureResult(opts) {
391
- const { ctx, app, deployment, tarball } = opts;
392
- // Try to fetch build log for diagnosis. Pre-build infra failures (worker
393
- // unreachable, nixpacks couldn't fetch from cache, tarball extraction
394
- // crashed) leave buildLog empty — we have to fall back to the deployment's
395
- // top-level errorMessage so the user isn't left staring at "see the log
396
- // below" with nothing below it.
397
- let buildLogTail;
398
- let fullLog = "";
399
- try {
400
- fullLog = await ctx.client.apps.getBuildLog(app.id, deployment.id);
401
- if (fullLog) {
402
- const lines = fullLog.split("\n");
403
- buildLogTail = lines.slice(-50).join("\n");
404
- }
405
- }
406
- catch {
407
- // Build log fetch can fail — that's fine, we still have a basic error
408
- }
409
- const errorMessage = deployment.errorMessage ?? "";
410
- // Classify against the build log first (post-build failures are richer),
411
- // then against errorMessage as a fallback for the pre-build / infra case.
412
- const classified = classifyError("Deployment failed", fullLog) ?? classifyError(errorMessage, "");
413
- // Always preserve the raw upstream string when we have one and there's
414
- // no build log to anchor to. The classifier replaces it with a friendly
415
- // template, but the actual error often carries the load-bearing detail
416
- // (port number, hostname, HTTP status) that support needs to debug.
417
- // Skip when it would just duplicate the explanation we're about to print.
418
- const rawErrorMessage = errorMessage && !buildLogTail && !classified?.explanation.includes(errorMessage)
419
- ? errorMessage
420
- : undefined;
421
- const error = classified
422
- ? {
423
- title: classified.title,
424
- explanation: classified.explanation,
425
- suggestion: classified.suggestion,
426
- buildLogTail,
427
- rawErrorMessage,
428
- errorClass: classified.errorClass,
429
- phase: classified.phase,
430
- cause: classified.cause,
431
- relevantFiles: classified.relevantFiles,
432
- missingEnvVars: classified.missingEnvVars,
433
- }
434
- : {
435
- title: "Deployment failed",
436
- explanation: errorMessage
437
- ? `The deployment failed: ${errorMessage}`
438
- : "The deployment failed. See the build log for details.",
439
- suggestion: buildLogTail
440
- ? "Check the build log below for errors. Run your build command locally to reproduce, fix, and publish again."
441
- : "No build log was produced — the failure happened before the build started. Run percher publish again; if it keeps failing with the same message, contact support.",
442
- buildLogTail,
443
- errorClass: "build_failed",
444
- phase: "build",
445
- cause: "unknown",
446
- relevantFiles: [],
447
- missingEnvVars: [],
448
- };
449
- return {
450
- status: "failed",
451
- app,
452
- deployment,
453
- fileCount: tarball.fileCount,
454
- bytes: tarball.bytes,
455
- error,
456
- };
457
- }
458
1046
  //# sourceMappingURL=publish.js.map