@percher/core 0.3.0 → 0.4.1

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 (270) hide show
  1. package/dist/commands/account.d.ts +24 -14
  2. package/dist/commands/account.d.ts.map +1 -1
  3. package/dist/commands/account.js +17 -4
  4. package/dist/commands/account.js.map +1 -1
  5. package/dist/commands/admin-reconcile-routes.d.ts +18 -0
  6. package/dist/commands/admin-reconcile-routes.d.ts.map +1 -0
  7. package/dist/commands/admin-reconcile-routes.js +22 -0
  8. package/dist/commands/admin-reconcile-routes.js.map +1 -0
  9. package/dist/commands/ai-files.d.ts +5 -17
  10. package/dist/commands/ai-files.d.ts.map +1 -1
  11. package/dist/commands/ai-files.js +3 -4
  12. package/dist/commands/ai-files.js.map +1 -1
  13. package/dist/commands/alerts.d.ts +69 -0
  14. package/dist/commands/alerts.d.ts.map +1 -0
  15. package/dist/commands/alerts.js +80 -0
  16. package/dist/commands/alerts.js.map +1 -0
  17. package/dist/commands/app-resources.d.ts +30 -0
  18. package/dist/commands/app-resources.d.ts.map +1 -0
  19. package/dist/commands/app-resources.js +34 -0
  20. package/dist/commands/app-resources.js.map +1 -0
  21. package/dist/commands/app-topology.d.ts +18 -0
  22. package/dist/commands/app-topology.d.ts.map +1 -0
  23. package/dist/commands/app-topology.js +25 -0
  24. package/dist/commands/app-topology.js.map +1 -0
  25. package/dist/commands/billing.d.ts +8 -8
  26. package/dist/commands/billing.d.ts.map +1 -1
  27. package/dist/commands/billing.js +1 -1
  28. package/dist/commands/billing.js.map +1 -1
  29. package/dist/commands/continue.d.ts +1 -1
  30. package/dist/commands/create.d.ts +2 -12
  31. package/dist/commands/create.d.ts.map +1 -1
  32. package/dist/commands/create.js +1 -1
  33. package/dist/commands/create.js.map +1 -1
  34. package/dist/commands/dashboard.d.ts +2 -8
  35. package/dist/commands/dashboard.d.ts.map +1 -1
  36. package/dist/commands/dashboard.js +1 -1
  37. package/dist/commands/dashboard.js.map +1 -1
  38. package/dist/commands/data-export.d.ts +2 -8
  39. package/dist/commands/data-export.d.ts.map +1 -1
  40. package/dist/commands/data-export.js +1 -1
  41. package/dist/commands/data-export.js.map +1 -1
  42. package/dist/commands/data.d.ts +2 -8
  43. package/dist/commands/data.d.ts.map +1 -1
  44. package/dist/commands/data.js +1 -1
  45. package/dist/commands/data.js.map +1 -1
  46. package/dist/commands/delete.d.ts +2 -8
  47. package/dist/commands/delete.d.ts.map +1 -1
  48. package/dist/commands/delete.js +1 -1
  49. package/dist/commands/delete.js.map +1 -1
  50. package/dist/commands/deploys.d.ts +4 -28
  51. package/dist/commands/deploys.d.ts.map +1 -1
  52. package/dist/commands/deploys.js +1 -1
  53. package/dist/commands/deploys.js.map +1 -1
  54. package/dist/commands/dev.d.ts +2 -6
  55. package/dist/commands/dev.d.ts.map +1 -1
  56. package/dist/commands/dev.js +3 -2
  57. package/dist/commands/dev.js.map +1 -1
  58. package/dist/commands/diagnose.d.ts +2 -22
  59. package/dist/commands/diagnose.d.ts.map +1 -1
  60. package/dist/commands/diagnose.js +1 -1
  61. package/dist/commands/diagnose.js.map +1 -1
  62. package/dist/commands/doctor.d.ts +20 -35
  63. package/dist/commands/doctor.d.ts.map +1 -1
  64. package/dist/commands/doctor.js +42 -4
  65. package/dist/commands/doctor.js.map +1 -1
  66. package/dist/commands/domains.d.ts +5 -27
  67. package/dist/commands/domains.d.ts.map +1 -1
  68. package/dist/commands/domains.js +1 -1
  69. package/dist/commands/domains.js.map +1 -1
  70. package/dist/commands/env-scan.js +1 -1
  71. package/dist/commands/env-scan.js.map +1 -1
  72. package/dist/commands/env.d.ts +4 -20
  73. package/dist/commands/env.d.ts.map +1 -1
  74. package/dist/commands/env.js +1 -1
  75. package/dist/commands/env.js.map +1 -1
  76. package/dist/commands/export.d.ts +1 -1
  77. package/dist/commands/forgejo.d.ts +45 -0
  78. package/dist/commands/forgejo.d.ts.map +1 -0
  79. package/dist/commands/forgejo.js +125 -0
  80. package/dist/commands/forgejo.js.map +1 -0
  81. package/dist/commands/generate.d.ts +2 -6
  82. package/dist/commands/generate.d.ts.map +1 -1
  83. package/dist/commands/generate.js +1 -1
  84. package/dist/commands/generate.js.map +1 -1
  85. package/dist/commands/github.d.ts +4 -15
  86. package/dist/commands/github.d.ts.map +1 -1
  87. package/dist/commands/github.js +17 -1
  88. package/dist/commands/github.js.map +1 -1
  89. package/dist/commands/import-project.d.ts +13 -9
  90. package/dist/commands/import-project.d.ts.map +1 -1
  91. package/dist/commands/import-project.js +73 -22
  92. package/dist/commands/import-project.js.map +1 -1
  93. package/dist/commands/init.d.ts +26 -11
  94. package/dist/commands/init.d.ts.map +1 -1
  95. package/dist/commands/init.js +103 -2
  96. package/dist/commands/init.js.map +1 -1
  97. package/dist/commands/insights.d.ts +2 -6
  98. package/dist/commands/insights.d.ts.map +1 -1
  99. package/dist/commands/insights.js +1 -1
  100. package/dist/commands/insights.js.map +1 -1
  101. package/dist/commands/login.d.ts +2 -8
  102. package/dist/commands/login.d.ts.map +1 -1
  103. package/dist/commands/login.js +22 -1
  104. package/dist/commands/login.js.map +1 -1
  105. package/dist/commands/logs.d.ts +25 -10
  106. package/dist/commands/logs.d.ts.map +1 -1
  107. package/dist/commands/logs.js +65 -5
  108. package/dist/commands/logs.js.map +1 -1
  109. package/dist/commands/mcp.d.ts +2 -2
  110. package/dist/commands/mcp.d.ts.map +1 -1
  111. package/dist/commands/mcp.js +1 -1
  112. package/dist/commands/mcp.js.map +1 -1
  113. package/dist/commands/migrate-supabase-map.d.ts +171 -0
  114. package/dist/commands/migrate-supabase-map.d.ts.map +1 -0
  115. package/dist/commands/migrate-supabase-map.js +452 -0
  116. package/dist/commands/migrate-supabase-map.js.map +1 -0
  117. package/dist/commands/migrate-supabase-schema.d.ts +67 -0
  118. package/dist/commands/migrate-supabase-schema.d.ts.map +1 -0
  119. package/dist/commands/migrate-supabase-schema.js +321 -0
  120. package/dist/commands/migrate-supabase-schema.js.map +1 -0
  121. package/dist/commands/migrate-supabase-scripts.d.ts +64 -0
  122. package/dist/commands/migrate-supabase-scripts.d.ts.map +1 -0
  123. package/dist/commands/migrate-supabase-scripts.js +564 -0
  124. package/dist/commands/migrate-supabase-scripts.js.map +1 -0
  125. package/dist/commands/migrate-supabase-sdk.d.ts +133 -0
  126. package/dist/commands/migrate-supabase-sdk.d.ts.map +1 -0
  127. package/dist/commands/migrate-supabase-sdk.js +1119 -0
  128. package/dist/commands/migrate-supabase-sdk.js.map +1 -0
  129. package/dist/commands/migrate-supabase-walker.d.ts +93 -0
  130. package/dist/commands/migrate-supabase-walker.d.ts.map +1 -0
  131. package/dist/commands/migrate-supabase-walker.js +413 -0
  132. package/dist/commands/migrate-supabase-walker.js.map +1 -0
  133. package/dist/commands/migrate-supabase.d.ts +81 -0
  134. package/dist/commands/migrate-supabase.d.ts.map +1 -0
  135. package/dist/commands/migrate-supabase.js +579 -0
  136. package/dist/commands/migrate-supabase.js.map +1 -0
  137. package/dist/commands/open.d.ts +2 -6
  138. package/dist/commands/open.d.ts.map +1 -1
  139. package/dist/commands/open.js +1 -1
  140. package/dist/commands/open.js.map +1 -1
  141. package/dist/commands/publish-api-error.d.ts +46 -0
  142. package/dist/commands/publish-api-error.d.ts.map +1 -0
  143. package/dist/commands/publish-api-error.js +307 -0
  144. package/dist/commands/publish-api-error.js.map +1 -0
  145. package/dist/commands/publish-failure.d.ts.map +1 -1
  146. package/dist/commands/publish-failure.js +11 -3
  147. package/dist/commands/publish-failure.js.map +1 -1
  148. package/dist/commands/publish-node.d.ts +5 -2
  149. package/dist/commands/publish-node.d.ts.map +1 -1
  150. package/dist/commands/publish-node.js +7 -3
  151. package/dist/commands/publish-node.js.map +1 -1
  152. package/dist/commands/publish.d.ts +58 -17
  153. package/dist/commands/publish.d.ts.map +1 -1
  154. package/dist/commands/publish.js +407 -145
  155. package/dist/commands/publish.js.map +1 -1
  156. package/dist/commands/push.d.ts +2 -12
  157. package/dist/commands/push.d.ts.map +1 -1
  158. package/dist/commands/push.js +25 -7
  159. package/dist/commands/push.js.map +1 -1
  160. package/dist/commands/redeploy.d.ts +2 -8
  161. package/dist/commands/redeploy.d.ts.map +1 -1
  162. package/dist/commands/redeploy.js +20 -16
  163. package/dist/commands/redeploy.js.map +1 -1
  164. package/dist/commands/rename.d.ts +2 -8
  165. package/dist/commands/rename.d.ts.map +1 -1
  166. package/dist/commands/rename.js +1 -1
  167. package/dist/commands/rename.js.map +1 -1
  168. package/dist/commands/reproduce.d.ts +2 -8
  169. package/dist/commands/reproduce.d.ts.map +1 -1
  170. package/dist/commands/reproduce.js +1 -1
  171. package/dist/commands/reproduce.js.map +1 -1
  172. package/dist/commands/reset-superuser.d.ts +2 -16
  173. package/dist/commands/reset-superuser.d.ts.map +1 -1
  174. package/dist/commands/reset-superuser.js +1 -1
  175. package/dist/commands/reset-superuser.js.map +1 -1
  176. package/dist/commands/restore.d.ts +7 -22
  177. package/dist/commands/restore.d.ts.map +1 -1
  178. package/dist/commands/restore.js +1 -1
  179. package/dist/commands/restore.js.map +1 -1
  180. package/dist/commands/resume.d.ts +2 -6
  181. package/dist/commands/resume.d.ts.map +1 -1
  182. package/dist/commands/resume.js +1 -1
  183. package/dist/commands/resume.js.map +1 -1
  184. package/dist/commands/rollback.d.ts +4 -9
  185. package/dist/commands/rollback.d.ts.map +1 -1
  186. package/dist/commands/rollback.js +3 -2
  187. package/dist/commands/rollback.js.map +1 -1
  188. package/dist/commands/sharing.d.ts +48 -0
  189. package/dist/commands/sharing.d.ts.map +1 -0
  190. package/dist/commands/sharing.js +85 -0
  191. package/dist/commands/sharing.js.map +1 -0
  192. package/dist/commands/status.d.ts +29 -0
  193. package/dist/commands/status.d.ts.map +1 -0
  194. package/dist/commands/status.js +48 -0
  195. package/dist/commands/status.js.map +1 -0
  196. package/dist/commands/transfers.d.ts +34 -0
  197. package/dist/commands/transfers.d.ts.map +1 -0
  198. package/dist/commands/transfers.js +62 -0
  199. package/dist/commands/transfers.js.map +1 -0
  200. package/dist/commands/unsuspend.d.ts +2 -6
  201. package/dist/commands/unsuspend.d.ts.map +1 -1
  202. package/dist/commands/unsuspend.js +1 -1
  203. package/dist/commands/unsuspend.js.map +1 -1
  204. package/dist/commands/versions.d.ts +2 -6
  205. package/dist/commands/versions.d.ts.map +1 -1
  206. package/dist/commands/versions.js +1 -1
  207. package/dist/commands/versions.js.map +1 -1
  208. package/dist/commands/wait-deploy.d.ts +2 -12
  209. package/dist/commands/wait-deploy.d.ts.map +1 -1
  210. package/dist/commands/wait-deploy.js +5 -4
  211. package/dist/commands/wait-deploy.js.map +1 -1
  212. package/dist/context.d.ts +15 -0
  213. package/dist/context.d.ts.map +1 -1
  214. package/dist/detect.d.ts +11 -0
  215. package/dist/detect.d.ts.map +1 -1
  216. package/dist/detect.js +31 -8
  217. package/dist/detect.js.map +1 -1
  218. package/dist/env-scan-source.js +1 -1
  219. package/dist/env-scan-source.js.map +1 -1
  220. package/dist/error-classifier.d.ts +17 -0
  221. package/dist/error-classifier.d.ts.map +1 -1
  222. package/dist/error-classifier.js +95 -9
  223. package/dist/error-classifier.js.map +1 -1
  224. package/dist/errors.d.ts +1 -1
  225. package/dist/errors.d.ts.map +1 -1
  226. package/dist/errors.js.map +1 -1
  227. package/dist/event-renderer.d.ts +17 -0
  228. package/dist/event-renderer.d.ts.map +1 -0
  229. package/dist/event-renderer.js +130 -0
  230. package/dist/event-renderer.js.map +1 -0
  231. package/dist/index.d.ts +63 -47
  232. package/dist/index.d.ts.map +1 -1
  233. package/dist/index.js +56 -40
  234. package/dist/index.js.map +1 -1
  235. package/dist/plans.d.ts +70 -5
  236. package/dist/plans.d.ts.map +1 -1
  237. package/dist/plans.js +83 -18
  238. package/dist/plans.js.map +1 -1
  239. package/dist/poll-deployment.d.ts +13 -1
  240. package/dist/poll-deployment.d.ts.map +1 -1
  241. package/dist/poll-deployment.js +37 -1
  242. package/dist/poll-deployment.js.map +1 -1
  243. package/dist/publish-retry.d.ts +29 -0
  244. package/dist/publish-retry.d.ts.map +1 -0
  245. package/dist/publish-retry.js +224 -0
  246. package/dist/publish-retry.js.map +1 -0
  247. package/dist/recovery.d.ts +60 -3
  248. package/dist/recovery.d.ts.map +1 -1
  249. package/dist/recovery.js +24 -1
  250. package/dist/recovery.js.map +1 -1
  251. package/dist/static-docker.d.ts +77 -0
  252. package/dist/static-docker.d.ts.map +1 -0
  253. package/dist/static-docker.js +105 -0
  254. package/dist/static-docker.js.map +1 -0
  255. package/dist/structured-error-codes.d.ts +30 -0
  256. package/dist/structured-error-codes.d.ts.map +1 -0
  257. package/dist/structured-error-codes.js +86 -0
  258. package/dist/structured-error-codes.js.map +1 -0
  259. package/dist/tarball.d.ts +11 -0
  260. package/dist/tarball.d.ts.map +1 -1
  261. package/dist/tarball.js +31 -10
  262. package/dist/tarball.js.map +1 -1
  263. package/dist/templates/ai-files/cursor-percher-mdc.d.ts.map +1 -1
  264. package/dist/templates/ai-files/cursor-percher-mdc.js +12 -9
  265. package/dist/templates/ai-files/cursor-percher-mdc.js.map +1 -1
  266. package/dist/templates.js +11 -11
  267. package/dist/templates.js.map +1 -1
  268. package/dist/watcher.js +1 -1
  269. package/dist/watcher.js.map +1 -1
  270. package/package.json +7 -2
