@offlocal/mcp 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.js +20 -0
- package/dist/detect.js +2 -2
- package/dist/index.js +12 -1
- package/dist/tools/deploy.js +40 -5
- package/dist/tools/plan.js +9 -2
- package/dist/tools/plans.js +25 -0
- package/dist/tools/providers.js +12 -4
- package/dist/tools/status.js +18 -3
- package/dist/zip.js +18 -3
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -157,6 +157,26 @@ export async function setProjectEnv(projectId, vars) {
|
|
|
157
157
|
return { error: json.error ?? String(res.status), message: json.message };
|
|
158
158
|
return json;
|
|
159
159
|
}
|
|
160
|
+
export async function rollback(projectId, toDeploymentId) {
|
|
161
|
+
const res = await fetch(`${API_URL}/api/projects/${encodeURIComponent(projectId)}/rollback`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "content-type": "application/json", ...authHeaders() },
|
|
164
|
+
body: JSON.stringify(toDeploymentId ? { toDeploymentId } : {}),
|
|
165
|
+
});
|
|
166
|
+
const json = (await res.json().catch(() => ({})));
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
return { error: json.error ?? String(res.status), message: json.message };
|
|
169
|
+
}
|
|
170
|
+
return json;
|
|
171
|
+
}
|
|
172
|
+
export async function getPlanInfo() {
|
|
173
|
+
const res = await fetch(`${API_URL}/api/me/plan`, {
|
|
174
|
+
headers: { ...authHeaders() },
|
|
175
|
+
});
|
|
176
|
+
if (!res.ok)
|
|
177
|
+
throw new Error(await failureText(res, "get_plan"));
|
|
178
|
+
return (await res.json());
|
|
179
|
+
}
|
|
160
180
|
async function failureText(res, kind) {
|
|
161
181
|
const body = await res.text().catch(() => "");
|
|
162
182
|
return `${kind} failed: ${res.status} ${res.statusText}${body ? ` - ${body.slice(0, 400)}` : ""}`;
|
package/dist/detect.js
CHANGED
|
@@ -313,6 +313,8 @@ function pickNodeFramework(deps) {
|
|
|
313
313
|
return "remix";
|
|
314
314
|
if ("astro" in deps)
|
|
315
315
|
return "astro";
|
|
316
|
+
if ("vite" in deps)
|
|
317
|
+
return "vite";
|
|
316
318
|
if ("fastify" in deps)
|
|
317
319
|
return "fastify";
|
|
318
320
|
if ("hono" in deps)
|
|
@@ -321,8 +323,6 @@ function pickNodeFramework(deps) {
|
|
|
321
323
|
return "express";
|
|
322
324
|
if ("koa" in deps)
|
|
323
325
|
return "koa";
|
|
324
|
-
if ("vite" in deps)
|
|
325
|
-
return "vite";
|
|
326
326
|
return "node";
|
|
327
327
|
}
|
|
328
328
|
function nodePort(framework) {
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { pingApi } from "./client.js";
|
|
6
|
-
import { DeployInputShape, deployHandler } from "./tools/deploy.js";
|
|
6
|
+
import { DeployInputShape, RollbackInputShape, deployHandler, rollbackHandler, } from "./tools/deploy.js";
|
|
7
|
+
import { PlansInputShape, plansHandler } from "./tools/plans.js";
|
|
7
8
|
import { StatusInputShape, statusHandler } from "./tools/status.js";
|
|
8
9
|
import { LogsInputShape, logsHandler } from "./tools/logs.js";
|
|
9
10
|
import { PingInputShape, pingHandler } from "./tools/ping.js";
|
|
@@ -21,6 +22,8 @@ const CreateManagedDatabaseSchema = z.object(CreateManagedDatabaseInputShape);
|
|
|
21
22
|
const SetEnvSchema = z.object(SetEnvInputShape);
|
|
22
23
|
const PlanSchema = z.object(PlanInputShape);
|
|
23
24
|
const SetPreferenceSchema = z.object(SetPreferenceInputShape);
|
|
25
|
+
const RollbackSchema = z.object(RollbackInputShape);
|
|
26
|
+
const PlansSchema = z.object(PlansInputShape);
|
|
24
27
|
async function main() {
|
|
25
28
|
const server = new McpServer({
|
|
26
29
|
name: "offlocal",
|
|
@@ -32,6 +35,8 @@ async function main() {
|
|
|
32
35
|
server.tool("offlocal_deploy", "Deploy an app from a local directory to Offlocal. Returns a deploymentId you can poll with offlocal_status. Supports Node, Python, Go, Rust, and static sites out of the box (auto-detecting runtime/framework/port), and uses the project's own Dockerfile as-is when one exists. For an unrecognized stack with no Dockerfile, it returns instructions asking you to add a Dockerfile and re-run - follow them, then call this again. You can override appName and port. Only call this when the user explicitly asks to deploy.", DeployInputShape, async (args) => deployHandler(DeploySchema.parse(args)));
|
|
33
36
|
server.tool("offlocal_status", "Get the current status of a deployment. Poll this every few seconds after offlocal_deploy until status is 'live' or 'failed'.", StatusInputShape, async (args) => statusHandler(StatusSchema.parse(args)));
|
|
34
37
|
server.tool("offlocal_logs", "Read recent container logs for a deployment. Useful for debugging a failed deploy or watching live output.", LogsInputShape, async (args) => logsHandler(LogsSchema.parse(args)));
|
|
38
|
+
server.tool("offlocal_rollback", "Roll a project back to a previous build. Re-deploys an existing image (skips the build, ~1 min), so it's fast and safe. Defaults to the most recent prior build; pass toDeploymentId to target a specific one. Use when a new deploy is broken or the user wants the last working version back.", RollbackInputShape, async (args) => rollbackHandler(RollbackSchema.parse(args)));
|
|
39
|
+
server.tool("offlocal_plans", "Show the user's current Offlocal.ai plan, the full tier catalog (prices, limits, features), and upcoming features. Use it to answer plan and pricing questions, and to show upgrade options when the user needs more capacity (projects, databases, custom domains). Includes the billing link.", PlansInputShape, async () => plansHandler());
|
|
35
40
|
server.tool("offlocal_resolve_requirement", "When a deploy needs an external resource (a database, hosting, etc.) that the project doesn't already have, call this with the capability (e.g. 'postgres') to get the providers that can supply it and their connection status. Present the options to the user and let them choose; the user can also just paste an existing connection string as an env var instead.", ResolveRequirementInputShape, async (args) => resolveRequirementHandler(ResolveRequirementSchema.parse(args)));
|
|
36
41
|
server.tool("offlocal_connect", "Get a browser link for the user to connect a chosen provider via OAuth. You CANNOT complete OAuth yourself - share the link, then poll offlocal_list_connections until the provider is 'connected'. Only call this if the user chose a provider that isn't connected yet.", ConnectInputShape, async (args) => connectHandler(ConnectSchema.parse(args)));
|
|
37
42
|
server.tool("offlocal_list_connections", "List the user's provider connections and their status. Use this to poll after offlocal_connect (no faster than every 5 seconds) until a provider becomes 'connected'.", ListConnectionsInputShape, async () => listConnectionsHandler());
|
|
@@ -39,6 +44,12 @@ async function main() {
|
|
|
39
44
|
server.tool("offlocal_provision_status", "Poll an in-progress provision (DigitalOcean/Railway) by its actionId until it's ready. Poll no faster than once every ~20 seconds.", ProvisionStatusInputShape, async (args) => provisionStatusHandler(ProvisionStatusSchema.parse(args)));
|
|
40
45
|
server.tool("offlocal_create_managed_database", "Create an Offlocal Managed Postgres database for a project (the 'Offlocal' platform option for postgres). Pass the projectId from offlocal_deploy. DATABASE_URL is wired into the project automatically; redeploy to apply. Requires a Hobby+ plan.", CreateManagedDatabaseInputShape, async (args) => createManagedDatabaseHandler(CreateManagedDatabaseSchema.parse(args)));
|
|
41
46
|
server.tool("offlocal_set_env", "Set environment variables on a project (stored encrypted, injected on the next deploy). Use this for cross-app wiring in a monorepo — e.g. set NEXT_PUBLIC_API_URL on the web project to the API project's live URL so the frontend can reach the backend. Build-time vars (NEXT_PUBLIC_*, VITE_*) require a fresh deploy to take effect.", SetEnvInputShape, async (args) => setEnvHandler(SetEnvSchema.parse(args)));
|
|
47
|
+
process.on("uncaughtException", (err) => {
|
|
48
|
+
console.error("offlocal-mcp uncaught exception (continuing):", err);
|
|
49
|
+
});
|
|
50
|
+
process.on("unhandledRejection", (reason) => {
|
|
51
|
+
console.error("offlocal-mcp unhandled rejection (continuing):", reason);
|
|
52
|
+
});
|
|
42
53
|
pingApi().catch(() => { });
|
|
43
54
|
const transport = new StdioServerTransport();
|
|
44
55
|
await server.connect(transport);
|
package/dist/tools/deploy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { createDeployment, markUploadComplete, uploadBundle, } from "../client.js";
|
|
2
|
+
import { createDeployment, markUploadComplete, rollback, uploadBundle, } from "../client.js";
|
|
3
3
|
import { detect } from "../detect.js";
|
|
4
|
-
import { ICON } from "../ui.js";
|
|
4
|
+
import { ICON, ui } from "../ui.js";
|
|
5
5
|
import { bundleDirectory } from "../zip.js";
|
|
6
6
|
export const DeployInputShape = {
|
|
7
7
|
sourceDir: z
|
|
@@ -43,10 +43,13 @@ export async function deployHandler(input) {
|
|
|
43
43
|
const buildSource = detected.strategy === "dockerfile"
|
|
44
44
|
? "your Dockerfile"
|
|
45
45
|
: `auto-generated Dockerfile (${detected.framework})`;
|
|
46
|
+
const slug = created.slug ?? appName;
|
|
47
|
+
const expectedUrl = created.expectedUrl ?? `https://${slug}.offlocal.ai`;
|
|
46
48
|
const summary = {
|
|
47
49
|
deploymentId: created.deploymentId,
|
|
48
50
|
projectId: created.projectId,
|
|
49
51
|
appName,
|
|
52
|
+
slug,
|
|
50
53
|
framework: detected.framework,
|
|
51
54
|
buildSource,
|
|
52
55
|
port,
|
|
@@ -55,7 +58,7 @@ export async function deployHandler(input) {
|
|
|
55
58
|
bytes: bundle.byteSize,
|
|
56
59
|
},
|
|
57
60
|
status: "uploaded",
|
|
58
|
-
expectedUrl
|
|
61
|
+
expectedUrl,
|
|
59
62
|
pollAfterSeconds: 45,
|
|
60
63
|
expectedTotalSeconds: 180,
|
|
61
64
|
};
|
|
@@ -69,19 +72,51 @@ export async function deployHandler(input) {
|
|
|
69
72
|
`| ${ICON.spark} Framework | ${summary.framework} (${summary.buildSource}) |\n` +
|
|
70
73
|
`| Port | ${summary.port} |\n` +
|
|
71
74
|
`| Bundle | ${summary.bundle.fileCount} files · ${summary.bundle.bytes} bytes |\n\n` +
|
|
75
|
+
(bundle.warnings.length
|
|
76
|
+
? bundle.warnings.map((w) => `${ICON.warn} ${w}`).join("\n") + "\n\n"
|
|
77
|
+
: "") +
|
|
72
78
|
`———\n` +
|
|
73
|
-
`${ICON.arrow} **Next (you, the agent):** tell the user it's building (2–
|
|
79
|
+
`${ICON.arrow} **Next (you, the agent):** tell the user it's building (usually 2–8 min) and you'll ` +
|
|
74
80
|
`report back when it's live. Then:\n` +
|
|
75
81
|
` 1. Sleep 45 seconds.\n` +
|
|
76
82
|
` 2. \`offlocal_status({ deploymentId: "${summary.deploymentId}" })\`.\n` +
|
|
77
83
|
` 3. Sleep the \`pollAfterSeconds\` it returns, then call status again — exactly once per interval.\n` +
|
|
78
|
-
` 4. Repeat until \`live\` or \`failed\`.
|
|
84
|
+
` 4. Repeat until \`live\` or \`failed\`. **Slow is normal — don't panic or declare failure on a slow deploy.** ` +
|
|
85
|
+
`The status tool tells you if it's genuinely stuck; only then should you stop and suggest a retry.\n` +
|
|
79
86
|
`Keep the **projectId** from the live status for any database step.`,
|
|
80
87
|
},
|
|
81
88
|
],
|
|
82
89
|
structuredContent: summary,
|
|
83
90
|
};
|
|
84
91
|
}
|
|
92
|
+
export const RollbackInputShape = {
|
|
93
|
+
projectId: z
|
|
94
|
+
.string()
|
|
95
|
+
.min(1)
|
|
96
|
+
.describe("The Offlocal projectId to roll back (from offlocal_deploy / offlocal_status)."),
|
|
97
|
+
toDeploymentId: z
|
|
98
|
+
.string()
|
|
99
|
+
.optional()
|
|
100
|
+
.describe("Optional: a specific previous deploymentId to roll back to. Omit to roll back to the most recent prior build."),
|
|
101
|
+
};
|
|
102
|
+
export async function rollbackHandler(input) {
|
|
103
|
+
const res = await rollback(input.projectId, input.toDeploymentId);
|
|
104
|
+
if (res.error || !res.deployment) {
|
|
105
|
+
const hint = res.error === "no_rollback_target" || res.error === "deployment_not_found"
|
|
106
|
+
? " There's no earlier build with an image to roll back to (this project may only have one successful deploy)."
|
|
107
|
+
: "";
|
|
108
|
+
return ui({
|
|
109
|
+
show: `${ICON.fail} Rollback failed: ${res.message ?? res.error}.${hint}`,
|
|
110
|
+
structured: res,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const d = res.deployment;
|
|
114
|
+
return ui({
|
|
115
|
+
show: `${ICON.spark} **Rolling back** to the build from \`${res.rolledBackTo}\` — re-deploying that image (skips the build, ~1 min).`,
|
|
116
|
+
next: `Poll \`offlocal_status({ deploymentId: "${d.id}" })\` until it's \`live\`. It reuses the previous image, so there's no rebuild.`,
|
|
117
|
+
structured: { deployment: d, rolledBackTo: res.rolledBackTo ?? null },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
85
120
|
function needsDockerfile(sourceDir, port) {
|
|
86
121
|
const summary = {
|
|
87
122
|
status: "needs_dockerfile",
|
package/dist/tools/plan.js
CHANGED
|
@@ -116,6 +116,9 @@ export async function planHandler(input) {
|
|
|
116
116
|
(requirements.length
|
|
117
117
|
? ` + ${requirements.map((r) => r.reason).join(", ")}`
|
|
118
118
|
: "");
|
|
119
|
+
const hasDatastore = requirements.some((r) => r.capability === "postgres" || r.capability === "mysql");
|
|
120
|
+
const hasServerComponent = components.some((c) => c.role !== "web");
|
|
121
|
+
const noteEphemeral = !hasDatastore && hasServerComponent;
|
|
119
122
|
const show = `${ICON.search} **Offlocal.ai — deployment plan**\n\n` +
|
|
120
123
|
`**Detected:** ${detectedLine}\n\n` +
|
|
121
124
|
`**Plan**\n${planLines.join("\n")}\n` +
|
|
@@ -123,9 +126,13 @@ export async function planHandler(input) {
|
|
|
123
126
|
`\n**Setup checklist** ${progressBar(0, checklist.length)}\n${checklistLines.join("\n")}\n\n` +
|
|
124
127
|
`Reply **"go"** to ship it ${ICON.rocket} — or switch any line ` +
|
|
125
128
|
`(e.g. _"use Supabase for the database"_, _"host the API on Railway"_).`;
|
|
129
|
+
const persistenceCheck = noteEphemeral
|
|
130
|
+
? `\n\nNote: this host's storage is ephemeral. Mention data persistence only if the user asks about keeping data between deploys — a database covers it.`
|
|
131
|
+
: "";
|
|
126
132
|
const agentNote = `When the user switches a line, call \`offlocal_set_preference\` ` +
|
|
127
|
-
`(capability \`${multi ? "hosting:<component> or postgres/…" : "hosting/postgres/…"}\`) then re-run \`offlocal_plan
|
|
128
|
-
|
|
133
|
+
`(capability \`${multi ? "hosting:<component> or postgres/…" : "hosting/postgres/…"}\`) then re-run \`offlocal_plan\`.` +
|
|
134
|
+
persistenceCheck +
|
|
135
|
+
`\n\n**Execution order once they confirm — walk the checklist top to bottom, ` +
|
|
129
136
|
`re-showing it with ${ICON.done} on finished steps so they see live progress:**\n` +
|
|
130
137
|
executionSteps(items, links, dbTarget).join("\n");
|
|
131
138
|
return ui({
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getPlanInfo } from "../client.js";
|
|
2
|
+
import { ICON, ui } from "../ui.js";
|
|
3
|
+
export const PlansInputShape = {};
|
|
4
|
+
export async function plansHandler() {
|
|
5
|
+
const info = await getPlanInfo();
|
|
6
|
+
const tierLines = info.plans.map((p) => {
|
|
7
|
+
const isCurrent = p.plan === info.plan;
|
|
8
|
+
const head = isCurrent
|
|
9
|
+
? `${ICON.connected} **${p.label} — ${p.priceLabel}** _(your plan)_`
|
|
10
|
+
: `${ICON.open} **${p.label} — ${p.priceLabel}**`;
|
|
11
|
+
const projects = `${p.projects} project${p.projects === 1 ? "" : "s"}`;
|
|
12
|
+
return `${head}\n ${projects} · ${p.features.slice(0, 4).join(" · ")}`;
|
|
13
|
+
});
|
|
14
|
+
const upcoming = info.upcoming.length
|
|
15
|
+
? `\n\n${ICON.soon} **On the roadmap:** ${info.upcoming.join(" · ")}`
|
|
16
|
+
: "";
|
|
17
|
+
return ui({
|
|
18
|
+
show: `${ICON.spark} **Offlocal.ai plans**\n\n` +
|
|
19
|
+
`You're on **${info.planLabel}** — using ${info.usage.projects}/${info.limits.projects} projects, ${info.limits.managedDbs} managed database${info.limits.managedDbs === 1 ? "" : "s"} included.\n\n` +
|
|
20
|
+
tierLines.join("\n\n") +
|
|
21
|
+
upcoming,
|
|
22
|
+
next: `Use this to answer the user's plan and pricing questions. If they need more capacity (projects, databases, custom domains), share ${info.billingUrl} where they can change plans.`,
|
|
23
|
+
structured: { ...info },
|
|
24
|
+
});
|
|
25
|
+
}
|
package/dist/tools/providers.js
CHANGED
|
@@ -110,7 +110,10 @@ export async function connectHandler(input) {
|
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
112
|
export const ProvisionInputShape = {
|
|
113
|
-
provider: z
|
|
113
|
+
provider: z
|
|
114
|
+
.string()
|
|
115
|
+
.min(1)
|
|
116
|
+
.describe("The CONNECTED external provider to provision on: supabase, planetscale, digitalocean, or railway. For Offlocal's own managed Postgres, use offlocal_create_managed_database instead — not this."),
|
|
114
117
|
type: z
|
|
115
118
|
.enum(["project", "database", "deployment", "env_vars", "branch"])
|
|
116
119
|
.describe("The kind of resource to create."),
|
|
@@ -131,6 +134,11 @@ export const ProvisionInputShape = {
|
|
|
131
134
|
};
|
|
132
135
|
export async function provisionHandler(input) {
|
|
133
136
|
const { provider, confirm, projectId, ...spec } = input;
|
|
137
|
+
if (["offlocal", "offlocal.ai"].includes(provider.toLowerCase())) {
|
|
138
|
+
return text(`${ICON.warn} "offlocal" isn't a provision provider. For an Offlocal Managed Postgres database, call ` +
|
|
139
|
+
`\`offlocal_create_managed_database({ projectId })\`. \`offlocal_provision\` is only for connected external ` +
|
|
140
|
+
`providers: **supabase, planetscale, digitalocean, railway**.`);
|
|
141
|
+
}
|
|
134
142
|
const res = await provisionResource(provider, spec, {
|
|
135
143
|
confirm: confirm ?? false,
|
|
136
144
|
projectId,
|
|
@@ -213,10 +221,10 @@ export async function setEnvHandler(input) {
|
|
|
213
221
|
export async function createManagedDatabaseHandler(input) {
|
|
214
222
|
const res = await createManagedDatabase(input.projectId);
|
|
215
223
|
if (res.error) {
|
|
216
|
-
const
|
|
217
|
-
? "
|
|
224
|
+
const planHint = res.error === "plan_not_allowed" || res.error === "limit_reached"
|
|
225
|
+
? " A Hobby plan or higher is required, or the database limit was reached — plans can be changed at /billing."
|
|
218
226
|
: "";
|
|
219
|
-
return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${
|
|
227
|
+
return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${planHint}`, res);
|
|
220
228
|
}
|
|
221
229
|
return text(`${ICON.db} **Offlocal Managed Postgres created** (${res.database?.status}). ` +
|
|
222
230
|
`${ICON.spark} DATABASE_URL is wired into this project automatically — **redeploy** to apply.`, res);
|
package/dist/tools/status.js
CHANGED
|
@@ -24,6 +24,9 @@ export async function statusHandler(input) {
|
|
|
24
24
|
const isTerminal = dep.status === "live" || dep.status === "failed";
|
|
25
25
|
const pollAfterSeconds = isTerminal ? 0 : pollIntervalFor(dep.status);
|
|
26
26
|
const etaSeconds = isTerminal ? 0 : etaFor(dep.status);
|
|
27
|
+
const secondsSinceUpdate = Math.max(0, Math.round((Date.now() - new Date(dep.updatedAt).getTime()) / 1000));
|
|
28
|
+
const STUCK_AFTER = 9 * 60;
|
|
29
|
+
const stalled = !isTerminal && secondsSinceUpdate > STUCK_AFTER;
|
|
27
30
|
const summary = {
|
|
28
31
|
deploymentId: dep.id,
|
|
29
32
|
projectId: dep.projectId,
|
|
@@ -32,6 +35,8 @@ export async function statusHandler(input) {
|
|
|
32
35
|
isTerminal,
|
|
33
36
|
pollAfterSeconds,
|
|
34
37
|
etaSeconds,
|
|
38
|
+
secondsSinceUpdate,
|
|
39
|
+
stalled,
|
|
35
40
|
liveUrl: dep.liveUrl,
|
|
36
41
|
errorType: dep.errorType,
|
|
37
42
|
errorMessage: dep.errorMessage,
|
|
@@ -61,9 +66,19 @@ export async function statusHandler(input) {
|
|
|
61
66
|
lines.push("");
|
|
62
67
|
lines.push(bar);
|
|
63
68
|
lines.push("");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
if (stalled) {
|
|
70
|
+
lines.push(`${ICON.warn} It's been at "${phase}" for ~${Math.round(secondsSinceUpdate / 60)} min with no change — ` +
|
|
71
|
+
`longer than a normal deploy. It may be stuck.\n` +
|
|
72
|
+
`Tell the user it's taking unusually long. You can wait a little longer, but do NOT keep polling indefinitely — ` +
|
|
73
|
+
`if it doesn't move within another couple of minutes, suggest re-running \`offlocal_deploy\` (a retry usually clears a stuck deploy).`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
lines.push(`Building / rolling out — **this is normal and can take 2–8 minutes** (occasionally a bit more). Stay calm and don't treat slowness as failure:\n` +
|
|
77
|
+
`• A deploy is only failed when the status is literally **failed** — nothing else means it broke.\n` +
|
|
78
|
+
`• "starting container" can mean the container is already healthy but not yet promoted; it'll flip to **live** shortly.\n` +
|
|
79
|
+
`• Empty \`offlocal_logs\` during build / before live is expected (container logs only exist once it's running) — not an error.\n` +
|
|
80
|
+
`Sleep ${pollAfterSeconds}s, then call this exactly once. Polling faster does not help.`);
|
|
81
|
+
}
|
|
67
82
|
}
|
|
68
83
|
return {
|
|
69
84
|
content: [{ type: "text", text: lines.join("\n") }],
|
package/dist/zip.js
CHANGED
|
@@ -20,13 +20,16 @@ const IGNORED_DIRS = new Set([
|
|
|
20
20
|
".idea",
|
|
21
21
|
".vscode",
|
|
22
22
|
]);
|
|
23
|
-
const IGNORED_FILE_RE = /^(\.env(\..+)
|
|
23
|
+
const IGNORED_FILE_RE = /^(\.env(\..+)?|\.DS_Store|Thumbs\.db)$|\.(log|tsbuildinfo|exe|dll|dylib|so|msi|dmg|pdb|class|o|obj|lib)$/i;
|
|
24
24
|
const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
|
25
25
|
const MAX_BUNDLE_BYTES = 250 * 1024 * 1024;
|
|
26
|
+
const WARN_BUNDLE_BYTES = 25 * 1024 * 1024;
|
|
27
|
+
const WARN_FILE_BYTES = 5 * 1024 * 1024;
|
|
26
28
|
export async function bundleDirectory(sourceDir) {
|
|
27
29
|
const zip = new AdmZip();
|
|
28
30
|
let fileCount = 0;
|
|
29
31
|
let totalBytes = 0;
|
|
32
|
+
const bigFiles = [];
|
|
30
33
|
await walk(sourceDir, async (absolute) => {
|
|
31
34
|
const rel = relative(sourceDir, absolute);
|
|
32
35
|
if (!rel)
|
|
@@ -42,14 +45,26 @@ export async function bundleDirectory(sourceDir) {
|
|
|
42
45
|
zip.addFile(zipPath, content);
|
|
43
46
|
fileCount++;
|
|
44
47
|
totalBytes += st.size;
|
|
48
|
+
if (st.size > WARN_FILE_BYTES)
|
|
49
|
+
bigFiles.push({ rel: zipPath, bytes: st.size });
|
|
45
50
|
if (totalBytes > MAX_BUNDLE_BYTES) {
|
|
46
|
-
throw new Error(`bundle exceeds ${MAX_BUNDLE_BYTES} bytes. Trim node_modules-like outputs or add a .
|
|
51
|
+
throw new Error(`bundle exceeds ${MAX_BUNDLE_BYTES} bytes. Trim node_modules-like outputs or add a .gitignore.`);
|
|
47
52
|
}
|
|
48
53
|
});
|
|
49
54
|
if (fileCount === 0) {
|
|
50
55
|
throw new Error(`no files to bundle under ${sourceDir}`);
|
|
51
56
|
}
|
|
52
|
-
|
|
57
|
+
const warnings = [];
|
|
58
|
+
if (totalBytes > WARN_BUNDLE_BYTES) {
|
|
59
|
+
warnings.push(`Bundle is ${mb(totalBytes)} — large for a deploy. Add a .gitignore for build output/binaries you don't need to ship.`);
|
|
60
|
+
}
|
|
61
|
+
for (const f of bigFiles.sort((a, b) => b.bytes - a.bytes).slice(0, 3)) {
|
|
62
|
+
warnings.push(`Large file bundled: ${f.rel} (${mb(f.bytes)}). Gitignore it if it isn't needed at runtime.`);
|
|
63
|
+
}
|
|
64
|
+
return { buffer: zip.toBuffer(), fileCount, byteSize: totalBytes, warnings };
|
|
65
|
+
}
|
|
66
|
+
function mb(bytes) {
|
|
67
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
53
68
|
}
|
|
54
69
|
async function walk(root, onFile) {
|
|
55
70
|
const stack = [root];
|
package/package.json
CHANGED