@offlocal/mcp 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -81,8 +81,28 @@ export async function getDeployment(deploymentId) {
81
81
  const res = await fetch(`${API_URL}/api/deployments/${encodeURIComponent(deploymentId)}`, { headers: { ...authHeaders() } });
82
82
  if (!res.ok)
83
83
  throw new Error(await failureText(res, "get_deployment"));
84
+ return (await res.json());
85
+ }
86
+ export async function detectApp(dir) {
87
+ const res = await fetch(`${API_URL}/api/detect`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json", ...authHeaders() },
90
+ body: JSON.stringify({ dir }),
91
+ });
92
+ if (!res.ok)
93
+ throw new Error(await failureText(res, "detect"));
84
94
  const json = (await res.json());
85
- return json.deployment;
95
+ return json.detected;
96
+ }
97
+ export async function planProject(snapshot) {
98
+ const res = await fetch(`${API_URL}/api/plan`, {
99
+ method: "POST",
100
+ headers: { "content-type": "application/json", ...authHeaders() },
101
+ body: JSON.stringify({ snapshot }),
102
+ });
103
+ if (!res.ok)
104
+ throw new Error(await failureText(res, "plan"));
105
+ return (await res.json());
86
106
  }
87
107
  export async function getDeploymentLogs(deploymentId, options) {
88
108
  const params = new URLSearchParams();
@@ -139,15 +159,6 @@ export async function pollProvision(actionId) {
139
159
  }
140
160
  return (await res.json());
141
161
  }