@@ -1,15 +1,18 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { PercherApiError, PercherClient, isDeployAlreadyInProgress, } from "@percher/client";
4
- import { parseFile } from "@percher/toml";
5
- import { z } from "zod/v3";
6
- import { classifyError } from "../error-classifier";
3
+ import { isDeployAlreadyInProgress, PercherApiError, PercherClient, } from "@percher/client";
4
+ import { TIMEOUTS } from "@percher/shared/timeouts";
5
+ import { PercherTomlError, parseFile } from "@percher/toml";
6
+ import { z } from "zod";
7
+ import { renderDeployEvent } from "../event-renderer";
7
8
  import { pollDeployment } from "../poll-deployment";
8
- import { RECOVERY_NEEDS_LOGIN, RECOVERY_NONE, recoveryAsk, recoveryDoctor, recoveryEnv, recoveryFixConfig, recoveryFromErrorClass, recoveryWait, } from "../recovery";
9
+ import { runDeployWithRetry } from "../publish-retry";
10
+ import { RECOVERY_NEEDS_LOGIN, RECOVERY_NONE, recoveryAsk, recoveryDoctor, recoveryEnv, recoveryFixConfig, recoveryWait, } from "../recovery";
9
11
  import { createTarball } from "../tarball";
10
12
  import { scanForMissingBuildEnvRefs } from "./env-scan";
