@offlocal/mcp 0.3.0 → 0.3.2
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 +21 -2
- package/dist/detect.js +32 -3
- package/dist/index.js +12 -2
- package/dist/tools/deploy.js +10 -2
- package/dist/tools/ping.js +22 -2
- package/dist/tools/providers.js +10 -2
- package/dist/tools/status.js +22 -3
- package/dist/version.js +9 -0
- package/dist/zip.js +18 -3
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { MCP_VERSION } from "./version.js";
|
|
1
2
|
const API_URL = process.env.OFFLOCAL_API_URL?.replace(/\/+$/, "") ?? "http://localhost:4000";
|
|
2
3
|
const AGENT_KEY = process.env.OFFLOCAL_AGENT_KEY ?? "";
|
|
3
4
|
function authHeaders() {
|
|
4
|
-
|
|
5
|
+
const headers = {
|
|
6
|
+
"x-offlocal-mcp-version": MCP_VERSION,
|
|
7
|
+
};
|
|
8
|
+
if (AGENT_KEY)
|
|
9
|
+
headers.Authorization = `Bearer ${AGENT_KEY}`;
|
|
10
|
+
return headers;
|
|
5
11
|
}
|
|
6
12
|
export async function pingApi() {
|
|
7
13
|
if (!AGENT_KEY) {
|
|
@@ -14,6 +20,15 @@ export async function pingApi() {
|
|
|
14
20
|
headers: { "content-type": "application/json", ...authHeaders() },
|
|
15
21
|
body: "{}",
|
|
16
22
|
});
|
|
23
|
+
if (res.status === 426) {
|
|
24
|
+
const body = await res.text().catch(() => "");
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
mustUpdate: true,
|
|
28
|
+
reason: body.slice(0, 300) ||
|
|
29
|
+
"This Offlocal MCP version is no longer supported. Update with: npx -y @offlocal/mcp@latest",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
17
32
|
if (!res.ok) {
|
|
18
33
|
const body = await res.text().catch(() => "");
|
|
19
34
|
return {
|
|
@@ -22,7 +37,11 @@ export async function pingApi() {
|
|
|
22
37
|
};
|
|
23
38
|
}
|
|
24
39
|
const data = (await res.json());
|
|
25
|
-
return {
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
user: data.user,
|
|
43
|
+
updateAvailable: res.headers.get("x-offlocal-mcp-update"),
|
|
44
|
+
};
|
|
26
45
|
}
|
|
27
46
|
catch (err) {
|
|
28
47
|
const message = err instanceof Error ? err.message : String(err);
|
package/dist/detect.js
CHANGED
|
@@ -124,6 +124,7 @@ const WEB_FRAMEWORKS = new Set([
|
|
|
124
124
|
"remix",
|
|
125
125
|
"astro",
|
|
126
126
|
"vite",
|
|
127
|
+
"cra",
|
|
127
128
|
"static",
|
|
128
129
|
]);
|
|
129
130
|
const API_FRAMEWORKS = new Set(["fastify", "hono", "express", "koa", "nestjs"]);
|
|
@@ -214,6 +215,8 @@ export function apiUrlEnvVar(framework) {
|
|
|
214
215
|
return { key: "NEXT_PUBLIC_API_URL", buildTime: true };
|
|
215
216
|
case "vite":
|
|
216
217
|
return { key: "VITE_API_URL", buildTime: true };
|
|
218
|
+
case "cra":
|
|
219
|
+
return { key: "REACT_APP_API_URL", buildTime: true };
|
|
217
220
|
case "nuxt":
|
|
218
221
|
return { key: "NUXT_PUBLIC_API_URL", buildTime: true };
|
|
219
222
|
case "astro":
|
|
@@ -279,9 +282,12 @@ async function detectPython(sourceDir, appName) {
|
|
|
279
282
|
const hasManage = await fileExists(join(sourceDir, "manage.py"));
|
|
280
283
|
let framework = "python";
|
|
281
284
|
let startCommand = "python app.py";
|
|
285
|
+
const notes = [];
|
|
282
286
|
if (hasManage || manifest.includes("django")) {
|
|
283
287
|
framework = "django";
|
|
284
|
-
|
|
288
|
+
const wsgi = await findDjangoWsgiModule(sourceDir);
|
|
289
|
+
startCommand = `python manage.py migrate --noinput && gunicorn ${wsgi}:application --bind 0.0.0.0:$PORT`;
|
|
290
|
+
notes.push("Django runs under gunicorn (production) and migrates on start. IMPORTANT: a default project's `ALLOWED_HOSTS` is empty and will 400 every request. Make settings.py read it from the environment — e.g. `ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')` — and offlocal will set ALLOWED_HOSTS to the live host automatically. Use a real database (SQLite resets on every cold start), and run `collectstatic` in a build step if you serve static files.");
|
|
285
291
|
}
|
|
286
292
|
else if (manifest.includes("fastapi")) {
|
|
287
293
|
framework = "fastapi";
|
|
@@ -302,8 +308,27 @@ async function detectPython(sourceDir, appName) {
|
|
|
302
308
|
startCommand,
|
|
303
309
|
port: 8000,
|
|
304
310
|
strategy: "template",
|
|
311
|
+
notes: notes.length ? notes : undefined,
|
|
305
312
|
};
|
|
306
313
|
}
|
|
314
|
+
async function findDjangoWsgiModule(sourceDir) {
|
|
315
|
+
const manage = await readTextSafe(join(sourceDir, "manage.py"));
|
|
316
|
+
const m = /DJANGO_SETTINGS_MODULE['"]\s*,\s*['"]([\w.]+)\.settings['"]/.exec(manage);
|
|
317
|
+
if (m?.[1])
|
|
318
|
+
return `${m[1]}.wsgi`;
|
|
319
|
+
try {
|
|
320
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
321
|
+
for (const e of entries) {
|
|
322
|
+
if (e.isDirectory() &&
|
|
323
|
+
(await fileExists(join(sourceDir, e.name, "wsgi.py")))) {
|
|
324
|
+
return `${e.name}.wsgi`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
}
|
|
330
|
+
return "wsgi";
|
|
331
|
+
}
|
|
307
332
|
function pickNodeFramework(deps) {
|
|
308
333
|
if ("next" in deps)
|
|
309
334
|
return "nextjs";
|
|
@@ -313,6 +338,10 @@ function pickNodeFramework(deps) {
|
|
|
313
338
|
return "remix";
|
|
314
339
|
if ("astro" in deps)
|
|
315
340
|
return "astro";
|
|
341
|
+
if ("vite" in deps)
|
|
342
|
+
return "vite";
|
|
343
|
+
if ("react-scripts" in deps || "@craco/craco" in deps)
|
|
344
|
+
return "cra";
|
|
316
345
|
if ("fastify" in deps)
|
|
317
346
|
return "fastify";
|
|
318
347
|
if ("hono" in deps)
|
|
@@ -321,8 +350,6 @@ function pickNodeFramework(deps) {
|
|
|
321
350
|
return "express";
|
|
322
351
|
if ("koa" in deps)
|
|
323
352
|
return "koa";
|
|
324
|
-
if ("vite" in deps)
|
|
325
|
-
return "vite";
|
|
326
353
|
return "node";
|
|
327
354
|
}
|
|
328
355
|
function nodePort(framework) {
|
|
@@ -331,6 +358,8 @@ function nodePort(framework) {
|
|
|
331
358
|
return 4321;
|
|
332
359
|
case "vite":
|
|
333
360
|
return 4173;
|
|
361
|
+
case "cra":
|
|
362
|
+
return 80;
|
|
334
363
|
default:
|
|
335
364
|
return 3000;
|
|
336
365
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ 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 { MCP_VERSION } from "./version.js";
|
|
6
7
|
import { DeployInputShape, RollbackInputShape, deployHandler, rollbackHandler, } from "./tools/deploy.js";
|
|
7
8
|
import { PlansInputShape, plansHandler } from "./tools/plans.js";
|
|
8
9
|
import { StatusInputShape, statusHandler } from "./tools/status.js";
|
|
@@ -27,7 +28,7 @@ const PlansSchema = z.object(PlansInputShape);
|
|
|
27
28
|
async function main() {
|
|
28
29
|
const server = new McpServer({
|
|
29
30
|
name: "offlocal",
|
|
30
|
-
version:
|
|
31
|
+
version: MCP_VERSION,
|
|
31
32
|
});
|
|
32
33
|
server.tool("offlocal_ping", "Verify the Offlocal connection. Returns the signed-in email if connected, or an error message if not. This tool does NOT deploy anything, upload anything, or provision any resources - it is the right tool to call when the user asks to 'check my Offlocal connection' or similar.", PingInputShape, async (args) => pingHandler(PingSchema.parse(args)));
|
|
33
34
|
server.tool("offlocal_plan", "START HERE when the user asks to deploy/ship/set up a project. Detects the app + the resources it needs (e.g. a Postgres database), applies the user's saved platform preferences, and returns a complete proposed plan (which platform for each piece — Offlocal.ai by default, or Supabase/PlanetScale/DigitalOcean/Railway). Present the plan to the user verbatim and let them confirm with 'go' or switch any line. Then execute the steps the plan lists. Do NOT deploy or provision anything until the user confirms the plan.", PlanInputShape, async (args) => planHandler(PlanSchema.parse(args)));
|
|
@@ -50,7 +51,16 @@ async function main() {
|
|
|
50
51
|
process.on("unhandledRejection", (reason) => {
|
|
51
52
|
console.error("offlocal-mcp unhandled rejection (continuing):", reason);
|
|
52
53
|
});
|
|
53
|
-
pingApi()
|
|
54
|
+
pingApi()
|
|
55
|
+
.then((r) => {
|
|
56
|
+
if (!r.ok && r.mustUpdate) {
|
|
57
|
+
console.error(`offlocal-mcp: ${r.reason}`);
|
|
58
|
+
}
|
|
59
|
+
else if (r.ok && r.updateAvailable) {
|
|
60
|
+
console.error(`offlocal-mcp: a newer version (v${r.updateAvailable}) is available — update with: npx -y @offlocal/mcp@latest`);
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.catch(() => { });
|
|
54
64
|
const transport = new StdioServerTransport();
|
|
55
65
|
await server.connect(transport);
|
|
56
66
|
}
|
package/dist/tools/deploy.js
CHANGED
|
@@ -72,13 +72,21 @@ export async function deployHandler(input) {
|
|
|
72
72
|
`| ${ICON.spark} Framework | ${summary.framework} (${summary.buildSource}) |\n` +
|
|
73
73
|
`| Port | ${summary.port} |\n` +
|
|
74
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
|
+
: "") +
|
|
78
|
+
(detected.notes?.length
|
|
79
|
+
? detected.notes.map((n) => `${ICON.warn} ${n}`).join("\n\n") +
|
|
80
|
+
"\n\n"
|
|
81
|
+
: "") +
|
|
75
82
|
`———\n` +
|
|
76
|
-
`${ICON.arrow} **Next (you, the agent):** tell the user it's building (2–
|
|
83
|
+
`${ICON.arrow} **Next (you, the agent):** tell the user it's building (usually 2–8 min) and you'll ` +
|
|
77
84
|
`report back when it's live. Then:\n` +
|
|
78
85
|
` 1. Sleep 45 seconds.\n` +
|
|
79
86
|
` 2. \`offlocal_status({ deploymentId: "${summary.deploymentId}" })\`.\n` +
|
|
80
87
|
` 3. Sleep the \`pollAfterSeconds\` it returns, then call status again — exactly once per interval.\n` +
|
|
81
|
-
` 4. Repeat until \`live\` or \`failed\`.
|
|
88
|
+
` 4. Repeat until \`live\` or \`failed\`. **Slow is normal — don't panic or declare failure on a slow deploy.** ` +
|
|
89
|
+
`The status tool tells you if it's genuinely stuck; only then should you stop and suggest a retry.\n` +
|
|
82
90
|
`Keep the **projectId** from the live status for any database step.`,
|
|
83
91
|
},
|
|
84
92
|
],
|
package/dist/tools/ping.js
CHANGED
|
@@ -4,6 +4,19 @@ export const PingInputShape = {};
|
|
|
4
4
|
export async function pingHandler(_input) {
|
|
5
5
|
const result = await pingApi();
|
|
6
6
|
if (!result.ok) {
|
|
7
|
+
if (result.mustUpdate) {
|
|
8
|
+
return {
|
|
9
|
+
content: [
|
|
10
|
+
{
|
|
11
|
+
type: "text",
|
|
12
|
+
text: `⚠️ Your Offlocal MCP is out of date and no longer supported.\n\n` +
|
|
13
|
+
`${result.reason}\n\n` +
|
|
14
|
+
"Update it with: npx -y @offlocal/mcp@latest (then restart the agent).",
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
structuredContent: { connected: false, mustUpdate: true, reason: result.reason },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
7
20
|
return {
|
|
8
21
|
content: [
|
|
9
22
|
{
|
|
@@ -20,14 +33,21 @@ export async function pingHandler(_input) {
|
|
|
20
33
|
};
|
|
21
34
|
}
|
|
22
35
|
const email = result.user?.email ?? "(unknown)";
|
|
36
|
+
const updateNote = result.updateAvailable
|
|
37
|
+
? `\n\nℹ️ A newer Offlocal MCP (v${result.updateAvailable}) is available — update with: npx -y @offlocal/mcp@latest`
|
|
38
|
+
: "";
|
|
23
39
|
return {
|
|
24
40
|
content: [
|
|
25
41
|
{
|
|
26
42
|
type: "text",
|
|
27
|
-
text: `Connected to Offlocal as ${email}
|
|
43
|
+
text: `Connected to Offlocal as ${email}.${updateNote}`,
|
|
28
44
|
},
|
|
29
45
|
],
|
|
30
|
-
structuredContent: {
|
|
46
|
+
structuredContent: {
|
|
47
|
+
connected: true,
|
|
48
|
+
email,
|
|
49
|
+
updateAvailable: result.updateAvailable ?? null,
|
|
50
|
+
},
|
|
31
51
|
};
|
|
32
52
|
}
|
|
33
53
|
void z;
|
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,
|
|
@@ -214,7 +222,7 @@ export async function createManagedDatabaseHandler(input) {
|
|
|
214
222
|
const res = await createManagedDatabase(input.projectId);
|
|
215
223
|
if (res.error) {
|
|
216
224
|
const planHint = res.error === "plan_not_allowed" || res.error === "limit_reached"
|
|
217
|
-
?
|
|
225
|
+
? ` Two options: add a managed Postgres by upgrading at /billing, or connect your own database with \`offlocal_connect_provider\` (capability "postgres").`
|
|
218
226
|
: "";
|
|
219
227
|
return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${planHint}`, res);
|
|
220
228
|
}
|
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,
|
|
@@ -55,15 +60,29 @@ export async function statusHandler(input) {
|
|
|
55
60
|
lines.push(dep.errorMessage.slice(0, 2000));
|
|
56
61
|
lines.push("```");
|
|
57
62
|
}
|
|
63
|
+
if (dep.errorType === "build_failed" || dep.errorType === "build_error") {
|
|
64
|
+
lines.push("");
|
|
65
|
+
lines.push(`Call \`offlocal_logs({ deploymentId: "${dep.id}" })\` for the full build output, fix the cause, then re-run \`offlocal_deploy\`.`);
|
|
66
|
+
}
|
|
58
67
|
}
|
|
59
68
|
else {
|
|
60
69
|
lines.push(`${ICON.busy} **${phase}**`);
|
|
61
70
|
lines.push("");
|
|
62
71
|
lines.push(bar);
|
|
63
72
|
lines.push("");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
if (stalled) {
|
|
74
|
+
lines.push(`${ICON.warn} It's been at "${phase}" for ~${Math.round(secondsSinceUpdate / 60)} min with no change — ` +
|
|
75
|
+
`longer than a normal deploy. It may be stuck.\n` +
|
|
76
|
+
`Tell the user it's taking unusually long. You can wait a little longer, but do NOT keep polling indefinitely — ` +
|
|
77
|
+
`if it doesn't move within another couple of minutes, suggest re-running \`offlocal_deploy\` (a retry usually clears a stuck deploy).`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
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` +
|
|
81
|
+
`• A deploy is only failed when the status is literally **failed** — nothing else means it broke.\n` +
|
|
82
|
+
`• "starting container" can mean the container is already healthy but not yet promoted; it'll flip to **live** shortly.\n` +
|
|
83
|
+
`• Empty \`offlocal_logs\` during build / before live is expected (container logs only exist once it's running) — not an error.\n` +
|
|
84
|
+
`Sleep ${pollAfterSeconds}s, then call this exactly once. Polling faster does not help.`);
|
|
85
|
+
}
|
|
67
86
|
}
|
|
68
87
|
return {
|
|
69
88
|
content: [{ type: "text", text: lines.join("\n") }],
|
package/dist/version.js
ADDED
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