142
- export async function getPreferences() {
143
- const res = await fetch(`${API_URL}/api/me/preferences`, {
144
- headers: { ...authHeaders() },
145
- });
146
- if (!res.ok)
147
- return {};
148
- const json = (await res.json());
149
- return json.preferences ?? {};
150
- }
151
162
  export async function setPreference(capability, platform) {
152
163
  const res = await fetch(`${API_URL}/api/me/preferences`, {
153
164
  method: "PUT",
package/dist/detect.js CHANGED
@@ -1,434 +1,5 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
2
- import { basename, join } from "node:path";
3
- const NO_BUILD = "echo no-build";
4
- const FROM_DOCKERFILE = "(provided by Dockerfile)";
1
+ import { gatherDirSnapshot } from "./snapshot.js";
2
+ import { detectApp } from "./client.js";
5
3
  export async function detect(sourceDir) {
6
- const appName = await resolveAppName(sourceDir);
7
- const hasDockerfile = await fileExists(join(sourceDir, "Dockerfile"));
8
- if (hasDockerfile) {
9
- const dockerfile = await readTextSafe(join(sourceDir, "Dockerfile"));
10
- return {
11
- appName,
12
- runtime: "dockerfile",
13
- framework: "dockerfile",
14
- buildCommand: FROM_DOCKERFILE,
15
- startCommand: FROM_DOCKERFILE,
16
- port: parseExpose(dockerfile) ?? 3000,
17
- strategy: "dockerfile",
18
- hasDockerfile: true,
19
- };
20
- }
21
- if (await fileExists(join(sourceDir, "package.json"))) {
22
- return { ...(await detectNode(sourceDir, appName)), hasDockerfile: false };
23
- }
24
- if ((await fileExists(join(sourceDir, "requirements.txt"))) ||
25
- (await fileExists(join(sourceDir, "pyproject.toml"))) ||
26
- (await fileExists(join(sourceDir, "Pipfile")))) {
27
- return { ...(await detectPython(sourceDir, appName)), hasDockerfile: false };
28
- }
29
- if (await fileExists(join(sourceDir, "go.mod"))) {
30
- return {
31
- appName,
32
- runtime: "go-1.22",
33
- framework: "go",
34
- buildCommand: NO_BUILD,
35
- startCommand: "/usr/local/bin/app",
36
- port: 8080,
37
- strategy: "template",
38
- hasDockerfile: false,
39
- };
40
- }
41
- if (await fileExists(join(sourceDir, "Cargo.toml"))) {
42
- return {
43
- appName,
44
- runtime: "rust-1",
45
- framework: "rust",
46
- buildCommand: NO_BUILD,
47
- startCommand: "/usr/local/bin/app",
48
- port: 8080,
49
- strategy: "template",
50
- hasDockerfile: false,
51
- };
52
- }
53
- if (await fileExists(join(sourceDir, "index.html"))) {
54
- return {
55
- appName,
56
- runtime: "static",
57
- framework: "static",
58
- buildCommand: NO_BUILD,
59
- startCommand: "nginx -g 'daemon off;'",
60
- port: 80,
61
- strategy: "template",
62
- hasDockerfile: false,
63
- };
64
- }
65
- return {
66
- appName,
67
- runtime: "unknown",
68
- framework: "unknown",
69
- buildCommand: NO_BUILD,
70
- startCommand: NO_BUILD,
71
- port: 3000,
72
- strategy: "unknown",
73
- hasDockerfile: false,
74
- };
75
- }
76
- export async function detectRequirements(sourceDir) {
77
- let deps = {};
78
- try {
79
- const pkg = JSON.parse(await readFile(join(sourceDir, "package.json"), "utf8"));
80
- deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
81
- }
82
- catch {
83
- deps = {};
84
- }
85
- const has = (name) => name in deps;
86
- const prisma = await readTextSafe(join(sourceDir, "prisma", "schema.prisma"));
87
- const prismaProvider = /provider\s*=\s*"(postgresql|mysql|sqlite|mongodb)"/i
88
- .exec(prisma)?.[1]
89
- ?.toLowerCase();
90
- const envText = ((await readTextSafe(join(sourceDir, ".env.example"))) +
91
- "\n" +
92
- (await readTextSafe(join(sourceDir, ".env")))).toLowerCase();
93
- const reqs = [];
94
- const wantsPg = has("pg") ||
95
- has("postgres") ||
96
- prismaProvider === "postgresql" ||
97
- /database_url\s*=\s*['"]?postgres/.test(envText);
98
- const wantsMysql = has("mysql2") ||
99
- has("mysql") ||
100
- has("@planetscale/database") ||
101
- prismaProvider === "mysql" ||
102
- /database_url\s*=\s*['"]?mysql/.test(envText);
103
- const wantsGenericDb = has("@prisma/client") ||
104
- has("prisma") ||
105
- has("drizzle-orm") ||
106
- has("typeorm") ||
107
- has("sequelize") ||
108
- has("knex") ||
109
- /\bdatabase_url\b/.test(envText);
110
- if (wantsPg)
111
- reqs.push({ capability: "postgres", reason: "Postgres driver/ORM detected" });
112
- else if (wantsMysql)
113
- reqs.push({ capability: "mysql", reason: "MySQL driver detected" });
114
- else if (wantsGenericDb)
115
- reqs.push({ capability: "postgres", reason: "the app reads DATABASE_URL" });
116
- if (has("redis") || has("ioredis")) {
117
- reqs.push({ capability: "redis", reason: "Redis client detected" });
118
- }
119
- const wantsObjectStorage = has("@aws-sdk/client-s3") ||
120
- has("@aws-sdk/lib-storage") ||
121
- has("aws-sdk") ||
122
- has("@google-cloud/storage") ||
123
- has("@azure/storage-blob") ||
124
- has("minio") ||
125
- has("multer-s3") ||
126
- has("cloudinary") ||
127
- /\b(s3_bucket|bucket_name|storage_bucket|r2_bucket|spaces_bucket|cloudinary_url)\b/.test(envText);
128
- if (wantsObjectStorage) {
129
- reqs.push({
130
- capability: "object_storage",
131
- reason: "object-storage client or bucket config detected",
132
- });
133
- }
134
- return reqs;
135
- }
136
- const WEB_FRAMEWORKS = new Set([
137
- "nextjs",
138
- "nuxt",
139
- "remix",
140
- "astro",
141
- "vite",
142
- "cra",
143
- "static",
144
- ]);
145
- const API_FRAMEWORKS = new Set(["fastify", "hono", "express", "koa", "nestjs"]);
146
- const COMPONENT_DIRS = ["apps", "packages", "services"];
147
- export async function detectComponents(sourceDir) {
148
- const candidates = await collectComponentDirs(sourceDir);
149
- const components = [];
150
- const seen = new Set();
151
- for (const dir of candidates) {
152
- if (seen.has(dir))
153
- continue;
154
- seen.add(dir);
155
- const app = await detect(dir);
156
- if (app.framework === "unknown" && dir !== sourceDir)
157
- continue;
158
- components.push({
159
- name: componentName(sourceDir, dir),
160
- relPath: relPath(sourceDir, dir),
161
- absPath: dir,
162
- role: roleFor(app.framework),
163
- framework: app.framework,
164
- });
165
- }
166
- if (components.length === 0) {
167
- const app = await detect(sourceDir);
168
- return [
169
- {
170
- name: "app",
171
- relPath: "",
172
- absPath: sourceDir,
173
- role: roleFor(app.framework),
174
- framework: app.framework,
175
- },
176
- ];
177
- }
178
- if (components.length === 1)
179
- return components;
180
- const roles = new Set(components.map((c) => c.role));
181
- if (roles.size < 2 && !components.some((c) => c.relPath !== "")) {
182
- return [components[0]];
183
- }
184
- return components;
185
- }
186
- async function collectComponentDirs(sourceDir) {
187
- const out = [];
188
- const rootPkg = await readJsonSafe(join(sourceDir, "package.json"));
189
- const hasWorkspaces = Boolean(rootPkg?.workspaces) ||
190
- (await fileExists(join(sourceDir, "pnpm-workspace.yaml"))) ||
191
- (await fileExists(join(sourceDir, "turbo.json")));
192
- if (!hasWorkspaces) {
193
- if (rootPkg && (rootPkg.dependencies || rootPkg.scripts))
194
- out.push(sourceDir);
195
- return out;
196
- }
197
- for (const base of COMPONENT_DIRS) {
198
- const baseDir = join(sourceDir, base);
199
- let entries = [];
200
- try {
201
- entries = (await readdir(baseDir, { withFileTypes: true }))
202
- .filter((e) => e.isDirectory())
203
- .map((e) => e.name);
204
- }
205
- catch {
206
- continue;
207
- }
208
- for (const name of entries) {
209
- const dir = join(baseDir, name);
210
- if ((await fileExists(join(dir, "package.json"))) ||
211
- (await fileExists(join(dir, "Dockerfile"))) ||
212
- (await fileExists(join(dir, "go.mod"))) ||
213
- (await fileExists(join(dir, "requirements.txt")))) {
214
- out.push(dir);
215
- }
216
- }
217
- }
218
- return out;
219
- }
220
- function roleFor(framework) {
221
- if (WEB_FRAMEWORKS.has(framework))
222
- return "web";
223
- if (API_FRAMEWORKS.has(framework))
224
- return "api";
225
- return "app";
226
- }
227
- export function apiUrlEnvVar(framework) {
228
- switch (framework) {
229
- case "nextjs":
230
- return { key: "NEXT_PUBLIC_API_URL", buildTime: true };
231
- case "vite":
232
- return { key: "VITE_API_URL", buildTime: true };
233
- case "cra":
234
- return { key: "REACT_APP_API_URL", buildTime: true };
235
- case "nuxt":
236
- return { key: "NUXT_PUBLIC_API_URL", buildTime: true };
237
- case "astro":
238
- return { key: "PUBLIC_API_URL", buildTime: true };
239
- default:
240
- return { key: "API_URL", buildTime: false };
241
- }
242
- }
243
- function componentName(root, dir) {
244
- if (dir === root)
245
- return "root";
246
- const raw = basename(dir).toLowerCase();
247
- if (["frontend", "client", "site", "www", "ui"].includes(raw))
248
- return "web";
249
- if (["backend", "server", "service"].includes(raw))
250
- return "api";
251
- return raw.replace(/[^a-z0-9-]+/g, "-") || "app";
252
- }
253
- function relPath(root, dir) {
254
- if (dir === root)
255
- return "";
256
- const r = dir.slice(root.length).replace(/^[\\/]+/, "").replace(/\\/g, "/");
257
- return r;
258
- }
259
- async function readJsonSafe(path) {
260
- try {
261
- return JSON.parse(await readFile(path, "utf8"));
262
- }
263
- catch {
264
- return null;
265
- }
266
- }
267
- async function detectNode(sourceDir, appName) {
268
- let pkg = null;
269
- try {
270
- pkg = JSON.parse(await readFile(join(sourceDir, "package.json"), "utf8"));
271
- }
272
- catch {
273
- pkg = null;
274
- }
275
- const deps = {
276
- ...(pkg?.dependencies ?? {}),
277
- ...(pkg?.devDependencies ?? {}),
278
- };
279
- const scripts = pkg?.scripts ?? {};
280
- const framework = pickNodeFramework(deps);
281
- return {
282
- appName: pkg?.name ?? appName,
283
- runtime: "node-20",
284
- framework,
285
- buildCommand: scripts.build ? "npm run build" : NO_BUILD,
286
- startCommand: scripts.start ? "npm start" : nodeFallbackStart(framework),
287
- port: nodePort(framework),
288
- strategy: "template",
289
- };
290
- }
291
- async function detectPython(sourceDir, appName) {
292
- const manifest = ((await readTextSafe(join(sourceDir, "requirements.txt"))) +
293
- "\n" +
294
- (await readTextSafe(join(sourceDir, "pyproject.toml"))) +
295
- "\n" +
296
- (await readTextSafe(join(sourceDir, "Pipfile")))).toLowerCase();
297
- const hasManage = await fileExists(join(sourceDir, "manage.py"));
298
- let framework = "python";
299
- let startCommand = "python app.py";
300
- const notes = [];
301
- if (hasManage || manifest.includes("django")) {
302
- framework = "django";
303
- const wsgi = await findDjangoWsgiModule(sourceDir);
304
- startCommand = `python manage.py migrate --noinput && gunicorn ${wsgi}:application --bind 0.0.0.0:$PORT`;
305
- 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.");
306
- }
307
- else if (manifest.includes("fastapi")) {
308
- framework = "fastapi";
309
- startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT";
310
- }
311
- else if (manifest.includes("flask")) {
312
- framework = "flask";
313
- startCommand = "gunicorn app:app -b 0.0.0.0:$PORT";
314
- }
315
- else if (await fileExists(join(sourceDir, "main.py"))) {
316
- startCommand = "python main.py";
317
- }
318
- return {
319
- appName,
320
- runtime: "python-3.12",
321
- framework,
322
- buildCommand: NO_BUILD,
323
- startCommand,
324
- port: 8000,
325
- strategy: "template",
326
- notes: notes.length ? notes : undefined,
327
- };
328
- }
329
- async function findDjangoWsgiModule(sourceDir) {
330
- const manage = await readTextSafe(join(sourceDir, "manage.py"));
331
- const m = /DJANGO_SETTINGS_MODULE['"]\s*,\s*['"]([\w.]+)\.settings['"]/.exec(manage);
332
- if (m?.[1])
333
- return `${m[1]}.wsgi`;
334
- try {
335
- const entries = await readdir(sourceDir, { withFileTypes: true });
336
- for (const e of entries) {
337
- if (e.isDirectory() &&
338
- (await fileExists(join(sourceDir, e.name, "wsgi.py")))) {
339
- return `${e.name}.wsgi`;
340
- }
341
- }
342
- }
343
- catch {
344
- }
345
- return "wsgi";
346
- }
347
- function pickNodeFramework(deps) {
348
- if ("next" in deps)
349
- return "nextjs";
350
- if ("nuxt" in deps)
351
- return "nuxt";
352
- if ("@remix-run/serve" in deps || "@remix-run/node" in deps)
353
- return "remix";
354
- if ("astro" in deps)
355
- return "astro";
356
- if ("vite" in deps)
357
- return "vite";
358
- if ("react-scripts" in deps || "@craco/craco" in deps)
359
- return "cra";
360
- if ("fastify" in deps)
361
- return "fastify";
362
- if ("hono" in deps)
363
- return "hono";
364
- if ("express" in deps)
365
- return "express";
366
- if ("koa" in deps)
367
- return "koa";
368
- return "node";
369
- }
370
- function nodePort(framework) {
371
- switch (framework) {
372
- case "astro":
373
- return 4321;
374
- case "vite":
375
- return 4173;
376
- case "cra":
377
- return 80;
378
- default:
379
- return 3000;
380
- }
381
- }
382
- function nodeFallbackStart(framework) {
383
- if (framework === "nextjs")
384
- return "npx next start";
385
- if (framework === "nuxt")
386
- return "node .output/server/index.mjs";
387
- return "node index.js";
388
- }
389
- async function resolveAppName(sourceDir) {
390
- try {
391
- const pkg = JSON.parse(await readFile(join(sourceDir, "package.json"), "utf8"));
392
- if (pkg.name)
393
- return pkg.name;
394
- }
395
- catch {
396
- }
397
- const cargo = await readTextSafe(join(sourceDir, "Cargo.toml"));
398
- const cargoName = /name\s*=\s*"([^"]+)"/.exec(cargo)?.[1];
399
- if (cargoName)
400
- return cargoName;
401
- const pyproject = await readTextSafe(join(sourceDir, "pyproject.toml"));
402
- const pyName = /name\s*=\s*"([^"]+)"/.exec(pyproject)?.[1];
403
- if (pyName)
404
- return pyName;
405
- return slugifyDirName(sourceDir) ?? "app";
406
- }
407
- function parseExpose(dockerfile) {
408
- const m = /^\s*EXPOSE\s+(\d{1,5})/im.exec(dockerfile);
409
- if (!m)
410
- return null;
411
- const port = Number.parseInt(m[1], 10);
412
- return port >= 1 && port <= 65535 ? port : null;
413
- }
414
- function slugifyDirName(dir) {
415
- const name = basename(dir).toLowerCase().replace(/[^a-z0-9-]+/g, "-");
416
- return name || null;
417
- }
418
- async function readTextSafe(path) {
419
- try {
420
- return await readFile(path, "utf8");
421
- }
422
- catch {
423
- return "";
424
- }
425
- }
426
- async function fileExists(path) {
427
- try {
428
- await stat(path);
429
- return true;
430
- }
431
- catch {
432
- return false;
433
- }
4
+ return detectApp(await gatherDirSnapshot(sourceDir));
434
5
  }
@@ -0,0 +1,127 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ const COMPONENT_DIRS = ["apps", "packages", "services"];
4
+ async function readTextSafe(path) {
5
+ try {
6
+ return await readFile(path, "utf8");
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
12
+ async function exists(path) {
13
+ try {
14
+ await stat(path);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ async function envSignals(dir) {
22
+ const text = ((await readTextSafe(join(dir, ".env.example"))) ?? "") +
23
+ "\n" +
24
+ ((await readTextSafe(join(dir, ".env"))) ?? "");
25
+ if (!text.trim())
26
+ return {};
27
+ const keys = new Set();
28
+ let dbUrlScheme = null;
29
+ for (const line of text.split(/\r?\n/)) {
30
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
31
+ if (!m)
32
+ continue;
33
+ const key = m[1];
34
+ keys.add(key);
35
+ if (key.toUpperCase() === "DATABASE_URL" && !dbUrlScheme) {
36
+ const val = (m[2] ?? "").trim().replace(/^['"]/, "");
37
+ if (/^postgres/i.test(val))
38
+ dbUrlScheme = "postgres";
39
+ else if (/^mysql/i.test(val))
40
+ dbUrlScheme = "mysql";
41
+ }
42
+ }
43
+ return { envKeys: keys.size ? [...keys] : undefined, dbUrlScheme };
44
+ }
45
+ async function wsgiDirs(dir) {
46
+ try {
47
+ const entries = await readdir(dir, { withFileTypes: true });
48
+ const out = [];
49
+ for (const e of entries) {
50
+ if (e.isDirectory() && (await exists(join(dir, e.name, "wsgi.py")))) {
51
+ out.push(e.name);
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ export async function gatherDirSnapshot(dir) {
61
+ const [packageJson, requirementsTxt, pyprojectToml, pipfile, cargoToml, dockerfile, managePy, prismaSchema, env, hasIndexHtml, hasMainPy, hasGoMod, wsgi,] = await Promise.all([
62
+ readTextSafe(join(dir, "package.json")),
63
+ readTextSafe(join(dir, "requirements.txt")),
64
+ readTextSafe(join(dir, "pyproject.toml")),
65
+ readTextSafe(join(dir, "Pipfile")),
66
+ readTextSafe(join(dir, "Cargo.toml")),
67
+ readTextSafe(join(dir, "Dockerfile")),
68
+ readTextSafe(join(dir, "manage.py")),
69
+ readTextSafe(join(dir, "prisma", "schema.prisma")),
70
+ envSignals(dir),
71
+ exists(join(dir, "index.html")),
72
+ exists(join(dir, "main.py")),
73
+ exists(join(dir, "go.mod")),
74
+ wsgiDirs(dir),
75
+ ]);
76
+ return {
77
+ dirName: basename(dir),
78
+ packageJson,
79
+ requirementsTxt,
80
+ pyprojectToml,
81
+ pipfile,
82
+ cargoToml,
83
+ dockerfile,
84
+ managePy,
85
+ prismaSchema,
86
+ envKeys: env.envKeys,
87
+ dbUrlScheme: env.dbUrlScheme,
88
+ hasIndexHtml,
89
+ hasMainPy,
90
+ hasGoMod,
91
+ wsgiDirs: wsgi.length ? wsgi : undefined,
92
+ };
93
+ }
94
+ export async function gatherPlanSnapshot(rootDir) {
95
+ const [root, hasPnpmWorkspace, hasTurboJson] = await Promise.all([
96
+ gatherDirSnapshot(rootDir),
97
+ exists(join(rootDir, "pnpm-workspace.yaml")),
98
+ exists(join(rootDir, "turbo.json")),
99
+ ]);
100
+ const components = [];
101
+ for (const base of COMPONENT_DIRS) {
102
+ const baseDir = join(rootDir, base);
103
+ let names = [];
104
+ try {
105
+ names = (await readdir(baseDir, { withFileTypes: true }))
106
+ .filter((e) => e.isDirectory())
107
+ .map((e) => e.name);
108
+ }
109
+ catch {
110
+ continue;
111
+ }
112
+ for (const name of names) {
113
+ const dir = join(baseDir, name);
114
+ const hasManifest = (await exists(join(dir, "package.json"))) ||
115
+ (await exists(join(dir, "Dockerfile"))) ||
116
+ (await exists(join(dir, "go.mod"))) ||
117
+ (await exists(join(dir, "requirements.txt")));
118
+ if (hasManifest) {
119
+ components.push({
120
+ relPath: `${base}/${name}`,
121
+ snapshot: await gatherDirSnapshot(dir),
122
+ });
123
+ }
124
+ }
125
+ }
126
+ return { root: { ...root, hasPnpmWorkspace, hasTurboJson }, components };
127
+ }
@@ -1,30 +1,8 @@
1
+ import { join } from "node:path";
1
2
  import { z } from "zod";
2
- import { apiUrlEnvVar, detectComponents, detectRequirements, } from "../detect.js";
3
- import { getPreferences, listConnections, resolveRequirement, setPreference, } from "../client.js";
3
+ import { gatherPlanSnapshot } from "../snapshot.js";
4
+ import { listConnections, planProject, setPreference, } from "../client.js";
4
5
  import { ICON, progressBar, step, ui } from "../ui.js";
5
- const OFFLOCAL_NATIVE = {
6
- hosting: { label: "Offlocal.ai (managed)", cost: "instant · *.offlocal.ai" },
7
- postgres: { label: "Offlocal Managed Postgres", cost: "included in your plan" },
8
- };
9
- const CAP_LABEL = {
10
- hosting: "Web app",
11
- postgres: "Postgres database",
12
- mysql: "MySQL database",
13
- redis: "Redis",
14
- object_storage: "Object storage",
15
- };
16
- const ROLE_LABEL = {
17
- web: "Web app",
18
- api: "API / server",
19
- app: "App",
20
- };
21
- const PROVIDER_COST = {
22
- supabase: "free tier available",
23
- planetscale: "no free tier (~$39/mo)",
24
- digitalocean: "~$15/mo",
25
- railway: "usage-based (paid plan)",
26
- vercel: "coming soon",
27
- };
28
6
  export const PlanInputShape = {
29
7
  sourceDir: z
30
8
  .string()
@@ -32,68 +10,8 @@ export const PlanInputShape = {
32
10
  .describe("Absolute path to the project root to deploy."),
33
11
  };
34
12
  export async function planHandler(input) {
35
- const [components, connections, preferences] = await Promise.all([
36
- detectComponents(input.sourceDir),
37
- listConnections().catch(() => []),
38
- getPreferences().catch(() => ({})),
39
- ]);
40
- const connected = new Set(connections.filter((c) => c.status === "connected").map((c) => c.provider));
41
- const multi = components.length > 1;
42
- const reqMap = new Map();
43
- for (const c of components) {
44
- const reqs = await detectRequirements(c.absPath).catch(() => []);
45
- for (const r of reqs)
46
- if (!reqMap.has(r.capability))
47
- reqMap.set(r.capability, r);
48
- }
49
- const requirements = [...reqMap.values()];
50
- const hostingOpts = (await resolveRequirement("hosting").catch(() => ({ options: [] }))).options;
51
- const items = [];
52
- for (const c of components) {
53
- const prefCapability = multi ? `hosting:${c.name}` : "hosting";
54
- const label = multi
55
- ? `${ROLE_LABEL[c.role] ?? "App"} · \`${c.relPath || c.name}\` (${c.framework})`
56
- : `${CAP_LABEL.hosting} · ${c.framework}`;
57
- items.push(buildItem({
58
- prefCapability,
59
- capability: "hosting",
60
- kind: "hosting",
61
- label,
62
- component: c,
63
- preferences,
64
- options: hostingOpts,
65
- connected,
66
- }));
67
- }
68
- for (const req of requirements) {
69
- const opts = (await resolveRequirement(req.capability).catch(() => ({ options: [] }))).options;
70
- items.push(buildItem({
71
- prefCapability: req.capability,
72
- capability: req.capability,
73
- kind: "resource",
74
- label: CAP_LABEL[req.capability] ?? req.capability,
75
- preferences,
76
- options: opts,
77
- connected,
78
- reason: req.reason,
79
- }));
80
- }
81
- const dbTarget = components.find((c) => c.role === "api") ?? components[0];
82
- const apiComp = components.find((c) => c.role === "api");
83
- const links = apiComp
84
- ? components
85
- .filter((c) => c.role === "web")
86
- .map((w) => {
87
- const env = apiUrlEnvVar(w.framework);
88
- return {
89
- web: w.name,
90
- webPath: w.absPath,
91
- api: apiComp.name,
92
- envVar: env.key,
93
- buildTime: env.buildTime,
94
- };
95
- })
96
- : [];
13
+ const snapshot = await gatherPlanSnapshot(input.sourceDir);
14
+ const { multi, components, requirements, items, links, dbTarget } = await planProject(snapshot);
97
15
  const planLines = items.map((it, i) => {
98
16
  const emoji = it.isOfflocal ? ICON.offlocal : it.recommended ? ICON.connected : ICON.open;
99
17
  const connect = it.needsConnect ? ` ${ICON.warn} first-time connect (~30s)` : "";
@@ -135,7 +53,7 @@ export async function planHandler(input) {
135
53
  persistenceCheck +
136
54
  `\n\n**Execution order once they confirm — walk the checklist top to bottom, ` +
137
55
  `re-showing it with ${ICON.done} on finished steps so they see live progress:**\n` +
138
- executionSteps(items, links, dbTarget).join("\n");
56
+ executionSteps(items, links, dbTarget, input.sourceDir).join("\n");
139
57
  return ui({
140
58
  show,
141
59
  next: `Show the plan above to the user and wait for "go" (or an edit). Do NOT deploy or provision anything yet.`,
@@ -147,10 +65,13 @@ export async function planHandler(input) {
147
65
  items,
148
66
  links,
149
67
  checklist,
150
- dbTargetComponent: dbTarget?.name ?? null,
68
+ dbTargetComponent: dbTarget,
151
69
  },
152
70
  });
153
71
  }
72
+ function absPath(sourceDir, relPath) {
73
+ return relPath ? join(sourceDir, ...relPath.split("/")) : sourceDir;
74
+ }
154
75
  function buildChecklist(items, links, dbTarget) {
155
76
  const out = [];
156
77
  let n = 0;
@@ -183,7 +104,7 @@ function buildChecklist(items, links, dbTarget) {
183
104
  }
184
105
  const redeploy = new Set();
185
106
  if (resources.length && hosting.length)
186
- redeploy.add(dbTarget?.name);
107
+ redeploy.add(dbTarget ?? undefined);
187
108
  for (const l of links)
188
109
  redeploy.add(l.web);
189
110
  if (redeploy.size) {
@@ -201,13 +122,13 @@ function projName(name) {
201
122
  return "the app";
202
123
  return name;
203
124
  }
204
- function executionSteps(items, links, dbTarget) {
125
+ function executionSteps(items, links, dbTarget, sourceDir) {
205
126
  const lines = [];
206
127
  const hosting = items.filter((it) => it.kind === "hosting");
207
128
  const resources = items.filter((it) => it.kind === "resource");
208
129
  for (const it of hosting) {
209
130
  if (it.isOfflocal) {
210
- lines.push(`• ${ICON.rocket} Deploy ${labelShort(it)}: \`offlocal_deploy({ sourceDir: "${it.componentPath || "<root>"}" })\` → poll \`offlocal_status\` until live → note the **projectId** and **live URL**.`);
131
+ lines.push(`• ${ICON.rocket} Deploy ${labelShort(it)}: \`offlocal_deploy({ sourceDir: "${absPath(sourceDir, it.componentRelPath)}" })\` → poll \`offlocal_status\` until live → note the **projectId** and **live URL**.`);
211
132
  }
212
133
  else if (it.recommended) {
213
134
  lines.push(`• ${ICON.web} Host ${labelShort(it)} on ${it.recommendedLabel}: ${it.cost === "coming soon" ? `${it.recommendedLabel} support is coming soon.` : `if not connected, \`offlocal_connect\` → share link → poll \`offlocal_list_connections\`. (External hosting is provider-driven.)`}`);
@@ -215,14 +136,14 @@ function executionSteps(items, links, dbTarget) {
215
136
  }
216
137
  for (const it of resources) {
217
138
  if (it.isOfflocal) {
218
- lines.push(`• ${ICON.db} ${it.label} (Offlocal Managed Postgres): \`offlocal_create_managed_database({ projectId: <${projName(dbTarget?.name)} projectId> })\`. DATABASE_URL is wired in automatically.`);
139
+ lines.push(`• ${ICON.db} ${it.label} (Offlocal Managed Postgres): \`offlocal_create_managed_database({ projectId: <${projName(dbTarget)} projectId> })\`. DATABASE_URL is wired in automatically.`);
219
140
  }
220
141
  else {
221
142
  lines.push(`• ${ICON.db} ${it.label} on ${it.recommendedLabel}: ` +
222
143
  (it.needsConnect
223
144
  ? `\`offlocal_connect\` → share link → poll \`offlocal_list_connections\` until connected, then `
224
145
  : "") +
225
- `\`offlocal_provision({ provider: "${it.recommended}", type: "database" })\` for a PREVIEW + cost → confirm with the user → call again with \`confirm: true, projectId: <${projName(dbTarget?.name)} projectId>\`. If it returns status **"provisioning"**, poll \`offlocal_provision_status({ actionId })\` until ready.`);
146
+ `\`offlocal_provision({ provider: "${it.recommended}", type: "database" })\` for a PREVIEW + cost → confirm with the user → call again with \`confirm: true, projectId: <${projName(dbTarget)} projectId>\`. If it returns status **"provisioning"**, poll \`offlocal_provision_status({ actionId })\` until ready.`);
226
147
  }
227
148
  }
228
149
  for (const l of links) {
@@ -232,7 +153,7 @@ function executionSteps(items, links, dbTarget) {
232
153
  }
233
154
  const redeploy = new Set();
234
155
  if (resources.length && hosting.length)
235
- redeploy.add(dbTarget?.name);
156
+ redeploy.add(dbTarget ?? undefined);
236
157
  for (const l of links)
237
158
  redeploy.add(l.web);
238
159
  if (redeploy.size) {
@@ -248,61 +169,6 @@ function humanComp(name) {
248
169
  function labelShort(it) {
249
170
  return humanComp(it.componentName);
250
171
  }
251
- function buildItem(args) {
252
- const { prefCapability, capability, kind, label, preferences, options, connected, component, reason } = args;
253
- const native = OFFLOCAL_NATIVE[capability];
254
- const offlocalAvailable = Boolean(native);
255
- const pref = preferences[prefCapability];
256
- const prefOption = options.find((o) => o.provider === pref);
257
- const prefComingSoon = Boolean(prefOption && prefOption.status === "coming_soon");
258
- const prefValid = Boolean(pref &&
259
- !prefComingSoon &&
260
- (pref === "offlocal" ? offlocalAvailable : Boolean(prefOption)));
261
- const recommended = prefValid && pref
262
- ? pref
263
- : offlocalAvailable
264
- ? "offlocal"
265
- : (options.find((o) => o.connected)?.provider ??
266
- options.find((o) => o.connectable)?.provider ??
267
- null);
268
- const isOfflocal = recommended === "offlocal";
269
- const recommendedLabel = !recommended
270
- ? "— no platform available —"
271
- : isOfflocal
272
- ? native.label
273
- : (options.find((o) => o.provider === recommended)?.label ?? recommended);
274
- const cost = isOfflocal
275
- ? native.cost
276
- : recommended
277
- ? (PROVIDER_COST[recommended] ?? "provider pricing")
278
- : "";
279
- const needsConnect = Boolean(recommended) && !isOfflocal && !connected.has(recommended);
280
- const note = prefComingSoon
281
- ? `${prefOption?.label ?? pref} support is coming soon`
282
- : undefined;
283
- return {
284
- prefCapability,
285
- capability,
286
- kind,
287
- label,
288
- componentName: component?.name,
289
- componentPath: component?.absPath,
290
- recommended,
291
- recommendedLabel,
292
- fromPreference: prefValid,
293
- needsConnect,
294
- isOfflocal,
295
- cost,
296
- reason,
297
- note,
298
- options: [
299
- ...(offlocalAvailable
300
- ? [{ platform: "offlocal", label: native.label, status: "available" }]
301
- : []),
302
- ...options.map((o) => ({ platform: o.provider, label: o.label, status: o.status })),
303
- ],
304
- };
305
- }
306
172
  export const SetPreferenceInputShape = {
307
173
  capability: z
308
174
  .string()
@@ -34,19 +34,11 @@ export async function listConnectionsHandler() {
34
34
  }
35
35
  export const ResolveRequirementInputShape = {
36
36
  need: z
37
- .enum([
38
- "hosting",
39
- "postgres",
40
- "mysql",
41
- "redis",
42
- "object_storage",
43
- "email",
44
- "domain",
45
- ])
37
+ .enum(["hosting", "postgres", "mysql", "redis", "object_storage"])
46
38
  .describe("The capability the app needs (e.g. 'postgres' for a Postgres database)."),
47
39
  };
48
40
  export async function resolveRequirementHandler(input) {
49
- const { need, options, pasteEnvVar } = await resolveRequirement(input.need);
41
+ const { need, options, connectorBudget, pasteEnvVar } = await resolveRequirement(input.need);
50
42
  if (options.length === 0) {
51
43
  return {
52
44
  content: [
@@ -60,21 +52,46 @@ export async function resolveRequirementHandler(input) {
60
52
  };
61
53
  }
62
54
  const lines = options.map((o) => {
63
- const tag = o.status === "connected"
64
- ? "connected ✓"
65
- : o.connectable
66
- ? "connect required"
55
+ const tag = o.managed
56
+ ? o.planEligible
57
+ ? "managed by Offlocal · included in your plan"
58
+ : "managed by Offlocal · needs a paid plan"
59
+ : o.status === "connected"
60
+ ? "connected ✓"
67
61
  : o.status === "coming_soon"
68
62
  ? "coming soon"
69
- : "not configured";
70
- return ` - ${o.label} (${o.provider}) - ${tag}`;
63
+ : o.planEligible === false
64
+ ? "connect required · plan limit reached (upgrade)"
65
+ : o.connectable
66
+ ? "connect required"
67
+ : "not configured";
68
+ const guide = [o.bestFor, o.cost, o.freeTier ? "free tier" : null]
69
+ .filter(Boolean)
70
+ .join(" · ");
71
+ return ` - ${o.label} (${o.provider}) — ${tag}` + (guide ? `\n ${guide}` : "");
71
72
  });
73
+ const budgetLine = connectorBudget
74
+ ? `\n_Connectors: ${connectorBudget.used}/${connectorBudget.limit} used` +
75
+ (connectorBudget.remaining === 0
76
+ ? ` — limit reached. A new connection needs a plan upgrade (or paste an env var)._`
77
+ : ` (${connectorBudget.remaining} left)._`)
78
+ : "";
79
+ const hasActionable = options.some((o) => o.connected || o.connectable || (o.managed && o.planEligible));
80
+ const intro = hasActionable
81
+ ? `This app needs "${need}". Present these options and **recommend one** based on the ` +
82
+ `user's situation — use the "best for", cost, and free-tier notes (e.g. suggest a free tier ` +
83
+ `for a side project, or the managed option for zero setup):`
84
+ : `This app needs "${need}", but nothing is connectable on this account right now. ` +
85
+ `Simplest path: ask the user for a connection string and set it with offlocal_set_env ` +
86
+ `(or pick an upgrade/managed option if one is shown). Options:`;
72
87
  return {
73
88
  content: [
74
89
  {
75
90
  type: "text",
76
- text: `This app needs "${need}". Present these options to the user and let them choose:\n` +
77
- `${lines.join("\n")}\n\n` +
91
+ text: `${intro}\n` +
92
+ `${lines.join("\n")}${budgetLine}\n\n` +
93
+ `If they pick **Offlocal Managed Postgres** (provider "offlocal"), call ` +
94
+ `offlocal_create_managed_database with the projectId — no connect step. ` +
78
95
  `If they pick a provider that says "connect required", call offlocal_connect ` +
79
96
  `with that provider to get a link for them to authorize. If one is already ` +
80
97
  `connected, you can go straight to offlocal_provision.\n` +
@@ -19,24 +19,17 @@ export const StatusInputShape = {
19
19
  .describe("The deploymentId returned by offlocal_deploy."),
20
20
  };
21
21
  export async function statusHandler(input) {
22
- const dep = await getDeployment(input.deploymentId);
23
- const phase = phaseFor(dep.status);
24
- const isTerminal = dep.status === "live" || dep.status === "failed";
25
- const pollAfterSeconds = isTerminal ? 0 : pollIntervalFor(dep.status);
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;
22
+ const { deployment: dep, progress } = await getDeployment(input.deploymentId);
30
23
  const summary = {
31
24
  deploymentId: dep.id,
32
25
  projectId: dep.projectId,
33
26
  status: dep.status,
34
- phase,
35
- isTerminal,
36
- pollAfterSeconds,
37
- etaSeconds,
38
- secondsSinceUpdate,
39
- stalled,
27
+ phase: progress.phase,
28
+ isTerminal: progress.isTerminal,
29
+ pollAfterSeconds: progress.pollAfterSeconds,
30
+ etaSeconds: progress.etaSeconds,
31
+ secondsSinceUpdate: progress.secondsSinceUpdate,
32
+ stalled: progress.stalled,
40
33
  liveUrl: dep.liveUrl,
41
34
  errorType: dep.errorType,
42
35
  errorMessage: dep.errorMessage,
@@ -66,12 +59,12 @@ export async function statusHandler(input) {
66
59
  }
67
60
  }
68
61
  else {
69
- lines.push(`${ICON.busy} **${phase}**`);
62
+ lines.push(`${ICON.busy} **${progress.phase}**`);
70
63
  lines.push("");
71
64
  lines.push(bar);
72
65
  lines.push("");
73
- if (stalled) {
74
- lines.push(`${ICON.warn} It's been at "${phase}" for ~${Math.round(secondsSinceUpdate / 60)} min with no change — ` +
66
+ if (progress.stalled) {
67
+ lines.push(`${ICON.warn} It's been at "${progress.phase}" for ~${Math.round(progress.secondsSinceUpdate / 60)} min with no change — ` +
75
68
  `longer than a normal deploy. It may be stuck.\n` +
76
69
  `Tell the user it's taking unusually long. You can wait a little longer, but do NOT keep polling indefinitely — ` +
77
70
  `if it doesn't move within another couple of minutes, suggest re-running \`offlocal_deploy\` (a retry usually clears a stuck deploy).`);
@@ -81,7 +74,7 @@ export async function statusHandler(input) {
81
74
  `• A deploy is only failed when the status is literally **failed** — nothing else means it broke.\n` +
82
75
  `• "starting container" can mean the container is already healthy but not yet promoted; it'll flip to **live** shortly.\n` +
83
76
  `• 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.`);
77
+ `Sleep ${progress.pollAfterSeconds}s, then call this exactly once. Polling faster does not help.`);
85
78
  }
86
79
  }
87
80
  return {
@@ -89,63 +82,3 @@ export async function statusHandler(input) {
89
82
  structuredContent: summary,
90
83
  };
91
84
  }
92
- function phaseFor(status) {
93
- switch (status) {
94
- case "created":
95
- case "upload_pending":
96
- return "waiting for source upload";
97
- case "uploaded":
98
- case "validating":
99
- return "validating source";
100
- case "queued":
101
- return "queued for build";
102
- case "building":
103
- case "image_built":
104
- return "building image";
105
- case "deploying":
106
- return "starting container";
107
- case "live":
108
- return "live";
109
- case "failed":
110
- return "failed";
111
- case "deleted":
112
- return "deleted";
113
- default:
114
- return "unknown";
115
- }
116
- }
117
- function pollIntervalFor(status) {
118
- switch (status) {
119
- case "upload_pending":
120
- return 5;
121
- case "uploaded":
122
- case "validating":
123
- return 10;
124
- case "queued":
125
- case "building":
126
- case "image_built":
127
- return 30;
128
- case "deploying":
129
- return 30;
130
- default:
131
- return 15;
132
- }
133
- }
134
- function etaFor(status) {
135
- switch (status) {
136
- case "upload_pending":
137
- return 30;
138
- case "uploaded":
139
- case "validating":
140
- return 15;
141
- case "queued":
142
- return 90;
143
- case "building":
144
- case "image_built":
145
- return 75;
146
- case "deploying":
147
- return 90;
148
- default:
149
- return 0;
150
- }
151
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offlocal/mcp",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "MCP server for Offlocal - lets your AI coding agent deploy apps via offlocal_deploy, offlocal_status, offlocal_logs, offlocal_ping.",
5
5
  "keywords": [
6
6
  "mcp",