11
13
  import { init } from "./init";
12
14
  import { login } from "./login";
15
+ import { classifyPublishApiError } from "./publish-api-error";
13
16
  import { buildFailureResult } from "./publish-failure";
14
17
  import { resolveNodeVersion } from "./publish-node";
15
18
  import { resolveReplaced } from "./wait-deploy";
@@ -159,6 +162,20 @@ export async function publish(ctx, input = {}) {
159
162
  return await publishInner(ctx, input);
160
163
  }
161
164
  catch (err) {
165
+ // Dogfood 2026-05-13 Fynd #2 — business-rule 403s (plan limits,
166
+ // rate limits, account suspension) used to land in the
167
+ // "Unexpected error / network issue / Percher bug" branch below,
168
+ // sending users on a wild goose chase when the real fix was
169
+ // "upgrade or delete an app". The classifier matches the known
170
+ // PercherApiError codes and returns a structured failure with the
171
+ // right title + actionable suggestion. Anything not matched falls
172
+ // through to the generic catch-all.
173
+ const classified = classifyPublishApiError(err, {
174
+ configPath: tomlPathFor(ctx.cwd),
175
+ bundle: { fileCount: 0, bytes: 0 },
176
+ });
177
+ if (classified)
178
+ return classified;
162
179
  // Catch-all: any uncaught exception becomes a structured failure result.
163
180
  // This guarantees agents always get JSON, never a raw stack trace.
164
181
  const message = err instanceof Error ? err.message : String(err);
@@ -187,20 +204,108 @@ export async function publish(ctx, input = {}) {
187
204
  }
188
205
  async function publishInner(ctx, input) {
189
206
  const t0 = Date.now();
207
+ // ── 0. Identity banner ─────────────────────────────────────────────
208
+ //
209
+ // Fas 2 F2-#7 (dogfood 2026-05-13). The CLI silently falls back to
210
+ // `~/.percher/config.json` when `PERCHER_TOKEN` env var is unset,
211
+ // which is convenient — except when env-var management is flaky
212
+ // (e.g. a bash session that loses the export between sub-commands)
213
+ // and the fallback ends up being a different user's token. The
214
+ // user thinks they're publishing as smoke-test@; the deploy lands
215
+ // on the admin account. Verified live: a Fas-2 retest cycle put
216
+ // two apps on the wrong account before the cause was traced.
217
+ //
218
+ // Surfacing the resolved identity as the FIRST output of `publish`
219
+ // makes the mismatch visible immediately. Best-effort: a network
220
+ // glitch or a 401 from whoami shouldn't abort publish — the
221
+ // downstream auth checks already handle real auth failure with
222
+ // proper recovery (`needs_login`). Here we only render when we
223
+ // can; otherwise stay silent and let the later checks speak.
224
+ //
225
+ // Skipped on dry-run: the contract there is "no API calls at all"
226
+ // (asserted by commands.publish.test.ts), and dry-run users
227
+ // typically know which account they're targeting because they
228
+ // just ran it locally to inspect bundle size.
229
+ if (!input.dryRun) {
230
+ try {
231
+ const me = await ctx.client.auth.whoami();
232
+ ctx.status(`Signed in as ${me.email}`);
233
+ }
234
+ catch {
235
+ // Swallow: whoami failure isn't a publish blocker — the actual
236
+ // publish steps below run their own auth and surface the right
237
+ // recovery shape if the token is bad.
238
+ }
239
+ }
190
240
  // ── 1. Config ──────────────────────────────────────────────────────
191
241
  ctx.status("[1/4] Reading config...");
192
242
  let config;
193
243
  try {
194
244
  config = await loadOrGenerate(ctx);
195
245
  }
196
- catch {
246
+ catch (err) {
247
+ // FUTURE11 Fas 2 — differentiate by the toml package's typed error.
248
+ // The old catch swallowed every cause and reported the same
249
+ // generic "Could not generate config" for three different failure
250
+ // modes (parse error, schema validation, auto-detection miss).
251
+ // Now we surface the real reason so the user/agent gets actionable
252
+ // guidance (line number on parse, schema bullet list on
253
+ // validation, "no framework detected" only on the actual init
254
+ // miss).
255
+ if (err instanceof PercherTomlError) {
256
+ if (err.code === "PARSE_ERROR") {
257
+ return {
258
+ status: "failed",
259
+ fileCount: 0,
260
+ bytes: 0,
261
+ error: {
262
+ title: "percher.toml has invalid TOML syntax",
263
+ explanation: err.message,
264
+ suggestion: "Fix the syntax error and retry publish.",
265
+ },
266
+ recovery: recoveryFixConfig({
267
+ problems: [{ file: "percher.toml", message: err.message }],
268
+ reasonCode: "config_invalid",
269
+ }),
270
+ summary: "percher.toml has invalid TOML syntax — fix and retry.",
271
+ configPath: tomlPathFor(ctx.cwd),
272
+ bundle: { fileCount: 0, bytes: 0 },
273
+ };
274
+ }
275
+ if (err.code === "VALIDATION_ERROR") {
276
+ const problems = (err.issues ?? []).map((i) => ({
277
+ file: "percher.toml",
278
+ message: `${i.path.join(".") || "(root)"}: ${i.message}`,
279
+ }));
280
+ const bullets = problems.map((p) => ` - ${p.message}`).join("\n");
281
+ const count = problems.length;
282
+ return {
283
+ status: "failed",
284
+ fileCount: 0,
285
+ bytes: 0,
286
+ error: {
287
+ title: "percher.toml failed schema validation",
288
+ explanation: `${count} schema issue${count === 1 ? "" : "s"}:\n${bullets}`,
289
+ suggestion: "Fix the schema issues above (check the percher.toml spec for valid sections/fields) and retry.",
290
+ },
291
+ recovery: recoveryFixConfig({ problems, reasonCode: "config_invalid" }),
292
+ summary: `percher.toml failed schema validation — ${count} issue${count === 1 ? "" : "s"}.`,
293
+ configPath: tomlPathFor(ctx.cwd),
294
+ bundle: { fileCount: 0, bytes: 0 },
295
+ };
296
+ }
297
+ // FILE_NOT_FOUND falls through to the init-failure branch below
298
+ // — loadOrGenerate should normally run init on FILE_NOT_FOUND,
299
+ // so reaching this point means init itself failed to fill the
300
+ // gap.
301
+ }
197
302
  return {
198
303
  status: "failed",
199
304
  fileCount: 0,
200
305
  bytes: 0,
201
306
  error: {
202
- title: "Could not generate config",
203
- explanation: "percher.toml could not be auto-generated for this project.",
307
+ title: "Couldn't auto-generate percher.toml",
308
+ explanation: "No percher.toml found and auto-detection couldn't determine your framework.",
204
309
  suggestion: "Create a percher.toml manually with at least [app] name and runtime, then try again.",
205
310
  },
206
311
  recovery: recoveryFixConfig({
@@ -294,148 +399,256 @@ async function publishInner(ctx, input) {
294
399
  const keyList = missingBuildEnvKeys.map((k) => ` • ${k}`).join("\n");
295
400
  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
401
  }
297
- // ── 4. Upload ──────────────────────────────────────────────────────
298
- const uploadStart = Date.now();
299
- ctx.status("[3/4] Uploading...");
300
- let deployment;
402
+ // ── 3.7. Preflight ─────────────────────────────────────────────────
403
+ // Phase 7.3 — cheap (~100 ms) probe of platform readiness so we don't
404
+ // burn the user's bandwidth uploading a tarball that's bound for a
405
+ // queue we've already lost contact with. On `accept: false` we surface
406
+ // a `retry` recovery and bail before reading the tarball stream.
407
+ // On a slow queue we emit a heads-up so the agent can manage user
408
+ // expectations ("queued behind 2 others, ~3min wait").
301
409
  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";
410
+ const preflight = await ctx.client.platform.preflight();
411
+ if (!preflight.accept) {
412
+ const reasonText = preflight.reason === "worker_circuit_breaker_open"
413
+ ? "platform circuit breaker is open — workers are restarting"
414
+ : preflight.reason === "worker_unhealthy"
415
+ ? "build worker is currently unreachable"
416
+ : "platform is currently unavailable";
357
417
  return {
358
418
  status: "failed",
359
419
  app,
360
420
  fileCount: tarball.fileCount,
361
421
  bytes: tarball.bytes,
362
422
  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",
423
+ title: "Platform unavailable",
424
+ explanation: `Preflight refused the upload: ${reasonText}.`,
425
+ suggestion: "Wait ~30s and retry the platform self-heals automatically and the auto-retry loop will pick it up on the next attempt.",
426
+ errorClass: "infra_unavailable",
367
427
  phase: "config",
368
- missingEnvVars: keys,
369
428
  },
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"],
429
+ recovery: {
430
+ retryable: true,
431
+ nextAction: "retry",
432
+ suggestedTool: "percher_publish",
433
+ args: {},
434
+ reasonCode: "infra_unavailable",
401
435
  },
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(", ")}.`,
436
+ summary: `Preflight refused — ${reasonText}.`,
407
437
  configPath: tomlPathFor(ctx.cwd),
408
438
  bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
409
439
  };
410
440
  }
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",
441
+ if (preflight.queueWaitEstimateSec > 60) {
442
+ ctx.status(`Build queue wait: ~${preflight.queueWaitEstimateSec}s your build is queued behind ${preflight.queueDepth} other(s).`);
443
+ }
444
+ }
445
+ catch {
446
+ // Preflight is advisory a network error here just means the CLI
447
+ // proceeds and lets the retry loop (Phase 7.2) handle whatever
448
+ // surfaces during the actual upload.
449
+ }
450
+ // ── 4. Upload ──────────────────────────────────────────────────────
451
+ const uploadStart = Date.now();
452
+ ctx.status("[3/4] Uploading...");
453
+ // Buffer the tarball so the retry loop (Phase 7.2) can resend the
454
+ // same bytes after a transient failure. createTarball returns a
455
+ // streaming source that's consumed-once; collecting it eagerly
456
+ // lets every attempt share the same payload (and the same
457
+ // Idempotency-Key) without re-walking the filesystem.
458
+ const tarballBytes = new Uint8Array(await new Response(tarball.stream).arrayBuffer());
459
+ // Phase 7.8 content-hash the bundle and probe whether we've
460
+ // already deployed identical bytes for this app. On hit we skip the
461
+ // upload entirely and call /rerun on the previous deploy — the
462
+ // existing image_cache short-circuits the build, so the new deploy
463
+ // is live in seconds instead of a 90s build cycle. `--no-cache`
464
+ // bypasses the probe so users with a cache-correctness suspicion
465
+ // can force a fresh upload + build.
466
+ let cacheReusedDeployment = null;
467
+ // Phase 7.8 follow-up — content-hash from createTarball() (sorted
468
+ // entries, deterministic). Hashing the gzipped bytes would not
469
+ // work: tar/gzip headers carry mtime/OS bytes that vary per run,
470
+ // so the same source produced a different hash on every publish.
471
+ // Codex P2, 2026-05-08. Sent as `X-Tarball-Hash` on the upload so
472
+ // the API stores it on `deployments.tarball_hash` for the probe.
473
+ const contentHash = tarball.contentHash;
474
+ if (!input.noCache) {
475
+ try {
476
+ const probe = await ctx.client.platform.cacheProbe({
477
+ appId: app.id,
478
+ tarballHash: contentHash,
479
+ });
480
+ if (probe.hit) {
481
+ ctx.status(`Build cache: identical bundle deployed previously (${probe.lastDeployId}) — redeploying without re-upload.`);
482
+ cacheReusedDeployment = await ctx.client.apps.rerunDeploy(app.id, probe.lastDeployId);
483
+ ctx.status("[4/4] Building & deploying (cache reuse)...");
484
+ }
485
+ }
486
+ catch {
487
+ // Cache-probe is advisory — a network error or 404 here just
488
+ // means the CLI proceeds with a normal upload.
489
+ }
490
+ }
491
+ const idempotencyKey = crypto.randomUUID();
492
+ let deployment;
493
+ if (cacheReusedDeployment) {
494
+ deployment = cacheReusedDeployment;
495
+ }
496
+ else {
497
+ try {
498
+ const { result: deployResponse, attempts: deployAttempts } = await runDeployWithRetry({
499
+ call: (attempt) => ctx.client.apps.deploy(app.id, {
500
+ tarball: tarballBytes,
501
+ type: input.preview ? "preview" : undefined,
502
+ note: input.message,
503
+ noCache: input.noCache,
504
+ idempotencyKey,
505
+ tarballHash: contentHash,
506
+ // Phase 7.5 — claim the count of CLI-side retries that
507
+ // preceded this attempt so the API persists it on the row;
508
+ // swap-stage promotes the live transition to
509
+ // `retry_recovered` when > 0. `attempt` is 1-indexed; first
510
+ // try has 0 retries and the client omits the header.
511
+ retriedAttempts: attempt - 1,
432
512
  }),
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
- };
513
+ onRetry: ({ attempt, decision }) => {
514
+ ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
515
+ },
516
+ });
517
+ if (deployAttempts > 1) {
518
+ ctx.status(`Recovered after ${deployAttempts - 1} retry${deployAttempts > 2 ? "ies" : ""}.`);
519
+ }
520
+ // FUTURE12 Phase 6d — server-side already_in_progress short-
521
+ // circuit. The API returned no new deploy row because there's
522
+ // already one in flight for this app; emit a wait_deploy
523
+ // recovery pointing at the active deployId so the agent calls
524
+ // percher_wait_for_deploy instead of queueing a duplicate.
525
+ // Status code: 200 (informational), not an error.
526
+ if (isDeployAlreadyInProgress(deployResponse)) {
527
+ return buildAlreadyInProgressResult({
528
+ active: deployResponse,
529
+ app,
530
+ tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
531
+ cwd: ctx.cwd,
532
+ });
533
+ }
534
+ deployment = deployResponse;
535
+ }
536
+ catch (err) {
537
+ // FUTURE12 Phase 6d — retry-state guardrail. The user already
538
+ // burned their one auto-retry inside the 10-minute window after
539
+ // an `infra_unavailable` failure. Recovery is `ask_user` with
540
+ // retryable=false so an agent doesn't loop indefinitely against
541
+ // a non-transient outage.
542
+ if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
543
+ return buildRetryLimitReachedResult({
544
+ err,
545
+ app,
546
+ tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
547
+ cwd: ctx.cwd,
548
+ });
549
+ }
550
+ // Daily-quota gate (Fas 4) returns 429 DAILY_QUOTA_EXCEEDED with
551
+ // structured `extra` fields (kind/used/limit/resetAt). Surface
552
+ // those to the agent as ask_user — retry only makes sense after
553
+ // resetAt, which the message includes verbatim.
554
+ // FUTURE12 Phase 6c — pre-queue env gate. Two codes, both 422:
555
+ // REQUIRED_ENV_MISSING → recoveryEnv (set the keys, re-publish)
556
+ // ENV_KEY_UNDECLARED → recoveryFixConfig (declare in [env])
557
+ // Distinct from build-failure missing_env: the gate fires BEFORE
558
+ // the deploy queues, so there's no deployId/buildLog to point at —
559
+ // the only artifact is the structured `extra` payload.
560
+ if (err instanceof PercherApiError && err.code === "REQUIRED_ENV_MISSING") {
561
+ const extra = err.extra ?? {};
562
+ const keys = extra.keys ?? [];
563
+ const source = extra.source ?? "contract";
564
+ const sourceText = source === "contract"
565
+ ? "declared in your [env].required"
566
+ : source === "discovered"
567
+ ? "learned from a previous build failure"
568
+ : "both declared and learned from a previous build failure";
569
+ return {
570
+ status: "failed",
571
+ app,
572
+ fileCount: tarball.fileCount,
573
+ bytes: tarball.bytes,
574
+ error: {
575
+ title: "Required env keys not set",
576
+ explanation: `${keys.length} env ${keys.length === 1 ? "key is" : "keys are"} ${sourceText} but missing from the env store: ${keys.join(", ")}.`,
577
+ suggestion: `Run \`bunx percher env set ${keys[0] ?? "KEY"}=value\` (and similarly for the rest) and re-publish.`,
578
+ errorClass: "missing_env",
579
+ phase: "config",
580
+ missingEnvVars: keys,
581
+ },
582
+ recovery: recoveryEnv({
583
+ app: app.name,
584
+ keys,
585
+ reasonCode: "missing_env",
586
+ }),
587
+ summary: `Missing required env ${keys.length === 1 ? "key" : "keys"}: ${keys.join(", ")}.`,
588
+ configPath: tomlPathFor(ctx.cwd),
589
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
590
+ };
591
+ }
592
+ if (err instanceof PercherApiError && err.code === "ENV_KEY_UNDECLARED") {
593
+ const extra = err.extra ?? {};
594
+ const apiProblems = extra.problems ?? [];
595
+ const problems = apiProblems.map((p) => ({
596
+ file: p.file,
597
+ line: p.line,
598
+ 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)"}`,
599
+ }));
600
+ const uniqueKeys = [...new Set(apiProblems.map((p) => p.key))];
601
+ return {
602
+ status: "failed",
603
+ app,
604
+ fileCount: tarball.fileCount,
605
+ bytes: tarball.bytes,
606
+ error: {
607
+ title: "Undeclared env key",
608
+ explanation: `Source references ${uniqueKeys.length} env ${uniqueKeys.length === 1 ? "key" : "keys"} that aren't classified in percher.toml's [env] table: ${uniqueKeys.join(", ")}.`,
609
+ 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.",
610
+ errorClass: "config_invalid",
611
+ phase: "config",
612
+ relevantFiles: ["percher.toml"],
613
+ },
614
+ recovery: recoveryFixConfig({
615
+ problems,
616
+ reasonCode: "env_key_undeclared",
617
+ }),
618
+ summary: `${uniqueKeys.length} undeclared env ${uniqueKeys.length === 1 ? "key" : "keys"} in source: ${uniqueKeys.join(", ")}.`,
619
+ configPath: tomlPathFor(ctx.cwd),
620
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
621
+ };
622
+ }
623
+ if (err instanceof PercherApiError && err.code === "DAILY_QUOTA_EXCEEDED") {
624
+ const extra = err.extra ?? {};
625
+ const kind = extra.kind ?? "live";
626
+ const used = extra.used ?? 0;
627
+ const limit = extra.limit ?? 0;
628
+ const resetAt = extra.resetAt ?? "";
629
+ const otherKind = kind === "live" ? "preview" : "live";
630
+ return {
631
+ status: "failed",
632
+ app,
633
+ fileCount: tarball.fileCount,
634
+ bytes: tarball.bytes,
635
+ error: {
636
+ title: "Daily deploy quota reached",
637
+ explanation: `You've used ${used} of ${limit} ${kind} deploys today on this account. Counter resets at ${resetAt}.`,
638
+ suggestion: `Wait until the counter resets, deploy a ${otherKind} instead, or upgrade your plan at https://percher.app/settings to raise this cap.`,
639
+ },
640
+ recovery: recoveryAsk({
641
+ 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.`,
642
+ options: ["wait", `deploy ${otherKind}`, "upgrade"],
643
+ reasonCode: "quota_exceeded",
644
+ }),
645
+ summary: `Daily ${kind}-deploy quota reached (${used}/${limit}). Resets ${resetAt}.`,
646
+ configPath: tomlPathFor(ctx.cwd),
647
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
648
+ };
649
+ }
650
+ throw err;
437
651
  }
438
- throw err;
439
652
  }
440
653
  // Surface the auto-replace one-shot signal — only the initial POST
441
654
  // response carries it, so capture before the polling loop reassigns
@@ -474,7 +687,7 @@ async function publishInner(ctx, input) {
474
687
  // ── 5. Poll ────────────────────────────────────────────────────────
475
688
  ctx.status("[4/4] Building & deploying...");
476
689
  const buildStart = Date.now();
477
- const timeoutMs = 5 * 60 * 1000;
690
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
478
691
  let lastStatus;
479
692
  let nextHeartbeatMs = 15_000;
480
693
  // Heartbeat tracking lives in the caller because pollDeployment is a
@@ -509,6 +722,16 @@ async function publishInner(ctx, input) {
509
722
  nextHeartbeatMs = elapsedMs + 15_000;
510
723
  }
511
724
  },
725
+ // Phase 6.1 — surface stage/sub-stage transitions as they're
726
+ // recorded so the user sees real progress instead of a generic
727
+ // "building..." spinner. renderDeployEvent returns null for
728
+ // hidden rows (e.g. `done/ok`, where the caller prints the
729
+ // final live URL line).
730
+ onEvent: (event) => {
731
+ const line = renderDeployEvent(event);
732
+ if (line)
733
+ ctx.status(line);
734
+ },
512
735
  // publish wants to return a structured PublishResult on timeout
513
736
  // (not throw), so capture the in-flight deployment and break out
514
737
  // via a sentinel object rather than letting the throw bubble.
@@ -599,6 +822,7 @@ async function publishInner(ctx, input) {
599
822
  // any /events fetch failure or post-login retry path. Codex P2
600
823
  // follow-up on 9e5ceee.
601
824
  const cacheHit = deployment.cacheHit;
825
+ const cacheStatus = deployment.cacheStatus;
602
826
  return {
603
827
  status: "live",
604
828
  url,
@@ -616,7 +840,20 @@ async function publishInner(ctx, input) {
616
840
  replacedPreview,
617
841
  firstDeploy: firstDeploy || undefined,
618
842
  cacheHit,
843
+ cacheStatus,
844
+ cacheReused: cacheReusedDeployment !== null || undefined,
619
845
  missingBuildEnvKeys: missingBuildEnvKeys.length > 0 ? missingBuildEnvKeys : undefined,
846
+ // Phase 7.9 — operator-facing trace key. Codex P2 follow-up on
847
+ // b8f469a: previously a CLI-generated UUID, but the API/worker
848
+ // path used `deployId` as the wire trace key (build-fetch sets
849
+ // X-Trace-Id: <deployId>, recent-calls indexes on it, worker
850
+ // log echo prefixes lines with it). Aliasing the user-facing
851
+ // value to deployId means `Trace: dep_xxx` in the CLI matches
852
+ // /internal/recent-calls?traceId=dep_xxx and a worker
853
+ // journalctl grep — one key end-to-end. Cache-reuse paths
854
+ // already get a real deployId via /rerun, so this works there
855
+ // too without special-casing.
856
+ traceId: deployment.id,
620
857
  recovery: RECOVERY_NONE,
621
858
  summary: buildLiveSummary({
622
859
  appName: app.name,
@@ -637,6 +874,8 @@ async function handleUnauthorized(ctx, config, tarball, input) {
637
874
  try {
638
875
  const deviceClient = new PercherClient({
639
876
  apiUrl: ctx.client.apiUrl,
877
+ // FUTURE6 — preserve MCP session id across the device-code mint.
878
+ sessionId: ctx.client.sessionId,
640
879
  });
641
880
  const { verificationUri, userCode } = await deviceClient.auth.requestDeviceCode();
642
881
  const loginUrl = `${verificationUri}?code=${userCode}`;
@@ -675,6 +914,7 @@ async function handleUnauthorized(ctx, config, tarball, input) {
675
914
  ctx.client = new PercherClient({
676
915
  apiUrl: ctx.client.apiUrl,
677
916
  token: newToken,
917
+ sessionId: ctx.client.sessionId,
678
918
  });
679
919
  // Re-create tarball since stream is consumed
680
920
  const freshTarball = await createTarball({ cwd: ctx.cwd, config });
@@ -682,17 +922,32 @@ async function handleUnauthorized(ctx, config, tarball, input) {
682
922
  let app;
683
923
  ({ app, firstDeploy: firstDeployRetry } = await ensureApp(ctx, config));
684
924
  ctx.status("Uploading...");
925
+ // Buffer for the retry loop (Phase 7.2) — same pattern as the main
926
+ // publishInner path; the post-login flow gets the same auto-retry
927
+ // protection without a parallel retry helper.
928
+ const freshTarballBytes = new Uint8Array(await new Response(freshTarball.stream).arrayBuffer());
929
+ const freshIdempotencyKey = crypto.randomUUID();
685
930
  let deployment;
686
931
  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,
932
+ const { result: deployResponse } = await runDeployWithRetry({
933
+ call: (attempt) => ctx.client.apps.deploy(app.id, {
934
+ tarball: freshTarballBytes,
935
+ // Preserve preview/note semantics across the post-login retry.
936
+ // Without these, an interactive `percher publish --preview -m "x"`
937
+ // that hits a 401 would silently downgrade to a live deploy with
938
+ // no note after the user logs in. Codex P1, 2026-04-29.
939
+ type: input.preview ? "preview" : undefined,
940
+ note: input.message,
941
+ noCache: input.noCache,
942
+ idempotencyKey: freshIdempotencyKey,
943
+ // Phase 7.5 — same X-Publish-Attempts claim as the main
944
+ // publish path; ensures the post-login retry chain still
945
+ // yields a `retry_recovered` outcome when it eventually wins.
946
+ retriedAttempts: attempt - 1,
947
+ }),
948
+ onRetry: ({ attempt, decision }) => {
949
+ ctx.status(`Retrying after ${Math.round(decision.delayMs / 1000)}s — ${decision.reason} (attempt ${attempt + 1}/4)…`);
950
+ },
696
951
  });
697
952
  // FUTURE12 Phase 6d — same already_in_progress short-circuit as
698
953
  // the main publishInner path. Without this branch, a user who
@@ -746,7 +1001,7 @@ async function handleUnauthorized(ctx, config, tarball, input) {
746
1001
  bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
747
1002
  };
748
1003
  }
749
- const timeoutMs = 5 * 60 * 1000;
1004
+ const timeoutMs = TIMEOUTS.clientPublishPoll;
750
1005
  const start = Date.now();
751
1006
  while (deployment.status !== "live" &&
752
1007
  deployment.status !== "failed" &&
@@ -818,6 +1073,13 @@ async function handleUnauthorized(ctx, config, tarball, input) {
818
1073
  firstDeploy: firstDeployRetry || undefined,
819
1074
  recovery: RECOVERY_NONE,
820
1075
  cacheHit: deployment.cacheHit,
1076
+ // Phase 7.9 — operator-facing trace key, same shape the primary
1077
+ // success path returns. Codex P2 follow-up on daf063f: the
1078
+ // post-login retry path was returning a PublishResult without
1079
+ // `traceId`, so a user who started unauthenticated, logged in,
1080
+ // and then succeeded got no `Trace: dep_xxx` line from the CLI
1081
+ // and the correlation key was missing for that publish.
1082
+ traceId: deployment.id,
821
1083
  summary: buildLiveSummary({
822
1084
  appName: app.name,
823
1085
  url: url ?? app.url,