@percher/core 0.4.2 → 0.4.4

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 (87) hide show
  1. package/dist/commands/cache.d.ts +23 -0
  2. package/dist/commands/cache.d.ts.map +1 -0
  3. package/dist/commands/cache.js +24 -0
  4. package/dist/commands/cache.js.map +1 -0
  5. package/dist/commands/capabilities.d.ts +7 -0
  6. package/dist/commands/capabilities.d.ts.map +1 -0
  7. package/dist/commands/capabilities.js +7 -0
  8. package/dist/commands/capabilities.js.map +1 -0
  9. package/dist/commands/claim.d.ts +24 -0
  10. package/dist/commands/claim.d.ts.map +1 -0
  11. package/dist/commands/claim.js +47 -0
  12. package/dist/commands/claim.js.map +1 -0
  13. package/dist/commands/create.d.ts.map +1 -1
  14. package/dist/commands/create.js +1 -1
  15. package/dist/commands/create.js.map +1 -1
  16. package/dist/commands/dashboard.d.ts.map +1 -1
  17. package/dist/commands/dashboard.js +12 -1
  18. package/dist/commands/dashboard.js.map +1 -1
  19. package/dist/commands/doctor.d.ts +126 -0
  20. package/dist/commands/doctor.d.ts.map +1 -1
  21. package/dist/commands/doctor.js +445 -313
  22. package/dist/commands/doctor.js.map +1 -1
  23. package/dist/commands/inspect-link.d.ts +20 -0
  24. package/dist/commands/inspect-link.d.ts.map +1 -0
  25. package/dist/commands/inspect-link.js +28 -0
  26. package/dist/commands/inspect-link.js.map +1 -0
  27. package/dist/commands/migrate-supabase-scripts.d.ts.map +1 -1
  28. package/dist/commands/migrate-supabase-scripts.js +10 -7
  29. package/dist/commands/migrate-supabase-scripts.js.map +1 -1
  30. package/dist/commands/migrate-supabase-sdk.d.ts.map +1 -1
  31. package/dist/commands/migrate-supabase-sdk.js +33 -28
  32. package/dist/commands/migrate-supabase-sdk.js.map +1 -1
  33. package/dist/commands/migrate-supabase-walker.d.ts.map +1 -1
  34. package/dist/commands/migrate-supabase-walker.js +2 -3
  35. package/dist/commands/migrate-supabase-walker.js.map +1 -1
  36. package/dist/commands/preview-branch.d.ts +31 -0
  37. package/dist/commands/preview-branch.d.ts.map +1 -0
  38. package/dist/commands/preview-branch.js +55 -0
  39. package/dist/commands/preview-branch.js.map +1 -0
  40. package/dist/commands/publish-api-error.d.ts +15 -0
  41. package/dist/commands/publish-api-error.d.ts.map +1 -1
  42. package/dist/commands/publish-api-error.js +155 -6
  43. package/dist/commands/publish-api-error.js.map +1 -1
  44. package/dist/commands/publish-failure.d.ts +3 -1
  45. package/dist/commands/publish-failure.d.ts.map +1 -1
  46. package/dist/commands/publish-failure.js +11 -7
  47. package/dist/commands/publish-failure.js.map +1 -1
  48. package/dist/commands/publish.d.ts +15 -0
  49. package/dist/commands/publish.d.ts.map +1 -1
  50. package/dist/commands/publish.js +176 -321
  51. package/dist/commands/publish.js.map +1 -1
  52. package/dist/commands/push.d.ts.map +1 -1
  53. package/dist/commands/push.js +4 -4
  54. package/dist/commands/push.js.map +1 -1
  55. package/dist/commands/redeploy.js +3 -3
  56. package/dist/commands/redeploy.js.map +1 -1
  57. package/dist/commands/rename.d.ts +7 -0
  58. package/dist/commands/rename.d.ts.map +1 -1
  59. package/dist/commands/rename.js +32 -1
  60. package/dist/commands/rename.js.map +1 -1
  61. package/dist/commands/wait-deploy.d.ts +12 -2
  62. package/dist/commands/wait-deploy.d.ts.map +1 -1
  63. package/dist/commands/wait-deploy.js +115 -46
  64. package/dist/commands/wait-deploy.js.map +1 -1
  65. package/dist/errors.d.ts +1 -1
  66. package/dist/errors.d.ts.map +1 -1
  67. package/dist/errors.js.map +1 -1
  68. package/dist/index.d.ts +6 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +6 -1
  71. package/dist/index.js.map +1 -1
  72. package/dist/plans.d.ts +14 -1
  73. package/dist/plans.d.ts.map +1 -1
  74. package/dist/plans.js +49 -1
  75. package/dist/plans.js.map +1 -1
  76. package/dist/poll-deployment.d.ts +3 -3
  77. package/dist/poll-deployment.d.ts.map +1 -1
  78. package/dist/poll-deployment.js +5 -4
  79. package/dist/poll-deployment.js.map +1 -1
  80. package/dist/publish-retry.d.ts.map +1 -1
  81. package/dist/publish-retry.js +2 -3
  82. package/dist/publish-retry.js.map +1 -1
  83. package/package.json +4 -4
  84. package/dist/commands/continue.d.ts +0 -48
  85. package/dist/commands/continue.d.ts.map +0 -1
  86. package/dist/commands/continue.js +0 -121
  87. package/dist/commands/continue.js.map +0 -1
