@offlocal/mcp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/client.js +103 -4
- package/dist/detect.js +324 -42
- package/dist/index.js +33 -6
- package/dist/tools/deploy.js +83 -19
- package/dist/tools/logs.js +0 -1
- package/dist/tools/ping.js +1 -3
- package/dist/tools/plan.js +338 -0
- package/dist/tools/plans.js +25 -0
- package/dist/tools/providers.js +223 -0
- package/dist/tools/status.js +31 -15
- package/dist/ui.js +52 -0
- package/dist/zip.js +0 -1
- package/package.json +10 -9
- package/dist/client.js.map +0 -1
- package/dist/detect.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/tools/deploy.js.map +0 -1
- package/dist/tools/logs.js.map +0 -1
- package/dist/tools/ping.js.map +0 -1
- package/dist/tools/status.js.map +0 -1
- package/dist/zip.js.map +0 -1
package/dist/tools/deploy.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { createDeployment, markUploadComplete, uploadBundle, } from "../client.js";
|
|
2
|
+
import { createDeployment, markUploadComplete, rollback, uploadBundle, } from "../client.js";
|
|
3
3
|
import { detect } from "../detect.js";
|
|
4
|
+
import { ICON, ui } from "../ui.js";
|
|
4
5
|
import { bundleDirectory } from "../zip.js";
|
|
5
6
|
export const DeployInputShape = {
|
|
6
7
|
sourceDir: z
|
|
@@ -25,6 +26,9 @@ export async function deployHandler(input) {
|
|
|
25
26
|
const detected = await detect(input.sourceDir);
|
|
26
27
|
const appName = input.appName ?? detected.appName;
|
|
27
28
|
const port = input.port ?? detected.port;
|
|
29
|
+
if (detected.strategy === "unknown") {
|
|
30
|
+
return needsDockerfile(input.sourceDir, port);
|
|
31
|
+
}
|
|
28
32
|
const bundle = await bundleDirectory(input.sourceDir);
|
|
29
33
|
const created = await createDeployment({
|
|
30
34
|
appName,
|
|
@@ -36,17 +40,25 @@ export async function deployHandler(input) {
|
|
|
36
40
|
});
|
|
37
41
|
await uploadBundle(created.upload.url, bundle.buffer);
|
|
38
42
|
await markUploadComplete(created.deploymentId);
|
|
43
|
+
const buildSource = detected.strategy === "dockerfile"
|
|
44
|
+
? "your Dockerfile"
|
|
45
|
+
: `auto-generated Dockerfile (${detected.framework})`;
|
|
46
|
+
const slug = created.slug ?? appName;
|
|
47
|
+
const expectedUrl = created.expectedUrl ?? `https://${slug}.offlocal.ai`;
|
|
39
48
|
const summary = {
|
|
40
49
|
deploymentId: created.deploymentId,
|
|
50
|
+
projectId: created.projectId,
|
|
41
51
|
appName,
|
|
52
|
+
slug,
|
|
42
53
|
framework: detected.framework,
|
|
54
|
+
buildSource,
|
|
43
55
|
port,
|
|
44
56
|
bundle: {
|
|
45
57
|
fileCount: bundle.fileCount,
|
|
46
58
|
bytes: bundle.byteSize,
|
|
47
59
|
},
|
|
48
60
|
status: "uploaded",
|
|
49
|
-
expectedUrl
|
|
61
|
+
expectedUrl,
|
|
50
62
|
pollAfterSeconds: 45,
|
|
51
63
|
expectedTotalSeconds: 180,
|
|
52
64
|
};
|
|
@@ -54,26 +66,78 @@ export async function deployHandler(input) {
|
|
|
54
66
|
content: [
|
|
55
67
|
{
|
|
56
68
|
type: "text",
|
|
57
|
-
text:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`
|
|
66
|
-
`Do NOT poll offlocal_status faster than once every 30 seconds. ` +
|
|
67
|
-
`Repeated polling does not speed anything up; the only thing that helps is time.\n\n` +
|
|
68
|
-
`Recommended sequence:\n` +
|
|
69
|
+
text: `${ICON.rocket} **Deploying \`${summary.appName}\`** — source uploaded, build started.\n\n` +
|
|
70
|
+
`| | |\n|---|---|\n` +
|
|
71
|
+
`| ${ICON.web} URL | ${summary.expectedUrl} |\n` +
|
|
72
|
+
`| ${ICON.spark} Framework | ${summary.framework} (${summary.buildSource}) |\n` +
|
|
73
|
+
`| Port | ${summary.port} |\n` +
|
|
74
|
+
`| Bundle | ${summary.bundle.fileCount} files · ${summary.bundle.bytes} bytes |\n\n` +
|
|
75
|
+
`———\n` +
|
|
76
|
+
`${ICON.arrow} **Next (you, the agent):** tell the user it's building (2–4 min) and you'll ` +
|
|
77
|
+
`report back when it's live. Then:\n` +
|
|
69
78
|
` 1. Sleep 45 seconds.\n` +
|
|
70
|
-
` 2.
|
|
71
|
-
` 3.
|
|
72
|
-
` 4.
|
|
73
|
-
`
|
|
79
|
+
` 2. \`offlocal_status({ deploymentId: "${summary.deploymentId}" })\`.\n` +
|
|
80
|
+
` 3. Sleep the \`pollAfterSeconds\` it returns, then call status again — exactly once per interval.\n` +
|
|
81
|
+
` 4. Repeat until \`live\` or \`failed\`. Polling faster does NOT speed up the build.\n` +
|
|
82
|
+
`Keep the **projectId** from the live status for any database step.`,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
structuredContent: summary,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export const RollbackInputShape = {
|
|
89
|
+
projectId: z
|
|
90
|
+
.string()
|
|
91
|
+
.min(1)
|
|
92
|
+
.describe("The Offlocal projectId to roll back (from offlocal_deploy / offlocal_status)."),
|
|
93
|
+
toDeploymentId: z
|
|
94
|
+
.string()
|
|
95
|
+
.optional()
|
|
96
|
+
.describe("Optional: a specific previous deploymentId to roll back to. Omit to roll back to the most recent prior build."),
|
|
97
|
+
};
|
|
98
|
+
export async function rollbackHandler(input) {
|
|
99
|
+
const res = await rollback(input.projectId, input.toDeploymentId);
|
|
100
|
+
if (res.error || !res.deployment) {
|
|
101
|
+
const hint = res.error === "no_rollback_target" || res.error === "deployment_not_found"
|
|
102
|
+
? " There's no earlier build with an image to roll back to (this project may only have one successful deploy)."
|
|
103
|
+
: "";
|
|
104
|
+
return ui({
|
|
105
|
+
show: `${ICON.fail} Rollback failed: ${res.message ?? res.error}.${hint}`,
|
|
106
|
+
structured: res,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const d = res.deployment;
|
|
110
|
+
return ui({
|
|
111
|
+
show: `${ICON.spark} **Rolling back** to the build from \`${res.rolledBackTo}\` — re-deploying that image (skips the build, ~1 min).`,
|
|
112
|
+
next: `Poll \`offlocal_status({ deploymentId: "${d.id}" })\` until it's \`live\`. It reuses the previous image, so there's no rebuild.`,
|
|
113
|
+
structured: { deployment: d, rolledBackTo: res.rolledBackTo ?? null },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function needsDockerfile(sourceDir, port) {
|
|
117
|
+
const summary = {
|
|
118
|
+
status: "needs_dockerfile",
|
|
119
|
+
sourceDir,
|
|
120
|
+
suggestedPort: port,
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
content: [
|
|
124
|
+
{
|
|
125
|
+
type: "text",
|
|
126
|
+
text: `I couldn't confidently detect this project's stack, so I didn't upload anything yet.\n\n` +
|
|
127
|
+
`To deploy it, add a Dockerfile to the project root, then run offlocal_deploy again.\n\n` +
|
|
128
|
+
`Write a production Dockerfile at:\n` +
|
|
129
|
+
` ${sourceDir}/Dockerfile\n\n` +
|
|
130
|
+
`Requirements for it to work on Offlocal:\n` +
|
|
131
|
+
` - It must build the app and end with a CMD or ENTRYPOINT that starts the server.\n` +
|
|
132
|
+
` - The server must listen on the port in the PORT environment variable\n` +
|
|
133
|
+
` (default to ${port} if PORT is unset), bound to 0.0.0.0 - not localhost.\n` +
|
|
134
|
+
` - EXPOSE that same port.\n` +
|
|
135
|
+
` - Use a multi-stage build for compiled languages to keep the image small.\n\n` +
|
|
136
|
+
`Base the Dockerfile on the actual files in the project (language, package\n` +
|
|
137
|
+
`manager, build and start commands). Once it's saved, call offlocal_deploy again\n` +
|
|
138
|
+
`with the same sourceDir - Offlocal will use your Dockerfile as-is.`,
|
|
74
139
|
},
|
|
75
140
|
],
|
|
76
141
|
structuredContent: summary,
|
|
77
142
|
};
|
|
78
143
|
}
|
|
79
|
-
//# sourceMappingURL=deploy.js.map
|
package/dist/tools/logs.js
CHANGED
package/dist/tools/ping.js
CHANGED
|
@@ -11,7 +11,7 @@ export async function pingHandler(_input) {
|
|
|
11
11
|
text: "Not connected to Offlocal.\n\n" +
|
|
12
12
|
`Reason: ${result.reason}\n\n` +
|
|
13
13
|
"Common fixes:\n" +
|
|
14
|
-
" - If you're calling from WSL, make sure OFFLOCAL_API_URL points at the Windows host (http://host.docker.internal:4000)
|
|
14
|
+
" - If you're calling from WSL, make sure OFFLOCAL_API_URL points at the Windows host (http://host.docker.internal:4000) - not localhost.\n" +
|
|
15
15
|
" - Make sure `pnpm dev:api` is running on the Windows side.\n" +
|
|
16
16
|
" - Double-check OFFLOCAL_AGENT_KEY matches a key created in the dashboard.",
|
|
17
17
|
},
|
|
@@ -30,6 +30,4 @@ export async function pingHandler(_input) {
|
|
|
30
30
|
structuredContent: { connected: true, email },
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
-
// Silence unused-import warnings if z isn't used after init.
|
|
34
33
|
void z;
|
|
35
|
-
//# sourceMappingURL=ping.js.map
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { apiUrlEnvVar, detectComponents, detectRequirements, } from "../detect.js";
|
|
3
|
+
import { getPreferences, listConnections, resolveRequirement, setPreference, } from "../client.js";
|
|
4
|
+
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
|
+
};
|
|
15
|
+
const ROLE_LABEL = {
|
|
16
|
+
web: "Web app",
|
|
17
|
+
api: "API / server",
|
|
18
|
+
app: "App",
|
|
19
|
+
};
|
|
20
|
+
const PROVIDER_COST = {
|
|
21
|
+
supabase: "free tier available",
|
|
22
|
+
planetscale: "no free tier (~$39/mo)",
|
|
23
|
+
digitalocean: "~$15/mo",
|
|
24
|
+
railway: "usage-based (paid plan)",
|
|
25
|
+
vercel: "coming soon",
|
|
26
|
+
};
|
|
27
|
+
export const PlanInputShape = {
|
|
28
|
+
sourceDir: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.describe("Absolute path to the project root to deploy."),
|
|
32
|
+
};
|
|
33
|
+
export async function planHandler(input) {
|
|
34
|
+
const [components, connections, preferences] = await Promise.all([
|
|
35
|
+
detectComponents(input.sourceDir),
|
|
36
|
+
listConnections().catch(() => []),
|
|
37
|
+
getPreferences().catch(() => ({})),
|
|
38
|
+
]);
|
|
39
|
+
const connected = new Set(connections.filter((c) => c.status === "connected").map((c) => c.provider));
|
|
40
|
+
const multi = components.length > 1;
|
|
41
|
+
const reqMap = new Map();
|
|
42
|
+
for (const c of components) {
|
|
43
|
+
const reqs = await detectRequirements(c.absPath).catch(() => []);
|
|
44
|
+
for (const r of reqs)
|
|
45
|
+
if (!reqMap.has(r.capability))
|
|
46
|
+
reqMap.set(r.capability, r);
|
|
47
|
+
}
|
|
48
|
+
const requirements = [...reqMap.values()];
|
|
49
|
+
const hostingOpts = (await resolveRequirement("hosting").catch(() => ({ options: [] }))).options;
|
|
50
|
+
const items = [];
|
|
51
|
+
for (const c of components) {
|
|
52
|
+
const prefCapability = multi ? `hosting:${c.name}` : "hosting";
|
|
53
|
+
const label = multi
|
|
54
|
+
? `${ROLE_LABEL[c.role] ?? "App"} · \`${c.relPath || c.name}\` (${c.framework})`
|
|
55
|
+
: `${CAP_LABEL.hosting} · ${c.framework}`;
|
|
56
|
+
items.push(buildItem({
|
|
57
|
+
prefCapability,
|
|
58
|
+
capability: "hosting",
|
|
59
|
+
kind: "hosting",
|
|
60
|
+
label,
|
|
61
|
+
component: c,
|
|
62
|
+
preferences,
|
|
63
|
+
options: hostingOpts,
|
|
64
|
+
connected,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
for (const req of requirements) {
|
|
68
|
+
const opts = (await resolveRequirement(req.capability).catch(() => ({ options: [] }))).options;
|
|
69
|
+
items.push(buildItem({
|
|
70
|
+
prefCapability: req.capability,
|
|
71
|
+
capability: req.capability,
|
|
72
|
+
kind: "resource",
|
|
73
|
+
label: CAP_LABEL[req.capability] ?? req.capability,
|
|
74
|
+
preferences,
|
|
75
|
+
options: opts,
|
|
76
|
+
connected,
|
|
77
|
+
reason: req.reason,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
const dbTarget = components.find((c) => c.role === "api") ?? components[0];
|
|
81
|
+
const apiComp = components.find((c) => c.role === "api");
|
|
82
|
+
const links = apiComp
|
|
83
|
+
? components
|
|
84
|
+
.filter((c) => c.role === "web")
|
|
85
|
+
.map((w) => {
|
|
86
|
+
const env = apiUrlEnvVar(w.framework);
|
|
87
|
+
return {
|
|
88
|
+
web: w.name,
|
|
89
|
+
webPath: w.absPath,
|
|
90
|
+
api: apiComp.name,
|
|
91
|
+
envVar: env.key,
|
|
92
|
+
buildTime: env.buildTime,
|
|
93
|
+
};
|
|
94
|
+
})
|
|
95
|
+
: [];
|
|
96
|
+
const planLines = items.map((it, i) => {
|
|
97
|
+
const emoji = it.isOfflocal ? ICON.offlocal : it.recommended ? ICON.connected : ICON.open;
|
|
98
|
+
const connect = it.needsConnect ? ` ${ICON.warn} first-time connect (~30s)` : "";
|
|
99
|
+
const saved = it.fromPreference ? " _(your saved choice)_" : "";
|
|
100
|
+
const note = it.note ? ` ${ICON.soon} _${it.note}_` : "";
|
|
101
|
+
return ` ${i + 1}. ${emoji} ${it.label} → **${it.recommendedLabel}**${saved}${connect} · _${it.cost}_${note}`;
|
|
102
|
+
});
|
|
103
|
+
const wiringBlock = links.length
|
|
104
|
+
? `\n**Wiring** _(automatic)_\n` +
|
|
105
|
+
links
|
|
106
|
+
.map((l) => ` ${ICON.plug} \`${l.web}\` reaches \`${l.api}\` via \`${l.envVar}\` (auto-set to ${l.api}'s live URL)`)
|
|
107
|
+
.join("\n") +
|
|
108
|
+
`\n`
|
|
109
|
+
: "";
|
|
110
|
+
const checklist = buildChecklist(items, links, dbTarget);
|
|
111
|
+
const checklistLines = checklist.map((c) => step("pending", c.text));
|
|
112
|
+
const detectedLine = multi
|
|
113
|
+
? `monorepo · ${components.length} apps (${components.map((c) => c.name).join(", ")})` +
|
|
114
|
+
(requirements.length ? ` + ${requirements.map((r) => r.capability).join(", ")}` : "")
|
|
115
|
+
: `${components[0]?.framework ?? "app"} app` +
|
|
116
|
+
(requirements.length
|
|
117
|
+
? ` + ${requirements.map((r) => r.reason).join(", ")}`
|
|
118
|
+
: "");
|
|
119
|
+
const hasDatastore = requirements.some((r) => r.capability === "postgres" || r.capability === "mysql");
|
|
120
|
+
const hasServerComponent = components.some((c) => c.role !== "web");
|
|
121
|
+
const noteEphemeral = !hasDatastore && hasServerComponent;
|
|
122
|
+
const show = `${ICON.search} **Offlocal.ai — deployment plan**\n\n` +
|
|
123
|
+
`**Detected:** ${detectedLine}\n\n` +
|
|
124
|
+
`**Plan**\n${planLines.join("\n")}\n` +
|
|
125
|
+
wiringBlock +
|
|
126
|
+
`\n**Setup checklist** ${progressBar(0, checklist.length)}\n${checklistLines.join("\n")}\n\n` +
|
|
127
|
+
`Reply **"go"** to ship it ${ICON.rocket} — or switch any line ` +
|
|
128
|
+
`(e.g. _"use Supabase for the database"_, _"host the API on Railway"_).`;
|
|
129
|
+
const persistenceCheck = noteEphemeral
|
|
130
|
+
? `\n\nNote: this host's storage is ephemeral. Mention data persistence only if the user asks about keeping data between deploys — a database covers it.`
|
|
131
|
+
: "";
|
|
132
|
+
const agentNote = `When the user switches a line, call \`offlocal_set_preference\` ` +
|
|
133
|
+
`(capability \`${multi ? "hosting:<component> or postgres/…" : "hosting/postgres/…"}\`) then re-run \`offlocal_plan\`.` +
|
|
134
|
+
persistenceCheck +
|
|
135
|
+
`\n\n**Execution order once they confirm — walk the checklist top to bottom, ` +
|
|
136
|
+
`re-showing it with ${ICON.done} on finished steps so they see live progress:**\n` +
|
|
137
|
+
executionSteps(items, links, dbTarget).join("\n");
|
|
138
|
+
return ui({
|
|
139
|
+
show,
|
|
140
|
+
next: `Show the plan above to the user and wait for "go" (or an edit). Do NOT deploy or provision anything yet.`,
|
|
141
|
+
agentNote,
|
|
142
|
+
structured: {
|
|
143
|
+
multiComponent: multi,
|
|
144
|
+
components,
|
|
145
|
+
requirements,
|
|
146
|
+
items,
|
|
147
|
+
links,
|
|
148
|
+
checklist,
|
|
149
|
+
dbTargetComponent: dbTarget?.name ?? null,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function buildChecklist(items, links, dbTarget) {
|
|
154
|
+
const out = [];
|
|
155
|
+
let n = 0;
|
|
156
|
+
const hosting = items.filter((it) => it.kind === "hosting");
|
|
157
|
+
const resources = items.filter((it) => it.kind === "resource");
|
|
158
|
+
for (const it of hosting) {
|
|
159
|
+
n += 1;
|
|
160
|
+
out.push({
|
|
161
|
+
n,
|
|
162
|
+
kind: "hosting",
|
|
163
|
+
text: `Deploy ${labelShort(it)} → ${it.recommendedLabel}`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
for (const it of resources) {
|
|
167
|
+
n += 1;
|
|
168
|
+
const connect = it.needsConnect ? ` (connect ${it.recommendedLabel} first)` : "";
|
|
169
|
+
out.push({
|
|
170
|
+
n,
|
|
171
|
+
kind: "resource",
|
|
172
|
+
text: `Provision ${it.label} → ${it.recommendedLabel}${connect}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
for (const l of links) {
|
|
176
|
+
n += 1;
|
|
177
|
+
out.push({
|
|
178
|
+
n,
|
|
179
|
+
kind: "link",
|
|
180
|
+
text: `Wire \`${l.web}\` → \`${l.api}\` URL (set ${l.envVar})`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const redeploy = new Set();
|
|
184
|
+
if (resources.length && hosting.length)
|
|
185
|
+
redeploy.add(dbTarget?.name);
|
|
186
|
+
for (const l of links)
|
|
187
|
+
redeploy.add(l.web);
|
|
188
|
+
if (redeploy.size) {
|
|
189
|
+
n += 1;
|
|
190
|
+
out.push({
|
|
191
|
+
n,
|
|
192
|
+
kind: "redeploy",
|
|
193
|
+
text: `Redeploy ${[...redeploy].map(humanComp).join(" & ")} to apply injected env`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
function projName(name) {
|
|
199
|
+
if (!name || name === "root" || name === "app")
|
|
200
|
+
return "the app";
|
|
201
|
+
return name;
|
|
202
|
+
}
|
|
203
|
+
function executionSteps(items, links, dbTarget) {
|
|
204
|
+
const lines = [];
|
|
205
|
+
const hosting = items.filter((it) => it.kind === "hosting");
|
|
206
|
+
const resources = items.filter((it) => it.kind === "resource");
|
|
207
|
+
for (const it of hosting) {
|
|
208
|
+
if (it.isOfflocal) {
|
|
209
|
+
lines.push(`• ${ICON.rocket} Deploy ${labelShort(it)}: \`offlocal_deploy({ sourceDir: "${it.componentPath || "<root>"}" })\` → poll \`offlocal_status\` until live → note the **projectId** and **live URL**.`);
|
|
210
|
+
}
|
|
211
|
+
else if (it.recommended) {
|
|
212
|
+
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.)`}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const it of resources) {
|
|
216
|
+
if (it.isOfflocal) {
|
|
217
|
+
lines.push(`• ${ICON.db} ${it.label} (Offlocal Managed Postgres): \`offlocal_create_managed_database({ projectId: <${projName(dbTarget?.name)} projectId> })\`. DATABASE_URL is wired in automatically.`);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
lines.push(`• ${ICON.db} ${it.label} on ${it.recommendedLabel}: ` +
|
|
221
|
+
(it.needsConnect
|
|
222
|
+
? `\`offlocal_connect\` → share link → poll \`offlocal_list_connections\` until connected, then `
|
|
223
|
+
: "") +
|
|
224
|
+
`\`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.`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const l of links) {
|
|
228
|
+
lines.push(`• ${ICON.plug} Wire \`${l.web}\` → \`${l.api}\`: once \`${l.api}\` is live, take its URL from \`offlocal_status\` and ` +
|
|
229
|
+
`\`offlocal_set_env({ projectId: <${l.web} projectId>, vars: { "${l.envVar}": "<${l.api} live URL>" } })\`.` +
|
|
230
|
+
(l.buildTime ? ` _(build-time var — only applies on a fresh deploy)_` : ""));
|
|
231
|
+
}
|
|
232
|
+
const redeploy = new Set();
|
|
233
|
+
if (resources.length && hosting.length)
|
|
234
|
+
redeploy.add(dbTarget?.name);
|
|
235
|
+
for (const l of links)
|
|
236
|
+
redeploy.add(l.web);
|
|
237
|
+
if (redeploy.size) {
|
|
238
|
+
lines.push(`• ${ICON.spark} **Redeploy** ${[...redeploy].map(humanComp).join(" & ")} (\`offlocal_deploy\` on each sourceDir) so the injected env (DATABASE_URL / API URL) takes effect.`);
|
|
239
|
+
}
|
|
240
|
+
return lines;
|
|
241
|
+
}
|
|
242
|
+
function humanComp(name) {
|
|
243
|
+
if (!name || name === "root" || name === "app")
|
|
244
|
+
return "the app";
|
|
245
|
+
return `\`${name}\``;
|
|
246
|
+
}
|
|
247
|
+
function labelShort(it) {
|
|
248
|
+
return humanComp(it.componentName);
|
|
249
|
+
}
|
|
250
|
+
function buildItem(args) {
|
|
251
|
+
const { prefCapability, capability, kind, label, preferences, options, connected, component, reason } = args;
|
|
252
|
+
const native = OFFLOCAL_NATIVE[capability];
|
|
253
|
+
const offlocalAvailable = Boolean(native);
|
|
254
|
+
const pref = preferences[prefCapability];
|
|
255
|
+
const prefOption = options.find((o) => o.provider === pref);
|
|
256
|
+
const prefComingSoon = Boolean(prefOption && prefOption.status === "coming_soon");
|
|
257
|
+
const prefValid = Boolean(pref &&
|
|
258
|
+
!prefComingSoon &&
|
|
259
|
+
(pref === "offlocal" ? offlocalAvailable : Boolean(prefOption)));
|
|
260
|
+
const recommended = prefValid && pref
|
|
261
|
+
? pref
|
|
262
|
+
: offlocalAvailable
|
|
263
|
+
? "offlocal"
|
|
264
|
+
: (options.find((o) => o.connected)?.provider ??
|
|
265
|
+
options.find((o) => o.connectable)?.provider ??
|
|
266
|
+
null);
|
|
267
|
+
const isOfflocal = recommended === "offlocal";
|
|
268
|
+
const recommendedLabel = !recommended
|
|
269
|
+
? "— no platform available —"
|
|
270
|
+
: isOfflocal
|
|
271
|
+
? native.label
|
|
272
|
+
: (options.find((o) => o.provider === recommended)?.label ?? recommended);
|
|
273
|
+
const cost = isOfflocal
|
|
274
|
+
? native.cost
|
|
275
|
+
: recommended
|
|
276
|
+
? (PROVIDER_COST[recommended] ?? "provider pricing")
|
|
277
|
+
: "";
|
|
278
|
+
const needsConnect = Boolean(recommended) && !isOfflocal && !connected.has(recommended);
|
|
279
|
+
const note = prefComingSoon
|
|
280
|
+
? `${prefOption?.label ?? pref} support is coming soon`
|
|
281
|
+
: undefined;
|
|
282
|
+
return {
|
|
283
|
+
prefCapability,
|
|
284
|
+
capability,
|
|
285
|
+
kind,
|
|
286
|
+
label,
|
|
287
|
+
componentName: component?.name,
|
|
288
|
+
componentPath: component?.absPath,
|
|
289
|
+
recommended,
|
|
290
|
+
recommendedLabel,
|
|
291
|
+
fromPreference: prefValid,
|
|
292
|
+
needsConnect,
|
|
293
|
+
isOfflocal,
|
|
294
|
+
cost,
|
|
295
|
+
reason,
|
|
296
|
+
note,
|
|
297
|
+
options: [
|
|
298
|
+
...(offlocalAvailable
|
|
299
|
+
? [{ platform: "offlocal", label: native.label, status: "available" }]
|
|
300
|
+
: []),
|
|
301
|
+
...options.map((o) => ({ platform: o.provider, label: o.label, status: o.status })),
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
export const SetPreferenceInputShape = {
|
|
306
|
+
capability: z
|
|
307
|
+
.string()
|
|
308
|
+
.min(1)
|
|
309
|
+
.max(40)
|
|
310
|
+
.describe('The capability to remember a platform for: "hosting", "postgres", "mysql", "redis", or per-component hosting like "hosting:web" / "hosting:api" (names come from offlocal_plan).'),
|
|
311
|
+
platform: z
|
|
312
|
+
.string()
|
|
313
|
+
.min(1)
|
|
314
|
+
.describe('The platform: "offlocal", "supabase", "planetscale", "digitalocean", "railway", or "vercel".'),
|
|
315
|
+
};
|
|
316
|
+
export async function setPreferenceHandler(input) {
|
|
317
|
+
const platform = input.platform.toLowerCase();
|
|
318
|
+
let comingSoon = false;
|
|
319
|
+
if (platform !== "offlocal") {
|
|
320
|
+
const conns = await listConnections().catch(() => []);
|
|
321
|
+
const meta = conns.find((c) => c.provider === platform);
|
|
322
|
+
comingSoon = meta?.availability === "coming_soon";
|
|
323
|
+
}
|
|
324
|
+
await setPreference(input.capability, platform);
|
|
325
|
+
if (comingSoon) {
|
|
326
|
+
return ui({
|
|
327
|
+
show: `${ICON.soon} **${platform}** support is coming soon. I've saved it for **${input.capability}** ` +
|
|
328
|
+
`and will switch to it automatically once it's available.`,
|
|
329
|
+
next: `Re-run \`offlocal_plan({ sourceDir })\` to see the updated plan.`,
|
|
330
|
+
structured: { capability: input.capability, platform, comingSoon: true },
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return ui({
|
|
334
|
+
show: `${ICON.done} Saved — **${input.capability} → ${platform}**. I'll default to this from now on.`,
|
|
335
|
+
next: `Re-run \`offlocal_plan({ sourceDir })\` to show the user the updated plan.`,
|
|
336
|
+
structured: { capability: input.capability, platform },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getPlanInfo } from "../client.js";
|
|
2
|
+
import { ICON, ui } from "../ui.js";
|
|
3
|
+
export const PlansInputShape = {};
|
|
4
|
+
export async function plansHandler() {
|
|
5
|
+
const info = await getPlanInfo();
|
|
6
|
+
const tierLines = info.plans.map((p) => {
|
|
7
|
+
const isCurrent = p.plan === info.plan;
|
|
8
|
+
const head = isCurrent
|
|
9
|
+
? `${ICON.connected} **${p.label} — ${p.priceLabel}** _(your plan)_`
|
|
10
|
+
: `${ICON.open} **${p.label} — ${p.priceLabel}**`;
|
|
11
|
+
const projects = `${p.projects} project${p.projects === 1 ? "" : "s"}`;
|
|
12
|
+
return `${head}\n ${projects} · ${p.features.slice(0, 4).join(" · ")}`;
|
|
13
|
+
});
|
|
14
|
+
const upcoming = info.upcoming.length
|
|
15
|
+
? `\n\n${ICON.soon} **On the roadmap:** ${info.upcoming.join(" · ")}`
|
|
16
|
+
: "";
|
|
17
|
+
return ui({
|
|
18
|
+
show: `${ICON.spark} **Offlocal.ai plans**\n\n` +
|
|
19
|
+
`You're on **${info.planLabel}** — using ${info.usage.projects}/${info.limits.projects} projects, ${info.limits.managedDbs} managed database${info.limits.managedDbs === 1 ? "" : "s"} included.\n\n` +
|
|
20
|
+
tierLines.join("\n\n") +
|
|
21
|
+
upcoming,
|
|
22
|
+
next: `Use this to answer the user's plan and pricing questions. If they need more capacity (projects, databases, custom domains), share ${info.billingUrl} where they can change plans.`,
|
|
23
|
+
structured: { ...info },
|
|
24
|
+
});
|
|
25
|
+
}
|