@percher/core 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-files-manifest.d.ts +28 -0
- package/dist/ai-files-manifest.d.ts.map +1 -0
- package/dist/ai-files-manifest.js +96 -0
- package/dist/ai-files-manifest.js.map +1 -0
- package/dist/commands/account.d.ts +51 -0
- package/dist/commands/account.d.ts.map +1 -0
- package/dist/commands/account.js +88 -0
- package/dist/commands/account.js.map +1 -0
- package/dist/commands/ai-files.d.ts +73 -0
- package/dist/commands/ai-files.d.ts.map +1 -0
- package/dist/commands/ai-files.js +179 -0
- package/dist/commands/ai-files.js.map +1 -0
- package/dist/commands/billing.d.ts +1 -1
- package/dist/commands/billing.d.ts.map +1 -1
- package/dist/commands/billing.js +1 -1
- package/dist/commands/billing.js.map +1 -1
- package/dist/commands/continue.d.ts +48 -0
- package/dist/commands/continue.d.ts.map +1 -0
- package/dist/commands/continue.js +121 -0
- package/dist/commands/continue.js.map +1 -0
- package/dist/commands/create.d.ts +1 -1
- 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 +15 -0
- package/dist/commands/dashboard.d.ts.map +1 -0
- package/dist/commands/dashboard.js +33 -0
- package/dist/commands/dashboard.js.map +1 -0
- package/dist/commands/data-export.d.ts +21 -0
- package/dist/commands/data-export.d.ts.map +1 -0
- package/dist/commands/data-export.js +36 -0
- package/dist/commands/data-export.js.map +1 -0
- package/dist/commands/data.d.ts +1 -1
- package/dist/commands/data.d.ts.map +1 -1
- package/dist/commands/data.js +1 -1
- package/dist/commands/data.js.map +1 -1
- package/dist/commands/delete.d.ts +1 -1
- package/dist/commands/delete.d.ts.map +1 -1
- package/dist/commands/delete.js +1 -1
- package/dist/commands/delete.js.map +1 -1
- package/dist/commands/deploys.d.ts +2 -2
- package/dist/commands/deploys.d.ts.map +1 -1
- package/dist/commands/deploys.js +21 -5
- package/dist/commands/deploys.js.map +1 -1
- package/dist/commands/dev.d.ts +1 -9
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +77 -23
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/diagnose.d.ts +1 -1
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +1 -1
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/doctor.d.ts +63 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +792 -10
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/domains.d.ts +1 -1
- package/dist/commands/domains.d.ts.map +1 -1
- package/dist/commands/domains.js +1 -1
- package/dist/commands/domains.js.map +1 -1
- package/dist/commands/env-scan.d.ts +2 -0
- package/dist/commands/env-scan.d.ts.map +1 -0
- package/dist/commands/env-scan.js +92 -0
- package/dist/commands/env-scan.js.map +1 -0
- package/dist/commands/env.d.ts +1 -1
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +1 -1
- package/dist/commands/env.js.map +1 -1
- package/dist/commands/export.d.ts +1 -1
- package/dist/commands/export.js +1 -1
- package/dist/commands/generate.d.ts +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +14 -9
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/github.d.ts +60 -0
- package/dist/commands/github.d.ts.map +1 -0
- package/dist/commands/github.js +112 -0
- package/dist/commands/github.js.map +1 -0
- package/dist/commands/import-project.d.ts +1 -1
- package/dist/commands/import-project.d.ts.map +1 -1
- package/dist/commands/import-project.js +1 -1
- package/dist/commands/import-project.js.map +1 -1
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.d.ts.map +1 -1
- package/dist/commands/insights.js +1 -1
- package/dist/commands/insights.js.map +1 -1
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logs.d.ts +1 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +1 -1
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/mcp.d.ts +1 -1
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/open.d.ts +1 -1
- package/dist/commands/open.d.ts.map +1 -1
- package/dist/commands/open.js +1 -1
- package/dist/commands/open.js.map +1 -1
- package/dist/commands/publish-failure.d.ts +31 -0
- package/dist/commands/publish-failure.d.ts.map +1 -0
- package/dist/commands/publish-failure.js +142 -0
- package/dist/commands/publish-failure.js.map +1 -0
- package/dist/commands/publish-node.d.ts +13 -0
- package/dist/commands/publish-node.d.ts.map +1 -0
- package/dist/commands/publish-node.js +38 -0
- package/dist/commands/publish-node.js.map +1 -0
- package/dist/commands/publish.d.ts +87 -3
- package/dist/commands/publish.d.ts.map +1 -1
- package/dist/commands/publish.js +589 -156
- package/dist/commands/publish.js.map +1 -1
- package/dist/commands/push.d.ts +45 -8
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +215 -22
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/redeploy.d.ts +28 -0
- package/dist/commands/redeploy.d.ts.map +1 -0
- package/dist/commands/redeploy.js +417 -0
- package/dist/commands/redeploy.js.map +1 -0
- package/dist/commands/rename.d.ts +1 -1
- package/dist/commands/rename.d.ts.map +1 -1
- package/dist/commands/rename.js +1 -1
- package/dist/commands/rename.js.map +1 -1
- package/dist/commands/reproduce.d.ts +64 -0
- package/dist/commands/reproduce.d.ts.map +1 -0
- package/dist/commands/reproduce.js +211 -0
- package/dist/commands/reproduce.js.map +1 -0
- package/dist/commands/reset-superuser.d.ts +1 -1
- package/dist/commands/reset-superuser.d.ts.map +1 -1
- package/dist/commands/reset-superuser.js +1 -1
- package/dist/commands/reset-superuser.js.map +1 -1
- package/dist/commands/restore.d.ts +79 -0
- package/dist/commands/restore.d.ts.map +1 -0
- package/dist/commands/restore.js +164 -0
- package/dist/commands/restore.js.map +1 -0
- package/dist/commands/resume.d.ts +1 -1
- package/dist/commands/resume.d.ts.map +1 -1
- package/dist/commands/resume.js +1 -1
- package/dist/commands/resume.js.map +1 -1
- package/dist/commands/rollback.d.ts +20 -8
- package/dist/commands/rollback.d.ts.map +1 -1
- package/dist/commands/rollback.js +11 -6
- package/dist/commands/rollback.js.map +1 -1
- package/dist/commands/unsuspend.d.ts +35 -0
- package/dist/commands/unsuspend.d.ts.map +1 -0
- package/dist/commands/unsuspend.js +27 -0
- package/dist/commands/unsuspend.js.map +1 -0
- package/dist/commands/versions.d.ts +1 -1
- package/dist/commands/versions.d.ts.map +1 -1
- package/dist/commands/versions.js +1 -1
- package/dist/commands/versions.js.map +1 -1
- package/dist/commands/wait-deploy.d.ts +92 -0
- package/dist/commands/wait-deploy.d.ts.map +1 -0
- package/dist/commands/wait-deploy.js +225 -0
- package/dist/commands/wait-deploy.js.map +1 -0
- package/dist/env-scan-source.d.ts +39 -0
- package/dist/env-scan-source.d.ts.map +1 -0
- package/dist/env-scan-source.js +332 -0
- package/dist/env-scan-source.js.map +1 -0
- package/dist/error-classifier.d.ts.map +1 -1
- package/dist/error-classifier.js +67 -4
- package/dist/error-classifier.js.map +1 -1
- package/dist/errors.d.ts +8 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/plans.d.ts +11 -0
- package/dist/plans.d.ts.map +1 -1
- package/dist/plans.js +10 -0
- package/dist/plans.js.map +1 -1
- package/dist/poll-deployment.d.ts +47 -0
- package/dist/poll-deployment.d.ts.map +1 -0
- package/dist/poll-deployment.js +57 -0
- package/dist/poll-deployment.js.map +1 -0
- package/dist/recovery.d.ts +356 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +299 -0
- package/dist/recovery.js.map +1 -0
- package/dist/stream-utils.d.ts +21 -0
- package/dist/stream-utils.d.ts.map +1 -0
- package/dist/stream-utils.js +41 -0
- package/dist/stream-utils.js.map +1 -0
- package/dist/templates/ai-files/claude-md.d.ts +7 -0
- package/dist/templates/ai-files/claude-md.d.ts.map +1 -0
- package/dist/templates/ai-files/claude-md.js +78 -0
- package/dist/templates/ai-files/claude-md.js.map +1 -0
- package/dist/templates/ai-files/cursor-percher-mdc.d.ts +7 -0
- package/dist/templates/ai-files/cursor-percher-mdc.d.ts.map +1 -0
- package/dist/templates/ai-files/cursor-percher-mdc.js +111 -0
- package/dist/templates/ai-files/cursor-percher-mdc.js.map +1 -0
- package/dist/templates/ai-files/index.d.ts +8 -0
- package/dist/templates/ai-files/index.d.ts.map +1 -0
- package/dist/templates/ai-files/index.js +4 -0
- package/dist/templates/ai-files/index.js.map +1 -0
- package/package.json +5 -5
package/dist/commands/publish.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { PercherApiError, PercherClient } from "@percher/client";
|
|
3
|
+
import { PercherApiError, PercherClient, isDeployAlreadyInProgress, } from "@percher/client";
|
|
4
4
|
import { parseFile } from "@percher/toml";
|
|
5
|
-
import { z } from "zod";
|
|
5
|
+
import { z } from "zod/v3";
|
|
6
6
|
import { classifyError } from "../error-classifier";
|
|
7
|
+
import { pollDeployment } from "../poll-deployment";
|
|
8
|
+
import { RECOVERY_NEEDS_LOGIN, RECOVERY_NONE, recoveryAsk, recoveryDoctor, recoveryEnv, recoveryFixConfig, recoveryFromErrorClass, recoveryWait, } from "../recovery";
|
|
7
9
|
import { createTarball } from "../tarball";
|
|
10
|
+
import { scanForMissingBuildEnvRefs } from "./env-scan";
|
|
8
11
|
import { init } from "./init";
|
|
9
12
|
import { login } from "./login";
|
|
13
|
+
import { buildFailureResult } from "./publish-failure";
|
|
14
|
+
import { resolveNodeVersion } from "./publish-node";
|
|
15
|
+
import { resolveReplaced } from "./wait-deploy";
|
|
10
16
|
export const publishInputSchema = z.object({
|
|
11
17
|
force: z.boolean().optional().describe("Skip size warnings"),
|
|
12
18
|
preview: z.boolean().optional().describe("Deploy as preview (does not replace the live version)"),
|
|
@@ -18,10 +24,129 @@ export const publishInputSchema = z.object({
|
|
|
18
24
|
.boolean()
|
|
19
25
|
.optional()
|
|
20
26
|
.describe("Show what would be deployed (file count, bytes, top files) without uploading or building. Useful for verifying the bundle before a real deploy."),
|
|
27
|
+
noCache: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Skip the image-cache lookup and force a fresh build. Use this when you suspect the cached image doesn't reflect your latest source — the new build's resulting image still gets cached, so future deploys benefit."),
|
|
31
|
+
waitForLive: z
|
|
32
|
+
.boolean()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("If true (default), publish blocks until the deploy is live or failed. If false, publish returns as soon as the deploy is queued and the agent can resume with percher_wait_for_deploy. Recommended for AI agents — gives back control quickly with a deployId so they can decide between waiting, asking the user, or working on something else."),
|
|
21
35
|
});
|
|
22
36
|
const MAX_BYTES = 500 * 1024 * 1024;
|
|
23
37
|
const SUSPICIOUS_BYTES = 50 * 1024 * 1024;
|
|
24
38
|
const SUSPICIOUS_FILES = 10_000;
|
|
39
|
+
function tomlPathFor(cwd) {
|
|
40
|
+
return join(cwd, "percher.toml");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* FUTURE11 review P2 — sync publish path's `replaced` terminal result.
|
|
44
|
+
* `replaced` has two API code-paths: a newer live deploy succeeded, or
|
|
45
|
+
* reconcile saw the container is gone. We can't tell which from the
|
|
46
|
+
* row itself, so resolve by fetching the latest deploy and routing
|
|
47
|
+
* accordingly (live → none + url; in-flight → wait_deploy; otherwise
|
|
48
|
+
* ask_user). Matches FUTURE12 Phase 4's locked behavior for
|
|
49
|
+
* `replaced_by_newer`.
|
|
50
|
+
*/
|
|
51
|
+
async function buildReplacedResult(opts) {
|
|
52
|
+
const { ctx, app, deployment, tarball, cwd } = opts;
|
|
53
|
+
const resolved = await resolveReplaced({
|
|
54
|
+
ctx,
|
|
55
|
+
app,
|
|
56
|
+
replacedDeployment: deployment,
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
status: "replaced",
|
|
60
|
+
url: resolved.url,
|
|
61
|
+
app,
|
|
62
|
+
deployment,
|
|
63
|
+
fileCount: tarball.fileCount,
|
|
64
|
+
bytes: tarball.bytes,
|
|
65
|
+
recovery: resolved.recovery,
|
|
66
|
+
summary: resolved.summary,
|
|
67
|
+
configPath: tomlPathFor(cwd),
|
|
68
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* FUTURE12 Phase 6d — server told us a deploy is already in flight for
|
|
73
|
+
* this app. Build a successful PublishResult that points the agent at
|
|
74
|
+
* the existing deployId via `wait_deploy` (retryable: false — the agent
|
|
75
|
+
* MUST wait, not retry).
|
|
76
|
+
*/
|
|
77
|
+
function buildAlreadyInProgressResult(opts) {
|
|
78
|
+
const { active, app, tarball, cwd } = opts;
|
|
79
|
+
const reasonCode = active.deployStatus === "queued"
|
|
80
|
+
? "deploy_queued"
|
|
81
|
+
: active.deployStatus === "building"
|
|
82
|
+
? "deploy_building"
|
|
83
|
+
: "deploy_deploying";
|
|
84
|
+
return {
|
|
85
|
+
status: "already_in_progress",
|
|
86
|
+
app,
|
|
87
|
+
fileCount: tarball.fileCount,
|
|
88
|
+
bytes: tarball.bytes,
|
|
89
|
+
recovery: recoveryWait({
|
|
90
|
+
app: app.name,
|
|
91
|
+
deployId: active.deployId,
|
|
92
|
+
reasonCode,
|
|
93
|
+
}),
|
|
94
|
+
summary: `${app.name} already has a deploy in progress (${active.deployId}, ${active.deployStatus}) — wait for it to finish instead of queueing a duplicate.`,
|
|
95
|
+
configPath: tomlPathFor(cwd),
|
|
96
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* FUTURE12 Phase 6d — server rejected this publish because the user
|
|
101
|
+
* already used their one auto-retry within the 10-minute window after
|
|
102
|
+
* an `infra_unavailable` failure. Build an `ask_user` recovery so the
|
|
103
|
+
* agent surfaces it to the human instead of looping.
|
|
104
|
+
*/
|
|
105
|
+
function buildRetryLimitReachedResult(opts) {
|
|
106
|
+
const { err, app, tarball, cwd } = opts;
|
|
107
|
+
const extra = err.extra ?? {};
|
|
108
|
+
const resetAtStr = extra.resetAt ?? "";
|
|
109
|
+
const resetAtForPrompt = resetAtStr || "the cooldown window expires";
|
|
110
|
+
const prompt = `Percher already retried once after an infrastructure failure for ${app.name}. Wait until ${resetAtForPrompt} or surface to the user.`;
|
|
111
|
+
return {
|
|
112
|
+
status: "failed",
|
|
113
|
+
app,
|
|
114
|
+
fileCount: tarball.fileCount,
|
|
115
|
+
bytes: tarball.bytes,
|
|
116
|
+
error: {
|
|
117
|
+
title: "Retry limit reached",
|
|
118
|
+
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.",
|
|
119
|
+
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.`,
|
|
120
|
+
},
|
|
121
|
+
recovery: recoveryAsk({
|
|
122
|
+
prompt,
|
|
123
|
+
options: ["wait before retrying", "inspect status"],
|
|
124
|
+
reasonCode: "retry_limit_reached",
|
|
125
|
+
retryable: false,
|
|
126
|
+
}),
|
|
127
|
+
summary: `Retry limit reached for ${app.name}${resetAtStr ? ` — wait until ${resetAtStr}` : ""}.`,
|
|
128
|
+
configPath: tomlPathFor(cwd),
|
|
129
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Build the one-line summary echoed back to humans. Includes app name,
|
|
134
|
+
* framework when known, total seconds, and final URL — covers the
|
|
135
|
+
* "deployed `kvittakvitto` (Next.js, 47s) → kvittakvitto.percher.run"
|
|
136
|
+
* shape called out in the agent-DX plan.
|
|
137
|
+
*/
|
|
138
|
+
function buildLiveSummary(opts) {
|
|
139
|
+
// Suffix: "(framework, Ns)" with optional "cached" tag when the
|
|
140
|
+
// image cache matched. Cache-hit deploys are typically <10s so the
|
|
141
|
+
// tag is the load-bearing signal in the summary, not the duration.
|
|
142
|
+
const cacheTag = opts.cacheHit ? "cached, " : "";
|
|
143
|
+
const fwSuffix = opts.framework
|
|
144
|
+
? ` (${opts.framework}, ${cacheTag}${opts.totalSeconds}s)`
|
|
145
|
+
: ` (${cacheTag}${opts.totalSeconds}s)`;
|
|
146
|
+
const verb = opts.preview ? "preview deployed" : "deployed";
|
|
147
|
+
const replaced = opts.replacedPreview ? " — replaced previous preview" : "";
|
|
148
|
+
return `${opts.appName} ${verb}${fwSuffix} → ${opts.url}${replaced}`;
|
|
149
|
+
}
|
|
25
150
|
/**
|
|
26
151
|
* All-in-one publish command for AI agents.
|
|
27
152
|
*
|
|
@@ -47,12 +172,23 @@ export async function publish(ctx, input = {}) {
|
|
|
47
172
|
explanation: hint ?? message,
|
|
48
173
|
suggestion: "This may be a network issue or a Percher bug. Check your connection and try again. If the problem persists, run percher_doctor for diagnostics.",
|
|
49
174
|
},
|
|
175
|
+
// Network glitches surface here too, but we can't tell them apart
|
|
176
|
+
// from genuine bugs without inspecting the cause. `ask_user` keeps
|
|
177
|
+
// the agent honest — surface the explanation, don't auto-retry.
|
|
178
|
+
recovery: recoveryAsk({
|
|
179
|
+
prompt: `Unexpected publish failure: ${hint ?? message}. Run \`percher doctor\` for diagnostics or surface this to the user.`,
|
|
180
|
+
reasonCode: "unknown",
|
|
181
|
+
}),
|
|
182
|
+
summary: `Unexpected error: ${hint ?? message}`,
|
|
183
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
184
|
+
bundle: { fileCount: 0, bytes: 0 },
|
|
50
185
|
};
|
|
51
186
|
}
|
|
52
187
|
}
|
|
53
188
|
async function publishInner(ctx, input) {
|
|
54
189
|
const t0 = Date.now();
|
|
55
190
|
// ── 1. Config ──────────────────────────────────────────────────────
|
|
191
|
+
ctx.status("[1/4] Reading config...");
|
|
56
192
|
let config;
|
|
57
193
|
try {
|
|
58
194
|
config = await loadOrGenerate(ctx);
|
|
@@ -67,9 +203,22 @@ async function publishInner(ctx, input) {
|
|
|
67
203
|
explanation: "percher.toml could not be auto-generated for this project.",
|
|
68
204
|
suggestion: "Create a percher.toml manually with at least [app] name and runtime, then try again.",
|
|
69
205
|
},
|
|
206
|
+
recovery: recoveryFixConfig({
|
|
207
|
+
problems: [
|
|
208
|
+
{
|
|
209
|
+
file: "percher.toml",
|
|
210
|
+
message: "percher.toml could not be auto-generated for this project. Create one manually with at least [app] name and runtime.",
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
reasonCode: "config_missing",
|
|
214
|
+
}),
|
|
215
|
+
summary: "Could not generate percher.toml — create one manually and retry.",
|
|
216
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
217
|
+
bundle: { fileCount: 0, bytes: 0 },
|
|
70
218
|
};
|
|
71
219
|
}
|
|
72
220
|
// ── 2. Tarball ─────────────────────────────────────────────────────
|
|
221
|
+
ctx.status("[2/4] Packaging files...");
|
|
73
222
|
const tarball = await createTarball({ cwd: ctx.cwd, config });
|
|
74
223
|
const packageMs = Date.now() - t0;
|
|
75
224
|
if (tarball.bytes > MAX_BYTES) {
|
|
@@ -82,6 +231,16 @@ async function publishInner(ctx, input) {
|
|
|
82
231
|
explanation: `The project is ${(tarball.bytes / 1024 / 1024).toFixed(1)} MB, which exceeds the 500 MB limit.`,
|
|
83
232
|
suggestion: `Add large files/folders to .gitignore. Biggest: ${tarball.topFiles.map((f) => f.path).join(", ")}`,
|
|
84
233
|
},
|
|
234
|
+
// Not retryable, not config-invalid in the toml sense — just too big.
|
|
235
|
+
// The user has to make a manual decision (add to .gitignore, split repo,
|
|
236
|
+
// bump plan limits). `ask_user` is the honest action.
|
|
237
|
+
recovery: recoveryAsk({
|
|
238
|
+
prompt: `Repository is ${(tarball.bytes / 1024 / 1024).toFixed(1)} MB, which exceeds the 500 MB upload limit. Add large files/folders to .gitignore (biggest: ${tarball.topFiles.map((f) => f.path).join(", ")}) and try again.`,
|
|
239
|
+
reasonCode: "unknown",
|
|
240
|
+
}),
|
|
241
|
+
summary: `Repository too large (${(tarball.bytes / 1024 / 1024).toFixed(1)} MB exceeds 500 MB limit).`,
|
|
242
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
243
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
85
244
|
};
|
|
86
245
|
}
|
|
87
246
|
if (!input.force && (tarball.fileCount > SUSPICIOUS_FILES || tarball.bytes > SUSPICIOUS_BYTES)) {
|
|
@@ -103,27 +262,181 @@ async function publishInner(ctx, input) {
|
|
|
103
262
|
status: "dry_run",
|
|
104
263
|
fileCount: tarball.fileCount,
|
|
105
264
|
bytes: tarball.bytes,
|
|
265
|
+
recovery: RECOVERY_NONE,
|
|
266
|
+
summary: `Dry run: ${tarball.fileCount} files, ${(tarball.bytes / 1024).toFixed(1)} KB — no deploy performed.`,
|
|
267
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
268
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
106
269
|
};
|
|
107
270
|
}
|
|
108
271
|
// ── 3. Auth + ensure app ───────────────────────────────────────────
|
|
109
272
|
let app;
|
|
273
|
+
let firstDeploy = false;
|
|
110
274
|
try {
|
|
111
|
-
app = await ensureApp(ctx, config);
|
|
275
|
+
({ app, firstDeploy } = await ensureApp(ctx, config));
|
|
112
276
|
}
|
|
113
277
|
catch (err) {
|
|
114
278
|
if (err instanceof PercherApiError && err.status === 401) {
|
|
115
|
-
return handleUnauthorized(ctx, config, tarball);
|
|
279
|
+
return handleUnauthorized(ctx, config, tarball, input);
|
|
116
280
|
}
|
|
117
281
|
throw err;
|
|
118
282
|
}
|
|
283
|
+
// ── 3.5. Build-env scan ────────────────────────────────────────────
|
|
284
|
+
// Advisory-only: a transient error must never block a deploy.
|
|
285
|
+
let missingBuildEnvKeys = [];
|
|
286
|
+
try {
|
|
287
|
+
const existingEnvKeys = new Set(Object.keys(await ctx.client.env.list(app.id)));
|
|
288
|
+
missingBuildEnvKeys = scanForMissingBuildEnvRefs(ctx.cwd, existingEnvKeys);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Degrade silently — scan failure is not a reason to abort publish.
|
|
292
|
+
}
|
|
293
|
+
if (missingBuildEnvKeys.length > 0 && !input.force) {
|
|
294
|
+
const keyList = missingBuildEnvKeys.map((k) => ` • ${k}`).join("\n");
|
|
295
|
+
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
|
+
}
|
|
119
297
|
// ── 4. Upload ──────────────────────────────────────────────────────
|
|
120
298
|
const uploadStart = Date.now();
|
|
121
|
-
ctx.status("Uploading...");
|
|
122
|
-
let deployment
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
299
|
+
ctx.status("[3/4] Uploading...");
|
|
300
|
+
let deployment;
|
|
301
|
+
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";
|
|
357
|
+
return {
|
|
358
|
+
status: "failed",
|
|
359
|
+
app,
|
|
360
|
+
fileCount: tarball.fileCount,
|
|
361
|
+
bytes: tarball.bytes,
|
|
362
|
+
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",
|
|
367
|
+
phase: "config",
|
|
368
|
+
missingEnvVars: keys,
|
|
369
|
+
},
|
|
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"],
|
|
401
|
+
},
|
|
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(", ")}.`,
|
|
407
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
408
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
409
|
+
};
|
|
410
|
+
}
|
|
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",
|
|
432
|
+
}),
|
|
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
|
+
};
|
|
437
|
+
}
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
127
440
|
// Surface the auto-replace one-shot signal — only the initial POST
|
|
128
441
|
// response carries it, so capture before the polling loop reassigns
|
|
129
442
|
// `deployment` from getDeployment() (which never sees the flag).
|
|
@@ -132,53 +445,137 @@ async function publishInner(ctx, input) {
|
|
|
132
445
|
ctx.status("Replaced previous preview...");
|
|
133
446
|
}
|
|
134
447
|
const uploadMs = Date.now() - uploadStart;
|
|
448
|
+
// FUTURE11 Phase 1 — async opt-in. Caller asked publish to return as
|
|
449
|
+
// soon as the deploy is queued so it can resume with
|
|
450
|
+
// percher_wait_for_deploy. The upload is finished and the server has
|
|
451
|
+
// the deploy row; the only thing left is the build, which is exactly
|
|
452
|
+
// the part agents shouldn't block on.
|
|
453
|
+
if (input.waitForLive === false) {
|
|
454
|
+
ctx.status(`Queued (${deployment.id}) — resume with percher_wait_for_deploy.`);
|
|
455
|
+
return {
|
|
456
|
+
status: "queued",
|
|
457
|
+
app,
|
|
458
|
+
deployment,
|
|
459
|
+
fileCount: tarball.fileCount,
|
|
460
|
+
bytes: tarball.bytes,
|
|
461
|
+
replacedPreview: replacedPreview || undefined,
|
|
462
|
+
firstDeploy: firstDeploy || undefined,
|
|
463
|
+
missingBuildEnvKeys: missingBuildEnvKeys.length > 0 ? missingBuildEnvKeys : undefined,
|
|
464
|
+
recovery: recoveryWait({
|
|
465
|
+
app: app.name,
|
|
466
|
+
deployId: deployment.id,
|
|
467
|
+
reasonCode: "deploy_queued",
|
|
468
|
+
}),
|
|
469
|
+
summary: `${app.name} deploy ${deployment.id} queued — resume with percher_wait_for_deploy.`,
|
|
470
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
471
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
472
|
+
};
|
|
473
|
+
}
|
|
135
474
|
// ── 5. Poll ────────────────────────────────────────────────────────
|
|
475
|
+
ctx.status("[4/4] Building & deploying...");
|
|
136
476
|
const buildStart = Date.now();
|
|
137
477
|
const timeoutMs = 5 * 60 * 1000;
|
|
138
478
|
let lastStatus;
|
|
139
479
|
let nextHeartbeatMs = 15_000;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
480
|
+
// Heartbeat tracking lives in the caller because pollDeployment is a
|
|
481
|
+
// generic primitive. Only emit when the status actually changes, or
|
|
482
|
+
// every ~15s as a heartbeat with elapsed time. The previous loop
|
|
483
|
+
// printed "building..." every 2s for 90s on a Next.js cold build,
|
|
484
|
+
// which was 45 lines of zero signal.
|
|
485
|
+
let timedOut = null;
|
|
486
|
+
try {
|
|
487
|
+
deployment = await pollDeployment({
|
|
488
|
+
ctx,
|
|
489
|
+
appId: app.id,
|
|
490
|
+
initial: deployment,
|
|
491
|
+
intervalMs: 2000,
|
|
492
|
+
timeoutMs,
|
|
493
|
+
appName: app.name,
|
|
494
|
+
// FUTURE11 review P2: a concurrent newer deploy can mark this row
|
|
495
|
+
// `replaced` while we're still polling. The default terminal set
|
|
496
|
+
// (live/failed) wouldn't catch that, so we'd spin until the 5min
|
|
497
|
+
// timeout and surface a misleading "Deploy timed out". Treat
|
|
498
|
+
// replaced as terminal and branch on it after the poll returns.
|
|
499
|
+
terminal: ["live", "failed", "replaced"],
|
|
500
|
+
onTick: (d) => {
|
|
501
|
+
const elapsedMs = Date.now() - buildStart;
|
|
502
|
+
if (d.status !== lastStatus) {
|
|
503
|
+
ctx.status(`${d.status}...`);
|
|
504
|
+
lastStatus = d.status;
|
|
505
|
+
nextHeartbeatMs = elapsedMs + 15_000;
|
|
506
|
+
}
|
|
507
|
+
else if (elapsedMs >= nextHeartbeatMs) {
|
|
508
|
+
ctx.status(`${d.status}... (${Math.round(elapsedMs / 1000)}s elapsed)`);
|
|
509
|
+
nextHeartbeatMs = elapsedMs + 15_000;
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
// publish wants to return a structured PublishResult on timeout
|
|
513
|
+
// (not throw), so capture the in-flight deployment and break out
|
|
514
|
+
// via a sentinel object rather than letting the throw bubble.
|
|
515
|
+
onTimeout: (d) => {
|
|
516
|
+
timedOut = { deployment: d };
|
|
517
|
+
// biome-ignore lint/suspicious/noExplicitAny: sentinel re-thrown locally
|
|
518
|
+
throw new (class PollTimeoutSentinel extends Error {
|
|
519
|
+
})("__poll_timeout__");
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
if (!timedOut)
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
if (timedOut) {
|
|
528
|
+
const stuckDeployment = timedOut.deployment;
|
|
529
|
+
return {
|
|
530
|
+
status: "failed",
|
|
531
|
+
app,
|
|
532
|
+
deployment: stuckDeployment,
|
|
533
|
+
fileCount: tarball.fileCount,
|
|
534
|
+
bytes: tarball.bytes,
|
|
535
|
+
error: {
|
|
536
|
+
title: "Deploy timed out",
|
|
537
|
+
explanation: "The deployment did not complete within 5 minutes.",
|
|
538
|
+
suggestion: `Run \`percher doctor --app ${app.name}\` (CLI) or the percher_doctor tool (MCP) to diagnose the stall. Doctor will fetch the build log, classify the state, and return a concrete next step. The build may need optimization or there may be an infrastructure issue.`,
|
|
539
|
+
},
|
|
540
|
+
// FUTURE12 Phase 4: stalled deploy is ambiguous (still in
|
|
541
|
+
// flight, infra issue, slow build). Hand off to doctor with
|
|
542
|
+
// mode='deploy' so it can read deploy state + build log and
|
|
543
|
+
// decide between retry / inspect / ask_user — instead of
|
|
544
|
+
// pushing the agent straight at the raw build log.
|
|
545
|
+
recovery: recoveryDoctor({
|
|
546
|
+
app: app.name,
|
|
547
|
+
deployId: stuckDeployment.id,
|
|
548
|
+
mode: "deploy",
|
|
549
|
+
reasonCode: "deploy_stalled",
|
|
550
|
+
}),
|
|
551
|
+
summary: `Deploy timed out after 5 minutes (${stuckDeployment.id}).`,
|
|
552
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
553
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
554
|
+
};
|
|
172
555
|
}
|
|
173
556
|
const buildMs = Date.now() - buildStart;
|
|
174
557
|
// ── 6. Result ──────────────────────────────────────────────────────
|
|
558
|
+
if (deployment.status === "replaced") {
|
|
559
|
+
return await buildReplacedResult({
|
|
560
|
+
ctx,
|
|
561
|
+
app,
|
|
562
|
+
deployment,
|
|
563
|
+
tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
564
|
+
cwd: ctx.cwd,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
175
567
|
if (deployment.status === "failed") {
|
|
176
|
-
|
|
568
|
+
const failResult = await buildFailureResult({
|
|
177
569
|
ctx,
|
|
178
570
|
app,
|
|
179
571
|
deployment,
|
|
180
572
|
tarball: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
573
|
+
input,
|
|
181
574
|
});
|
|
575
|
+
if (missingBuildEnvKeys.length > 0) {
|
|
576
|
+
failResult.missingBuildEnvKeys = missingBuildEnvKeys;
|
|
577
|
+
}
|
|
578
|
+
return failResult;
|
|
182
579
|
}
|
|
183
580
|
const totalSeconds = (Date.now() - t0) / 1000;
|
|
184
581
|
const url = deployment.previewUrl ?? deployment.url ?? app.url;
|
|
@@ -194,6 +591,14 @@ async function publishInner(ctx, input) {
|
|
|
194
591
|
/* non-critical */
|
|
195
592
|
}
|
|
196
593
|
}
|
|
594
|
+
// Read cache state directly off the polled deployment row. The row
|
|
595
|
+
// is the source of truth (deploy-pipeline writes deployments.cache_hit
|
|
596
|
+
// = true on the same code path that aliases the cached image). The
|
|
597
|
+
// earlier implementation fetched /events and inferred a hit from the
|
|
598
|
+
// subStage='cache_hit' event — that worked, but lost the signal on
|
|
599
|
+
// any /events fetch failure or post-login retry path. Codex P2
|
|
600
|
+
// follow-up on 9e5ceee.
|
|
601
|
+
const cacheHit = deployment.cacheHit;
|
|
197
602
|
return {
|
|
198
603
|
status: "live",
|
|
199
604
|
url,
|
|
@@ -209,9 +614,24 @@ async function publishInner(ctx, input) {
|
|
|
209
614
|
fileCount: tarball.fileCount,
|
|
210
615
|
bytes: tarball.bytes,
|
|
211
616
|
replacedPreview,
|
|
617
|
+
firstDeploy: firstDeploy || undefined,
|
|
618
|
+
cacheHit,
|
|
619
|
+
missingBuildEnvKeys: missingBuildEnvKeys.length > 0 ? missingBuildEnvKeys : undefined,
|
|
620
|
+
recovery: RECOVERY_NONE,
|
|
621
|
+
summary: buildLiveSummary({
|
|
622
|
+
appName: app.name,
|
|
623
|
+
url: url ?? app.url,
|
|
624
|
+
totalSeconds: Math.round(totalSeconds),
|
|
625
|
+
framework: config.app.framework,
|
|
626
|
+
preview: input.preview === true,
|
|
627
|
+
replacedPreview,
|
|
628
|
+
cacheHit: cacheHit === true,
|
|
629
|
+
}),
|
|
630
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
631
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
212
632
|
};
|
|
213
633
|
}
|
|
214
|
-
async function handleUnauthorized(ctx, config, tarball) {
|
|
634
|
+
async function handleUnauthorized(ctx, config, tarball, input) {
|
|
215
635
|
// In MCP context we can't open a browser — return login instructions
|
|
216
636
|
if (!ctx.interactiveLogin) {
|
|
217
637
|
try {
|
|
@@ -226,6 +646,10 @@ async function handleUnauthorized(ctx, config, tarball) {
|
|
|
226
646
|
loginCode: userCode,
|
|
227
647
|
fileCount: tarball.fileCount,
|
|
228
648
|
bytes: tarball.bytes,
|
|
649
|
+
recovery: RECOVERY_NEEDS_LOGIN,
|
|
650
|
+
summary: `Login required: open ${loginUrl} (code: ${userCode}).`,
|
|
651
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
652
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
229
653
|
};
|
|
230
654
|
}
|
|
231
655
|
catch {
|
|
@@ -238,6 +662,10 @@ async function handleUnauthorized(ctx, config, tarball) {
|
|
|
238
662
|
explanation: "No valid Percher token found.",
|
|
239
663
|
suggestion: "Run the percher_login tool first, or set PERCHER_TOKEN environment variable.",
|
|
240
664
|
},
|
|
665
|
+
recovery: RECOVERY_NEEDS_LOGIN,
|
|
666
|
+
summary: "Login required — run percher_login or set PERCHER_TOKEN.",
|
|
667
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
668
|
+
bundle: { fileCount: tarball.fileCount, bytes: tarball.bytes },
|
|
241
669
|
};
|
|
242
670
|
}
|
|
243
671
|
}
|
|
@@ -250,14 +678,79 @@ async function handleUnauthorized(ctx, config, tarball) {
|
|
|
250
678
|
});
|
|
251
679
|
// Re-create tarball since stream is consumed
|
|
252
680
|
const freshTarball = await createTarball({ cwd: ctx.cwd, config });
|
|
253
|
-
|
|
681
|
+
let firstDeployRetry = false;
|
|
682
|
+
let app;
|
|
683
|
+
({ app, firstDeploy: firstDeployRetry } = await ensureApp(ctx, config));
|
|
254
684
|
ctx.status("Uploading...");
|
|
255
|
-
let deployment
|
|
256
|
-
|
|
257
|
-
|
|
685
|
+
let deployment;
|
|
686
|
+
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,
|
|
696
|
+
});
|
|
697
|
+
// FUTURE12 Phase 6d — same already_in_progress short-circuit as
|
|
698
|
+
// the main publishInner path. Without this branch, a user who
|
|
699
|
+
// hits a 401 mid-publish and re-auths could land on the
|
|
700
|
+
// post-login deploy step and have it silently fall through into
|
|
701
|
+
// a 5-minute polling loop against the existing deploy's row.
|
|
702
|
+
if (isDeployAlreadyInProgress(deployResponse)) {
|
|
703
|
+
return buildAlreadyInProgressResult({
|
|
704
|
+
active: deployResponse,
|
|
705
|
+
app,
|
|
706
|
+
tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
|
|
707
|
+
cwd: ctx.cwd,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
deployment = deployResponse;
|
|
711
|
+
}
|
|
712
|
+
catch (err) {
|
|
713
|
+
if (err instanceof PercherApiError && err.code === "RETRY_LIMIT_REACHED") {
|
|
714
|
+
return buildRetryLimitReachedResult({
|
|
715
|
+
err,
|
|
716
|
+
app,
|
|
717
|
+
tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
|
|
718
|
+
cwd: ctx.cwd,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
throw err;
|
|
722
|
+
}
|
|
723
|
+
const replacedPreview = deployment.replacedPreview === true;
|
|
724
|
+
// FUTURE11 Phase 1 — same async opt-in as the main publishInner path.
|
|
725
|
+
// Without this, a caller that passed `waitForLive: false` and hit a
|
|
726
|
+
// 401 (interactive login flow) would silently fall through to the
|
|
727
|
+
// 5-minute polling loop after login, breaking the contract that
|
|
728
|
+
// waitForLive=false always returns as soon as the deploy is queued.
|
|
729
|
+
if (input.waitForLive === false) {
|
|
730
|
+
ctx.status(`Queued (${deployment.id}) — resume with percher_wait_for_deploy.`);
|
|
731
|
+
return {
|
|
732
|
+
status: "queued",
|
|
733
|
+
app,
|
|
734
|
+
deployment,
|
|
735
|
+
fileCount: freshTarball.fileCount,
|
|
736
|
+
bytes: freshTarball.bytes,
|
|
737
|
+
replacedPreview: replacedPreview || undefined,
|
|
738
|
+
firstDeploy: firstDeployRetry || undefined,
|
|
739
|
+
recovery: recoveryWait({
|
|
740
|
+
app: app.name,
|
|
741
|
+
deployId: deployment.id,
|
|
742
|
+
reasonCode: "deploy_queued",
|
|
743
|
+
}),
|
|
744
|
+
summary: `${app.name} deploy ${deployment.id} queued — resume with percher_wait_for_deploy.`,
|
|
745
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
746
|
+
bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
|
|
747
|
+
};
|
|
748
|
+
}
|
|
258
749
|
const timeoutMs = 5 * 60 * 1000;
|
|
259
750
|
const start = Date.now();
|
|
260
|
-
while (deployment.status !== "live" &&
|
|
751
|
+
while (deployment.status !== "live" &&
|
|
752
|
+
deployment.status !== "failed" &&
|
|
753
|
+
deployment.status !== "replaced") {
|
|
261
754
|
if (Date.now() - start > timeoutMs) {
|
|
262
755
|
return {
|
|
263
756
|
status: "failed",
|
|
@@ -268,20 +761,41 @@ async function handleUnauthorized(ctx, config, tarball) {
|
|
|
268
761
|
error: {
|
|
269
762
|
title: "Deploy timed out",
|
|
270
763
|
explanation: "The deployment did not complete within 5 minutes.",
|
|
271
|
-
suggestion:
|
|
764
|
+
suggestion: `Run \`percher doctor --app ${app.name}\` (CLI) or the percher_doctor tool (MCP) to diagnose the stall. Doctor will read deploy state + build log and return a concrete next step.`,
|
|
272
765
|
},
|
|
766
|
+
// FUTURE12 Phase 4: ambiguous stall — let doctor classify
|
|
767
|
+
// before the agent dives into the raw log.
|
|
768
|
+
recovery: recoveryDoctor({
|
|
769
|
+
app: app.name,
|
|
770
|
+
deployId: deployment.id,
|
|
771
|
+
mode: "deploy",
|
|
772
|
+
reasonCode: "deploy_stalled",
|
|
773
|
+
}),
|
|
774
|
+
summary: `Deploy timed out after 5 minutes (${deployment.id}).`,
|
|
775
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
776
|
+
bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
|
|
273
777
|
};
|
|
274
778
|
}
|
|
275
779
|
ctx.status(`${deployment.status}...`);
|
|
276
780
|
await new Promise((r) => setTimeout(r, 2000));
|
|
277
781
|
deployment = await ctx.client.apps.getDeployment(app.id, deployment.id);
|
|
278
782
|
}
|
|
783
|
+
if (deployment.status === "replaced") {
|
|
784
|
+
return await buildReplacedResult({
|
|
785
|
+
ctx,
|
|
786
|
+
app,
|
|
787
|
+
deployment,
|
|
788
|
+
tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
|
|
789
|
+
cwd: ctx.cwd,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
279
792
|
if (deployment.status === "failed") {
|
|
280
793
|
return buildFailureResult({
|
|
281
794
|
ctx,
|
|
282
795
|
app,
|
|
283
796
|
deployment,
|
|
284
797
|
tarball: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
|
|
798
|
+
input,
|
|
285
799
|
});
|
|
286
800
|
}
|
|
287
801
|
const totalSeconds = (Date.now() - start) / 1000;
|
|
@@ -300,6 +814,26 @@ async function handleUnauthorized(ctx, config, tarball) {
|
|
|
300
814
|
},
|
|
301
815
|
fileCount: freshTarball.fileCount,
|
|
302
816
|
bytes: freshTarball.bytes,
|
|
817
|
+
replacedPreview,
|
|
818
|
+
firstDeploy: firstDeployRetry || undefined,
|
|
819
|
+
recovery: RECOVERY_NONE,
|
|
820
|
+
cacheHit: deployment.cacheHit,
|
|
821
|
+
summary: buildLiveSummary({
|
|
822
|
+
appName: app.name,
|
|
823
|
+
url: url ?? app.url,
|
|
824
|
+
totalSeconds: Math.round(totalSeconds),
|
|
825
|
+
framework: config.app.framework,
|
|
826
|
+
preview: input.preview === true,
|
|
827
|
+
replacedPreview,
|
|
828
|
+
// Read directly off the polled deployment row — same source of
|
|
829
|
+
// truth as the primary publish path. Codex P2 follow-up on
|
|
830
|
+
// 9e5ceee: the previous hardcoded `false` here meant the user
|
|
831
|
+
// who hit the post-login retry branch always saw "Build cache:
|
|
832
|
+
// miss" regardless of whether the build actually used the cache.
|
|
833
|
+
cacheHit: deployment.cacheHit ?? false,
|
|
834
|
+
}),
|
|
835
|
+
configPath: tomlPathFor(ctx.cwd),
|
|
836
|
+
bundle: { fileCount: freshTarball.fileCount, bytes: freshTarball.bytes },
|
|
303
837
|
};
|
|
304
838
|
}
|
|
305
839
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
@@ -313,54 +847,21 @@ async function loadOrGenerate(ctx) {
|
|
|
313
847
|
}
|
|
314
848
|
async function ensureApp(ctx, config) {
|
|
315
849
|
try {
|
|
316
|
-
|
|
850
|
+
const app = await ctx.client.apps.get(config.app.name);
|
|
851
|
+
return { app, firstDeploy: false };
|
|
317
852
|
}
|
|
318
853
|
catch (err) {
|
|
319
854
|
if (err.code === "APP_NOT_FOUND") {
|
|
320
|
-
|
|
855
|
+
const app = await ctx.client.apps.create({
|
|
321
856
|
name: config.app.name,
|
|
322
857
|
runtime: config.app.runtime,
|
|
323
858
|
framework: config.app.framework,
|
|
324
859
|
});
|
|
860
|
+
return { app, firstDeploy: true };
|
|
325
861
|
}
|
|
326
862
|
throw err;
|
|
327
863
|
}
|
|
328
864
|
}
|
|
329
|
-
/**
|
|
330
|
-
* Resolve which Node version will be used by the build, in priority order:
|
|
331
|
-
* 1. user `.nvmrc` (literal contents trimmed)
|
|
332
|
-
* 2. user `engines.node` from package.json
|
|
333
|
-
* 3. server-side default ("Node 22 LTS" — kept in sync with
|
|
334
|
-
* packages/api/src/nixpacks/build.ts:DEFAULT_NODE_VERSION)
|
|
335
|
-
* Returns null when this isn't a Node project (no package.json + no .nvmrc).
|
|
336
|
-
*/
|
|
337
|
-
function resolveNodeVersion(cwd) {
|
|
338
|
-
const nvmrc = join(cwd, ".nvmrc");
|
|
339
|
-
if (existsSync(nvmrc)) {
|
|
340
|
-
try {
|
|
341
|
-
const v = readFileSync(nvmrc, "utf8").trim();
|
|
342
|
-
if (v)
|
|
343
|
-
return { value: v, source: ".nvmrc" };
|
|
344
|
-
}
|
|
345
|
-
catch {
|
|
346
|
-
/* fall through */
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
const pkgPath = join(cwd, "package.json");
|
|
350
|
-
if (!existsSync(pkgPath))
|
|
351
|
-
return null;
|
|
352
|
-
try {
|
|
353
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
354
|
-
const engines = pkg?.engines?.node;
|
|
355
|
-
if (typeof engines === "string" && engines.length > 0) {
|
|
356
|
-
return { value: engines, source: "engines.node" };
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
catch {
|
|
360
|
-
/* fall through */
|
|
361
|
-
}
|
|
362
|
-
return { value: "22 LTS", source: "default" };
|
|
363
|
-
}
|
|
364
865
|
function emitPreflight(ctx, config, tarball) {
|
|
365
866
|
const lines = [];
|
|
366
867
|
const fw = config.app.framework ?? (config.app.runtime === "docker" ? "Dockerfile" : config.app.runtime);
|
|
@@ -387,72 +888,4 @@ function emitPreflight(ctx, config, tarball) {
|
|
|
387
888
|
ctx.status(line);
|
|
388
889
|
}
|
|
389
890
|
}
|
|
390
|
-
async function buildFailureResult(opts) {
|
|
391
|
-
const { ctx, app, deployment, tarball } = opts;
|
|
392
|
-
// Try to fetch build log for diagnosis. Pre-build infra failures (worker
|
|
393
|
-
// unreachable, nixpacks couldn't fetch from cache, tarball extraction
|
|
394
|
-
// crashed) leave buildLog empty — we have to fall back to the deployment's
|
|
395
|
-
// top-level errorMessage so the user isn't left staring at "see the log
|
|
396
|
-
// below" with nothing below it.
|
|
397
|
-
let buildLogTail;
|
|
398
|
-
let fullLog = "";
|
|
399
|
-
try {
|
|
400
|
-
fullLog = await ctx.client.apps.getBuildLog(app.id, deployment.id);
|
|
401
|
-
if (fullLog) {
|
|
402
|
-
const lines = fullLog.split("\n");
|
|
403
|
-
buildLogTail = lines.slice(-50).join("\n");
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
catch {
|
|
407
|
-
// Build log fetch can fail — that's fine, we still have a basic error
|
|
408
|
-
}
|
|
409
|
-
const errorMessage = deployment.errorMessage ?? "";
|
|
410
|
-
// Classify against the build log first (post-build failures are richer),
|
|
411
|
-
// then against errorMessage as a fallback for the pre-build / infra case.
|
|
412
|
-
const classified = classifyError("Deployment failed", fullLog) ?? classifyError(errorMessage, "");
|
|
413
|
-
// Always preserve the raw upstream string when we have one and there's
|
|
414
|
-
// no build log to anchor to. The classifier replaces it with a friendly
|
|
415
|
-
// template, but the actual error often carries the load-bearing detail
|
|
416
|
-
// (port number, hostname, HTTP status) that support needs to debug.
|
|
417
|
-
// Skip when it would just duplicate the explanation we're about to print.
|
|
418
|
-
const rawErrorMessage = errorMessage && !buildLogTail && !classified?.explanation.includes(errorMessage)
|
|
419
|
-
? errorMessage
|
|
420
|
-
: undefined;
|
|
421
|
-
const error = classified
|
|
422
|
-
? {
|
|
423
|
-
title: classified.title,
|
|
424
|
-
explanation: classified.explanation,
|
|
425
|
-
suggestion: classified.suggestion,
|
|
426
|
-
buildLogTail,
|
|
427
|
-
rawErrorMessage,
|
|
428
|
-
errorClass: classified.errorClass,
|
|
429
|
-
phase: classified.phase,
|
|
430
|
-
cause: classified.cause,
|
|
431
|
-
relevantFiles: classified.relevantFiles,
|
|
432
|
-
missingEnvVars: classified.missingEnvVars,
|
|
433
|
-
}
|
|
434
|
-
: {
|
|
435
|
-
title: "Deployment failed",
|
|
436
|
-
explanation: errorMessage
|
|
437
|
-
? `The deployment failed: ${errorMessage}`
|
|
438
|
-
: "The deployment failed. See the build log for details.",
|
|
439
|
-
suggestion: buildLogTail
|
|
440
|
-
? "Check the build log below for errors. Run your build command locally to reproduce, fix, and publish again."
|
|
441
|
-
: "No build log was produced — the failure happened before the build started. Run percher publish again; if it keeps failing with the same message, contact support.",
|
|
442
|
-
buildLogTail,
|
|
443
|
-
errorClass: "build_failed",
|
|
444
|
-
phase: "build",
|
|
445
|
-
cause: "unknown",
|
|
446
|
-
relevantFiles: [],
|
|
447
|
-
missingEnvVars: [],
|
|
448
|
-
};
|
|
449
|
-
return {
|
|
450
|
-
status: "failed",
|
|
451
|
-
app,
|
|
452
|
-
deployment,
|
|
453
|
-
fileCount: tarball.fileCount,
|
|
454
|
-
bytes: tarball.bytes,
|
|
455
|
-
error,
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
891
|
//# sourceMappingURL=publish.js.map
|