@@ -1,18 +1,20 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { isDeployAlreadyInProgress, PercherApiError, PercherClient, } from "@percher/client";
3
+ import { isDeployAlreadyInProgress, PercherApiError, PercherClient, saveConfig, } from "@percher/client";
4
+ import { MAX_TARBALL_BYTES } from "@percher/shared";
5
+ import { solvePow } from "@percher/shared/pow";
4
6
  import { TIMEOUTS } from "@percher/shared/timeouts";
5
7
  import { PercherTomlError, parseFile } from "@percher/toml";
6
8
  import { z } from "zod";
7
9
  import { renderDeployEvent } from "../event-renderer";
8
10
  import { pollDeployment } from "../poll-deployment";
9
11
  import { runDeployWithRetry } from "../publish-retry";
10
- import { RECOVERY_NEEDS_LOGIN, RECOVERY_NONE, recoveryAsk, recoveryDoctor, recoveryEnv, recoveryFixConfig, recoveryWait, } from "../recovery";
12
+ import { RECOVERY_NEEDS_LOGIN, RECOVERY_NONE, recoveryAsk, recoveryDoctor, recoveryFixConfig, recoveryWait, } from "../recovery";
11
13
  import { createTarball } from "../tarball";
12
14
  import { scanForMissingBuildEnvRefs } from "./env-scan";
13
15
  import { init } from "./init";
14
16
  import { login } from "./login";
15
- import { classifyPublishApiError } from "./publish-api-error";
17
+ import { classifyPublishApiError, DEPLOY_GATE_CODES } from "./publish-api-error";
16
18
  import { buildFailureResult } from "./publish-failure";
17
19
  import { resolveNodeVersion } from "./publish-node";
18
20
  import { resolveReplaced } from "./wait-deploy";
@@ -35,8 +37,11 @@ export const publishInputSchema = z.object({
35
37
  .boolean()
36
38
  .optional()
37
39
  .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."),
40
+ anonymous: z
41
+ .boolean()
42
+ .optional()
43
+ .describe("Publish WITHOUT an account. Creates a throwaway anonymous app that expires in 72h unless claimed (percher_claim_app). The app runs with no outbound network, no database, and no env vars until claimed. Only honored when the caller has no token; ignored once signed in. Server-gated — fails with ANON_DEPLOYS_DISABLED if the instance hasn't enabled it."),
38
44
  });
39
- const MAX_BYTES = 500 * 1024 * 1024;
40
45
  const SUSPICIOUS_BYTES = 50 * 1024 * 1024;
41
46
  const SUSPICIOUS_FILES = 10_000;
