@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 +21 -10
- package/dist/detect.js +3 -432
- package/dist/snapshot.js +127 -0
- package/dist/tools/plan.js +16 -150
- package/dist/tools/providers.js +35 -18
- package/dist/tools/status.js +11 -78
- package/package.json +1 -1
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.
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
package/dist/snapshot.js
ADDED
|
@@ -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
|
+
}
|
package/dist/tools/plan.js
CHANGED
|
@@ -1,30 +1,8 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
36
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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()
|
package/dist/tools/providers.js
CHANGED
|
@@ -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.
|
|
64
|
-
?
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
:
|
|
70
|
-
|
|
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:
|
|
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` +
|
package/dist/tools/status.js
CHANGED
|
@@ -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