@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.
- package/dist/commands/cache.d.ts +23 -0
- package/dist/commands/cache.d.ts.map +1 -0
- package/dist/commands/cache.js +24 -0
- package/dist/commands/cache.js.map +1 -0
- package/dist/commands/capabilities.d.ts +7 -0
- package/dist/commands/capabilities.d.ts.map +1 -0
- package/dist/commands/capabilities.js +7 -0
- package/dist/commands/capabilities.js.map +1 -0
- package/dist/commands/claim.d.ts +24 -0
- package/dist/commands/claim.d.ts.map +1 -0
- package/dist/commands/claim.js +47 -0
- package/dist/commands/claim.js.map +1 -0
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +1 -1
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/dashboard.d.ts.map +1 -1
- package/dist/commands/dashboard.js +12 -1
- package/dist/commands/dashboard.js.map +1 -1
- package/dist/commands/doctor.d.ts +126 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +445 -313
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/inspect-link.d.ts +20 -0
- package/dist/commands/inspect-link.d.ts.map +1 -0
- package/dist/commands/inspect-link.js +28 -0
- package/dist/commands/inspect-link.js.map +1 -0
- package/dist/commands/migrate-supabase-scripts.d.ts.map +1 -1
- package/dist/commands/migrate-supabase-scripts.js +10 -7
- package/dist/commands/migrate-supabase-scripts.js.map +1 -1
- package/dist/commands/migrate-supabase-sdk.d.ts.map +1 -1
- package/dist/commands/migrate-supabase-sdk.js +33 -28
- package/dist/commands/migrate-supabase-sdk.js.map +1 -1
- package/dist/commands/migrate-supabase-walker.d.ts.map +1 -1
- package/dist/commands/migrate-supabase-walker.js +2 -3
- package/dist/commands/migrate-supabase-walker.js.map +1 -1
- package/dist/commands/preview-branch.d.ts +31 -0
- package/dist/commands/preview-branch.d.ts.map +1 -0
- package/dist/commands/preview-branch.js +55 -0
- package/dist/commands/preview-branch.js.map +1 -0
- package/dist/commands/publish-api-error.d.ts +15 -0
- package/dist/commands/publish-api-error.d.ts.map +1 -1
- package/dist/commands/publish-api-error.js +155 -6
- package/dist/commands/publish-api-error.js.map +1 -1
- package/dist/commands/publish-failure.d.ts +3 -1
- package/dist/commands/publish-failure.d.ts.map +1 -1
- package/dist/commands/publish-failure.js +11 -7
- package/dist/commands/publish-failure.js.map +1 -1
- package/dist/commands/publish.d.ts +15 -0
- package/dist/commands/publish.d.ts.map +1 -1
- package/dist/commands/publish.js +176 -321
- package/dist/commands/publish.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +4 -4
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/redeploy.js +3 -3
- package/dist/commands/redeploy.js.map +1 -1
- package/dist/commands/rename.d.ts +7 -0
- package/dist/commands/rename.d.ts.map +1 -1
- package/dist/commands/rename.js +32 -1
- package/dist/commands/rename.js.map +1 -1
- package/dist/commands/wait-deploy.d.ts +12 -2
- package/dist/commands/wait-deploy.d.ts.map +1 -1
- package/dist/commands/wait-deploy.js +115 -46
- package/dist/commands/wait-deploy.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/plans.d.ts +14 -1
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +49 -1
- package/dist/plans.js.map +1 -1
- package/dist/poll-deployment.d.ts +3 -3
- package/dist/poll-deployment.d.ts.map +1 -1
- package/dist/poll-deployment.js +5 -4
- package/dist/poll-deployment.js.map +1 -1
- package/dist/publish-retry.d.ts.map +1 -1
- package/dist/publish-retry.js +2 -3
- package/dist/publish-retry.js.map +1 -1
- package/package.json +4 -4
- package/dist/commands/continue.d.ts +0 -48
- package/dist/commands/continue.d.ts.map +0 -1
- package/dist/commands/continue.js +0 -121
- package/dist/commands/continue.js.map +0 -1
package/dist/commands/publish.js
CHANGED
|
@@ -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,
|
|
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 >
|
|
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
|
-
|
|
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
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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: "
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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:
|
|
904
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
1002
905
|
};
|
|
1003
906
|
}
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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 ────────────────────────────────────────────────────────────
|