42
47
  function tomlPathFor(cwd) {
@@ -99,39 +104,6 @@ function buildAlreadyInProgressResult(opts) {
99
104
  bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
100
105
  };
101
106
  }
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
107
  /**
136
108
  * Build the one-line summary echoed back to humans. Includes app name,
137
109
  * framework when known, total seconds, and final URL — covers the
@@ -326,7 +298,7 @@ async function publishInner(ctx, input) {
326
298
  ctx.status("[2/4] Packaging files...");
327
299
  const tarball = await createTarball({ cwd: ctx.cwd, config });
328
300
  const packageMs = Date.now() - t0;
329
- if (tarball.bytes > MAX_BYTES) {
301
+ if (tarball.bytes > MAX_TARBALL_BYTES) {
330
302
  return {
331
303
  status: "failed",
332
304
  fileCount: tarball.fileCount,
@@ -447,7 +419,29 @@ async function publishInner(ctx, input) {
447
419
  // proceeds and lets the retry loop (Phase 7.2) handle whatever
448
420
  // surfaces during the actual upload.
449
421
  }
450
- // ── 4. Upload ──────────────────────────────────────────────────────
422
+ // ── 4. Upload → poll → result ─────────────────────────────────────
423
+ return executeDeploy({
424
+ ctx,
425
+ config,
426
+ input,
427
+ app,
428
+ firstDeploy,
429
+ tarball,
430
+ t0,
431
+ packageMs,
432
+ missingBuildEnvKeys,
433
+ });
434
+ }
435
+ /**
436
+ * Post-upload deploy pipeline shared by the primary publish path and
437
+ * the post-login retry: buffer + upload the tarball (cache probe,
438
+ * idempotent retry loop), poll to a terminal status via pollDeployment
439
+ * (which retries transient network failures), and shape the
440
+ * PublishResult. Both entry paths must route through here so the
441
+ * re-auth path can't drift from the primary one.
442
+ */
443
+ async function executeDeploy(opts) {
444
+ const { ctx, config, input, app, firstDeploy, tarball, t0, packageMs, missingBuildEnvKeys } = opts;
451
445
  const uploadStart = Date.now();
452
446
  ctx.status("[3/4] Uploading...");
453
447
  // Buffer the tarball so the retry loop (Phase 7.2) can resend the
@@ -471,7 +465,11 @@ async function publishInner(ctx, input) {
471
465
  // Codex P2, 2026-05-08. Sent as `X-Tarball-Hash` on the upload so
472
466
  // the API stores it on `deployments.tarball_hash` for the probe.
473
467
  const contentHash = tarball.contentHash;
474
- if (!input.noCache) {
468
+ // Preview publishes never take the probe fast-path: the probe's hit
469
+ // branch reruns the previous LIVE deploy (traffic swap included),
470
+ // which would silently turn `--preview` into a live redeploy. The
471
+ // server-side image cache still skips the rebuild on upload.
472
+ if (!input.noCache && !input.preview) {
475
473
  try {
476
474
  const probe = await ctx.client.platform.cacheProbe({
477
475
  appId: app.id,
@@ -534,118 +532,18 @@ async function publishInner(ctx, input) {
534
532
  deployment = deployResponse;
535
533
  }
536
534
  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(", ")}.`,
535
+ // Pre-queue deploy gates (retry-limit guardrail, env contract,
536
+ // daily quota) classify via the shared table with app + bundle
537
+ // context. Everything else rethrows so the top-level catch-all
538
+ // keeps its existing rendering (no app, zero bundle).
539
+ if (err instanceof PercherApiError && DEPLOY_GATE_CODES.has(err.code)) {
540
+ const classified = classifyPublishApiError(err, {
588
541
  configPath: tomlPathFor(ctx.cwd),
589
542
  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
543
  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
- };
544
+ });
545
+ if (classified)
546
+ return classified;
649
547
  }
650
548
  throw err;
651
549
  }
@@ -869,6 +767,22 @@ async function publishInner(ctx, input) {
869
767
  };
870
768
  }
871
769
  async function handleUnauthorized(ctx, config, tarball, input) {
770
+ // Anonymous publish — no account needed. Mint a throwaway anon app
771
+ // (server-gated) and run the ordinary pipeline with its token.
772
+ if (input.anonymous) {
773
+ return handleAnonymousPublish(ctx, config, input, tarball);
774
+ }
775
+ // Advertise the anonymous path so an agent on the needs_login branch can
776
+ // discover it. Added statically: if the instance hasn't enabled anon
777
+ // deploys, the attempt returns ANON_DEPLOYS_DISABLED with clear text and
778
+ // the agent falls back to the login path.
779
+ const anonAlternative = {
780
+ action: "retry",
781
+ suggestedTool: "percher_publish",
782
+ args: { ...input, anonymous: true },
783
+ when: "to publish without an account (expires in 72h, claim later with percher_claim_app)",
784
+ };
785
+ const needsLoginRecovery = { ...RECOVERY_NEEDS_LOGIN, alternativeActions: [anonAlternative] };
872
786
  // In MCP context we can't open a browser — return login instructions
873
787
  if (!ctx.interactiveLogin) {
874
788
  try {
@@ -885,7 +799,7 @@ async function handleUnauthorized(ctx, config, tarball, input) {
885
799
  loginCode: userCode,
886
800
  fileCount: tarball.fileCount,
887
801
  bytes: tarball.bytes,
888
- recovery: RECOVERY_NEEDS_LOGIN,
802
+ recovery: needsLoginRecovery,
889
803
  summary: `Login required: open ${loginUrl} (code: ${userCode}).`,
890
804
  configPath: tomlPathFor(ctx.cwd),
891
805
  bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
@@ -901,14 +815,17 @@ async function handleUnauthorized(ctx, config, tarball, input) {
901
815
  explanation: "No valid Percher token found.",
902
816
  suggestion: "Run the percher_login tool first, or set PERCHER_TOKEN environment variable.",
903
817
  },
904
- recovery: RECOVERY_NEEDS_LOGIN,
818
+ recovery: needsLoginRecovery,
905
819
  summary: "Login required — run percher_login or set PERCHER_TOKEN.",
906
820
  configPath: tomlPathFor(ctx.cwd),
907
821
  bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
908
822
  };
909
823
  }
910
824
  }
911
- // Interactive CLI — trigger login flow
825
+ // Interactive CLI — trigger login flow, rebind the client to the new
826
+ // token, rebuild the inputs the failed attempt consumed (the tarball
827
+ // stream is spent, the app lookup ran against the dead token), then
828
+ // hand off to the same pipeline as the primary path.
912
829
  ctx.status("Not logged in — starting login...");
913
830
  const newToken = await login(ctx);
914
831
  ctx.client = new PercherClient({
@@ -916,186 +833,124 @@ async function handleUnauthorized(ctx, config, tarball, input) {
916
833
  token: newToken,
917
834
  sessionId: ctx.client.sessionId,
918
835
  });
919
- // Re-create tarball since stream is consumed
836
+ // Timing restarts here so the interactive login wait doesn't inflate
837
+ // totalSeconds in the result.
838
+ const t0 = Date.now();
920
839
  const freshTarball = await createTarball({ cwd: ctx.cwd, config });
921
- let firstDeployRetry = false;
922
- let app;
923
- ({ app, firstDeploy: firstDeployRetry } = await ensureApp(ctx, config));
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();
930
- let deployment;
840
+ const packageMs = Date.now() - t0;
841
+ const { app, firstDeploy } = await ensureApp(ctx, config);
842
+ // The advisory build-env scan doesn't run on this path (the 401 fired
843
+ // before publishInner reached it); empty keeps the field absent.
844
+ return executeDeploy({
845
+ ctx,
846
+ config,
847
+ input,
848
+ app,
849
+ firstDeploy,
850
+ tarball: freshTarball,
851
+ t0,
852
+ packageMs,
853
+ missingBuildEnvKeys: [],
854
+ });
855
+ }
856
+ /**
857
+ * Anonymous (account-less) publish. Mints a throwaway anon app + token
858
+ * via POST /anon/apps, persists + rebinds the token, overrides the app
859
+ * name to the server-assigned suffixed one, then runs the ordinary
860
+ * deploy pipeline. The result carries the claim token + TTL + the active
861
+ * restrictions so the caller can claim the app into a real account later.
862
+ */
863
+ async function handleAnonymousPublish(ctx, config, input, tarball) {
864
+ const anonClient = new PercherClient({
865
+ apiUrl: ctx.client.apiUrl,
866
+ sessionId: ctx.client.sessionId,
867
+ });
868
+ let created;
931
869
  try {
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
- },
951
- });
952
- // FUTURE12 Phase 6d — same already_in_progress short-circuit as
953
- // the main publishInner path. Without this branch, a user who
954
- // hits a 401 mid-publish and re-auths could land on the
955
- // post-login deploy step and have it silently fall through into
956
- // a 5-minute polling loop against the existing deploy's row.
957
- if (isDeployAlreadyInProgress(deployResponse)) {
958
- return buildAlreadyInProgressResult({
959
- active: deployResponse,
960
- app,
961
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
962
- cwd: ctx.cwd,
963
- });
870
+ // Proof-of-work gate: fetch a challenge and solve it before minting
871
+ // the anon app. When the server-side PoW flag is off, difficulty is 0
872
+ // and solvePow returns "0" instantly. A null solution means a
873
+ // pathological difficulty exhausted the iteration budget — throw so
874
+ // the catch below surfaces the same graceful "anonymous unavailable"
875
+ // needs_login fallback as a feature-flag-off (403) challenge fetch.
876
+ const ch = await anonClient.anon.requestChallenge();
877
+ const solution = solvePow(ch.nonce, ch.difficulty);
878
+ if (solution === null) {
879
+ throw new Error("proof-of-work unsolvable (difficulty too high)");
964
880
  }
965
- deployment = deployResponse;
881
+ created = await anonClient.anon.createAnonApp({
882
+ name: config.app.name,
883
+ powChallengeId: ch.challengeId,
884
+ powSolution: solution,
885
+ });
966
886
  }
967
887
  catch (err) {
968
- if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
969
- return buildRetryLimitReachedResult({
970
- err,
971
- app,
972
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
973
- cwd: ctx.cwd,
974
- });
975
- }
976
- throw err;
977
- }
978
- const replacedPreview = deployment.replacedPreview === true;
979
- // FUTURE11 Phase 1 — same async opt-in as the main publishInner path.
980
- // Without this, a caller that passed `waitForLive: false` and hit a
981
- // 401 (interactive login flow) would silently fall through to the
982
- // 5-minute polling loop after login, breaking the contract that
983
- // waitForLive=false always returns as soon as the deploy is queued.
984
- if (input.waitForLive === false) {
985
- ctx.status(`Queued (${deployment.id}) — resume with percher_wait_for_deploy.`);
888
+ const code = err.code;
889
+ const detail = code === "ANON_CAPACITY"
890
+ ? "Anonymous capacity is full right now."
891
+ : "Anonymous deploys are not enabled on this instance.";
986
892
  return {
987
- status: "queued",
988
- app,
989
- deployment,
990
- fileCount: freshTarball.fileCount,
991
- bytes: freshTarball.bytes,
992
- replacedPreview: replacedPreview || undefined,
993
- firstDeploy: firstDeployRetry || undefined,
994
- recovery: recoveryWait({
995
- app: app.name,
996
- deployId: deployment.id,
997
- reasonCode: "deploy_queued",
998
- }),
999
- summary: `${app.name} deploy ${deployment.id} queued — resume with percher_wait_for_deploy.`,
893
+ status: "needs_login",
894
+ fileCount: tarball.fileCount,
895
+ bytes: tarball.bytes,
896
+ error: {
897
+ title: "Anonymous publish unavailable",
898
+ explanation: detail,
899
+ suggestion: "Run the percher_login tool to publish with an account instead.",
900
+ },
901
+ recovery: RECOVERY_NEEDS_LOGIN,
902
+ summary: `${detail} Log in to publish instead.`,
1000
903
  configPath: tomlPathFor(ctx.cwd),
1001
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
904
+ bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
1002
905
  };
1003
906
  }
1004
- const timeoutMs = TIMEOUTS.clientPublishPoll;
1005
- const start = Date.now();
1006
- while (deployment.status !== "live" &&
1007
- deployment.status !== "failed" &&
1008
- deployment.status !== "replaced") {
1009
- if (Date.now() - start > timeoutMs) {
1010
- return {
1011
- status: "failed",
1012
- app,
1013
- deployment,
1014
- fileCount: freshTarball.fileCount,
1015
- bytes: freshTarball.bytes,
1016
- error: {
1017
- title: "Deploy timed out",
1018
- explanation: "The deployment did not complete within 5 minutes.",
1019
- 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.`,
1020
- },
1021
- // FUTURE12 Phase 4: ambiguous stalllet doctor classify
1022
- // before the agent dives into the raw log.
1023
- recovery: recoveryDoctor({
1024
- app: app.name,
1025
- deployId: deployment.id,
1026
- mode: "deploy",
1027
- reasonCode: "deploy_stalled",
1028
- }),
1029
- summary: `Deploy timed out after 5 minutes (${deployment.id}).`,
1030
- configPath: tomlPathFor(ctx.cwd),
1031
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
1032
- };
1033
- }
1034
- ctx.status(`${deployment.status}...`);
1035
- await new Promise((r) => setTimeout(r, 2000));
1036
- deployment = await ctx.client.apps.getDeployment(app.id, deployment.id);
1037
- }
1038
- if (deployment.status === "replaced") {
1039
- return await buildReplacedResult({
1040
- ctx,
1041
- app,
1042
- deployment,
1043
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
1044
- cwd: ctx.cwd,
1045
- });
1046
- }
1047
- if (deployment.status === "failed") {
1048
- return buildFailureResult({
1049
- ctx,
1050
- app,
1051
- deployment,
1052
- tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
1053
- input,
1054
- });
1055
- }
1056
- const totalSeconds = (Date.now() - start) / 1000;
1057
- const url = deployment.previewUrl ?? deployment.url ?? app.url;
1058
- ctx.status(`Live at ${url} (${totalSeconds.toFixed(0)}s)`);
1059
- return {
1060
- status: "live",
1061
- url,
907
+ // Persist + rebind to the anon token; stash the claim state so a later
908
+ // `percher claim` in this project works without re-pasting the token.
909
+ saveConfig({
910
+ token: created.token,
911
+ anonClaim: {
912
+ app: created.app.name,
913
+ claimToken: created.claimToken,
914
+ expiresAt: created.expiresAt,
915
+ },
916
+ });
917
+ const newClient = new PercherClient({
918
+ apiUrl: ctx.client.apiUrl,
919
+ token: created.token,
920
+ sessionId: ctx.client.sessionId,
921
+ });
922
+ ctx.client = newClient;
923
+ ctx.setClient?.(newClient);
924
+ // The server assigns a random suffix (anti-squat) deploy to that name.
925
+ config.app.name = created.app.name;
926
+ const t0 = Date.now();
927
+ const freshTarball = await createTarball({ cwd: ctx.cwd, config });
928
+ const packageMs = Date.now() - t0;
929
+ const { app, firstDeploy } = await ensureApp(ctx, config);
930
+ const result = await executeDeploy({
931
+ ctx,
932
+ config,
933
+ input,
1062
934
  app,
1063
- deployment,
1064
- timing: {
1065
- totalSeconds: Math.round(totalSeconds),
1066
- packageMs: 0,
1067
- uploadMs: 0,
1068
- buildMs: 0,
935
+ firstDeploy,
936
+ tarball: freshTarball,
937
+ t0,
938
+ packageMs,
939
+ missingBuildEnvKeys: [],
940
+ });
941
+ return {
942
+ ...result,
943
+ anonymous: true,
944
+ claim: {
945
+ claimUrl: created.claimUrl,
946
+ claimToken: created.claimToken,
947
+ expiresAt: created.expiresAt,
1069
948
  },
1070
- fileCount: freshTarball.fileCount,
1071
- bytes: freshTarball.bytes,
1072
- replacedPreview,
1073
- firstDeploy: firstDeployRetry || undefined,
1074
- recovery: RECOVERY_NONE,
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,
1083
- summary: buildLiveSummary({
1084
- appName: app.name,
1085
- url: url ?? app.url,
1086
- totalSeconds: Math.round(totalSeconds),
1087
- framework: config.app.framework,
1088
- preview: input.preview === true,
1089
- replacedPreview,
1090
- // Read directly off the polled deployment row — same source of
1091
- // truth as the primary publish path. Codex P2 follow-up on
1092
- // 9e5ceee: the previous hardcoded `false` here meant the user
1093
- // who hit the post-login retry branch always saw "Build cache:
1094
- // miss" regardless of whether the build actually used the cache.
1095
- cacheHit: deployment.cacheHit ?? false,
1096
- }),
1097
- configPath: tomlPathFor(ctx.cwd),
1098
- bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
949
+ warnings: [
950
+ ...(result.warnings ?? []),
951
+ `Anonymous app: expires ${created.expiresAt} unless claimed — run percher_claim_app (claim token is in claim.claimToken).`,
952
+ "No outbound network, no database, and no env vars until claimed.",
953
+ ],
1099
954
  };
1100
955
  }
1101
956
  // ── Helpers ────────────────────────────────────────────────────────────