@offlocal/mcp 0.1.0 → 0.2.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.
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { createDeployment, markUploadComplete, uploadBundle, } from "../client.js";
3
3
  import { detect } from "../detect.js";
4
+ import { ICON } 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,10 +40,15 @@ 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})`;
39
46
  const summary = {
40
47
  deploymentId: created.deploymentId,
48
+ projectId: created.projectId,
41
49
  appName,
42
50
  framework: detected.framework,
51
+ buildSource,
43
52
  port,
44
53
  bundle: {
45
54
  fileCount: bundle.fileCount,
@@ -54,26 +63,50 @@ export async function deployHandler(input) {
54
63
  content: [
55
64
  {
56
65
  type: "text",
57
- text: `Deployment created and source uploaded.\n\n` +
58
- ` deploymentId: ${summary.deploymentId}\n` +
59
- ` appName: ${summary.appName}\n` +
60
- ` framework: ${summary.framework}\n` +
61
- ` port: ${summary.port}\n` +
62
- ` bundle: ${summary.bundle.fileCount} files, ${summary.bundle.bytes} bytes\n` +
63
- ` expectedUrl: ${summary.expectedUrl}\n\n` +
64
- `WAIT this is a normal, slow operation.\n` +
65
- `Typical end-to-end time: 2-4 minutes (CodeBuild cold start + ECS rollout).\n` +
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` +
66
+ text: `${ICON.rocket} **Deploying \`${summary.appName}\`** source uploaded, build started.\n\n` +
67
+ `| | |\n|---|---|\n` +
68
+ `| ${ICON.web} URL | ${summary.expectedUrl} |\n` +
69
+ `| ${ICON.spark} Framework | ${summary.framework} (${summary.buildSource}) |\n` +
70
+ `| Port | ${summary.port} |\n` +
71
+ `| Bundle | ${summary.bundle.fileCount} files · ${summary.bundle.bytes} bytes |\n\n` +
72
+ `———\n` +
73
+ `${ICON.arrow} **Next (you, the agent):** tell the user it's building (2–4 min) and you'll ` +
74
+ `report back when it's live. Then:\n` +
69
75
  ` 1. Sleep 45 seconds.\n` +
70
- ` 2. Call offlocal_status({ deploymentId: "${summary.deploymentId}" }).\n` +
71
- ` 3. The response will include "pollAfterSeconds" sleep that long, then call status again.\n` +
72
- ` 4. Continue until status is 'live' or 'failed'.\n\n` +
73
- `Tell the user the deploy is in progress and you'll report back when it's live.`,
76
+ ` 2. \`offlocal_status({ deploymentId: "${summary.deploymentId}" })\`.\n` +
77
+ ` 3. Sleep the \`pollAfterSeconds\` it returns, then call status again — exactly once per interval.\n` +
78
+ ` 4. Repeat until \`live\` or \`failed\`. Polling faster does NOT speed up the build.\n` +
79
+ `Keep the **projectId** from the live status for any database step.`,
80
+ },
81
+ ],
82
+ structuredContent: summary,
83
+ };
84
+ }
85
+ function needsDockerfile(sourceDir, port) {
86
+ const summary = {
87
+ status: "needs_dockerfile",
88
+ sourceDir,
89
+ suggestedPort: port,
90
+ };
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: `I couldn't confidently detect this project's stack, so I didn't upload anything yet.\n\n` +
96
+ `To deploy it, add a Dockerfile to the project root, then run offlocal_deploy again.\n\n` +
97
+ `Write a production Dockerfile at:\n` +
98
+ ` ${sourceDir}/Dockerfile\n\n` +
99
+ `Requirements for it to work on Offlocal:\n` +
100
+ ` - It must build the app and end with a CMD or ENTRYPOINT that starts the server.\n` +
101
+ ` - The server must listen on the port in the PORT environment variable\n` +
102
+ ` (default to ${port} if PORT is unset), bound to 0.0.0.0 - not localhost.\n` +
103
+ ` - EXPOSE that same port.\n` +
104
+ ` - Use a multi-stage build for compiled languages to keep the image small.\n\n` +
105
+ `Base the Dockerfile on the actual files in the project (language, package\n` +
106
+ `manager, build and start commands). Once it's saved, call offlocal_deploy again\n` +
107
+ `with the same sourceDir - Offlocal will use your Dockerfile as-is.`,
74
108
  },
75
109
  ],
76
110
  structuredContent: summary,
77
111
  };
78
112
  }
79
- //# sourceMappingURL=deploy.js.map
@@ -35,4 +35,3 @@ export async function logsHandler(input) {
35
35
  structuredContent: { logs },
36
36
  };
37
37
  }
38
- //# sourceMappingURL=logs.js.map
@@ -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) not localhost.\n" +
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,331 @@
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 show = `${ICON.search} **Offlocal.ai — deployment plan**\n\n` +
120
+ `**Detected:** ${detectedLine}\n\n` +
121
+ `**Plan**\n${planLines.join("\n")}\n` +
122
+ wiringBlock +
123
+ `\n**Setup checklist** ${progressBar(0, checklist.length)}\n${checklistLines.join("\n")}\n\n` +
124
+ `Reply **"go"** to ship it ${ICON.rocket} — or switch any line ` +
125
+ `(e.g. _"use Supabase for the database"_, _"host the API on Railway"_).`;
126
+ const agentNote = `When the user switches a line, call \`offlocal_set_preference\` ` +
127
+ `(capability \`${multi ? "hosting:<component> or postgres/…" : "hosting/postgres/…"}\`) then re-run \`offlocal_plan\`.\n\n` +
128
+ `**Execution order once they confirm — walk the checklist top to bottom, ` +
129
+ `re-showing it with ${ICON.done} on finished steps so they see live progress:**\n` +
130
+ executionSteps(items, links, dbTarget).join("\n");
131
+ return ui({
132
+ show,
133
+ next: `Show the plan above to the user and wait for "go" (or an edit). Do NOT deploy or provision anything yet.`,
134
+ agentNote,
135
+ structured: {
136
+ multiComponent: multi,
137
+ components,
138
+ requirements,
139
+ items,
140
+ links,
141
+ checklist,
142
+ dbTargetComponent: dbTarget?.name ?? null,
143
+ },
144
+ });
145
+ }
146
+ function buildChecklist(items, links, dbTarget) {
147
+ const out = [];
148
+ let n = 0;
149
+ const hosting = items.filter((it) => it.kind === "hosting");
150
+ const resources = items.filter((it) => it.kind === "resource");
151
+ for (const it of hosting) {
152
+ n += 1;
153
+ out.push({
154
+ n,
155
+ kind: "hosting",
156
+ text: `Deploy ${labelShort(it)} → ${it.recommendedLabel}`,
157
+ });
158
+ }
159
+ for (const it of resources) {
160
+ n += 1;
161
+ const connect = it.needsConnect ? ` (connect ${it.recommendedLabel} first)` : "";
162
+ out.push({
163
+ n,
164
+ kind: "resource",
165
+ text: `Provision ${it.label} → ${it.recommendedLabel}${connect}`,
166
+ });
167
+ }
168
+ for (const l of links) {
169
+ n += 1;
170
+ out.push({
171
+ n,
172
+ kind: "link",
173
+ text: `Wire \`${l.web}\` → \`${l.api}\` URL (set ${l.envVar})`,
174
+ });
175
+ }
176
+ const redeploy = new Set();
177
+ if (resources.length && hosting.length)
178
+ redeploy.add(dbTarget?.name);
179
+ for (const l of links)
180
+ redeploy.add(l.web);
181
+ if (redeploy.size) {
182
+ n += 1;
183
+ out.push({
184
+ n,
185
+ kind: "redeploy",
186
+ text: `Redeploy ${[...redeploy].map(humanComp).join(" & ")} to apply injected env`,
187
+ });
188
+ }
189
+ return out;
190
+ }
191
+ function projName(name) {
192
+ if (!name || name === "root" || name === "app")
193
+ return "the app";
194
+ return name;
195
+ }
196
+ function executionSteps(items, links, dbTarget) {
197
+ const lines = [];
198
+ const hosting = items.filter((it) => it.kind === "hosting");
199
+ const resources = items.filter((it) => it.kind === "resource");
200
+ for (const it of hosting) {
201
+ if (it.isOfflocal) {
202
+ lines.push(`• ${ICON.rocket} Deploy ${labelShort(it)}: \`offlocal_deploy({ sourceDir: "${it.componentPath || "<root>"}" })\` → poll \`offlocal_status\` until live → note the **projectId** and **live URL**.`);
203
+ }
204
+ else if (it.recommended) {
205
+ 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.)`}`);
206
+ }
207
+ }
208
+ for (const it of resources) {
209
+ if (it.isOfflocal) {
210
+ lines.push(`• ${ICON.db} ${it.label} (Offlocal Managed Postgres): \`offlocal_create_managed_database({ projectId: <${projName(dbTarget?.name)} projectId> })\`. DATABASE_URL is wired in automatically.`);
211
+ }
212
+ else {
213
+ lines.push(`• ${ICON.db} ${it.label} on ${it.recommendedLabel}: ` +
214
+ (it.needsConnect
215
+ ? `\`offlocal_connect\` → share link → poll \`offlocal_list_connections\` until connected, then `
216
+ : "") +
217
+ `\`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.`);
218
+ }
219
+ }
220
+ for (const l of links) {
221
+ lines.push(`• ${ICON.plug} Wire \`${l.web}\` → \`${l.api}\`: once \`${l.api}\` is live, take its URL from \`offlocal_status\` and ` +
222
+ `\`offlocal_set_env({ projectId: <${l.web} projectId>, vars: { "${l.envVar}": "<${l.api} live URL>" } })\`.` +
223
+ (l.buildTime ? ` _(build-time var — only applies on a fresh deploy)_` : ""));
224
+ }
225
+ const redeploy = new Set();
226
+ if (resources.length && hosting.length)
227
+ redeploy.add(dbTarget?.name);
228
+ for (const l of links)
229
+ redeploy.add(l.web);
230
+ if (redeploy.size) {
231
+ lines.push(`• ${ICON.spark} **Redeploy** ${[...redeploy].map(humanComp).join(" & ")} (\`offlocal_deploy\` on each sourceDir) so the injected env (DATABASE_URL / API URL) takes effect.`);
232
+ }
233
+ return lines;
234
+ }
235
+ function humanComp(name) {
236
+ if (!name || name === "root" || name === "app")
237
+ return "the app";
238
+ return `\`${name}\``;
239
+ }
240
+ function labelShort(it) {
241
+ return humanComp(it.componentName);
242
+ }
243
+ function buildItem(args) {
244
+ const { prefCapability, capability, kind, label, preferences, options, connected, component, reason } = args;
245
+ const native = OFFLOCAL_NATIVE[capability];
246
+ const offlocalAvailable = Boolean(native);
247
+ const pref = preferences[prefCapability];
248
+ const prefOption = options.find((o) => o.provider === pref);
249
+ const prefComingSoon = Boolean(prefOption && prefOption.status === "coming_soon");
250
+ const prefValid = Boolean(pref &&
251
+ !prefComingSoon &&
252
+ (pref === "offlocal" ? offlocalAvailable : Boolean(prefOption)));
253
+ const recommended = prefValid && pref
254
+ ? pref
255
+ : offlocalAvailable
256
+ ? "offlocal"
257
+ : (options.find((o) => o.connected)?.provider ??
258
+ options.find((o) => o.connectable)?.provider ??
259
+ null);
260
+ const isOfflocal = recommended === "offlocal";
261
+ const recommendedLabel = !recommended
262
+ ? "— no platform available —"
263
+ : isOfflocal
264
+ ? native.label
265
+ : (options.find((o) => o.provider === recommended)?.label ?? recommended);
266
+ const cost = isOfflocal
267
+ ? native.cost
268
+ : recommended
269
+ ? (PROVIDER_COST[recommended] ?? "provider pricing")
270
+ : "";
271
+ const needsConnect = Boolean(recommended) && !isOfflocal && !connected.has(recommended);
272
+ const note = prefComingSoon
273
+ ? `${prefOption?.label ?? pref} support is coming soon`
274
+ : undefined;
275
+ return {
276
+ prefCapability,
277
+ capability,
278
+ kind,
279
+ label,
280
+ componentName: component?.name,
281
+ componentPath: component?.absPath,
282
+ recommended,
283
+ recommendedLabel,
284
+ fromPreference: prefValid,
285
+ needsConnect,
286
+ isOfflocal,
287
+ cost,
288
+ reason,
289
+ note,
290
+ options: [
291
+ ...(offlocalAvailable
292
+ ? [{ platform: "offlocal", label: native.label, status: "available" }]
293
+ : []),
294
+ ...options.map((o) => ({ platform: o.provider, label: o.label, status: o.status })),
295
+ ],
296
+ };
297
+ }
298
+ export const SetPreferenceInputShape = {
299
+ capability: z
300
+ .string()
301
+ .min(1)
302
+ .max(40)
303
+ .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).'),
304
+ platform: z
305
+ .string()
306
+ .min(1)
307
+ .describe('The platform: "offlocal", "supabase", "planetscale", "digitalocean", "railway", or "vercel".'),
308
+ };
309
+ export async function setPreferenceHandler(input) {
310
+ const platform = input.platform.toLowerCase();
311
+ let comingSoon = false;
312
+ if (platform !== "offlocal") {
313
+ const conns = await listConnections().catch(() => []);
314
+ const meta = conns.find((c) => c.provider === platform);
315
+ comingSoon = meta?.availability === "coming_soon";
316
+ }
317
+ await setPreference(input.capability, platform);
318
+ if (comingSoon) {
319
+ return ui({
320
+ show: `${ICON.soon} **${platform}** support is coming soon. I've saved it for **${input.capability}** ` +
321
+ `and will switch to it automatically once it's available.`,
322
+ next: `Re-run \`offlocal_plan({ sourceDir })\` to see the updated plan.`,
323
+ structured: { capability: input.capability, platform, comingSoon: true },
324
+ });
325
+ }
326
+ return ui({
327
+ show: `${ICON.done} Saved — **${input.capability} → ${platform}**. I'll default to this from now on.`,
328
+ next: `Re-run \`offlocal_plan({ sourceDir })\` to show the user the updated plan.`,
329
+ structured: { capability: input.capability, platform },
330
+ });
331
+ }
@@ -0,0 +1,223 @@
1
+ import { z } from "zod";
2
+ import { createManagedDatabase, getConnectLink, listConnections, pollProvision, provisionResource, resolveRequirement, setProjectEnv, } from "../client.js";
3
+ import { ICON } from "../ui.js";
4
+ function text(t, structured) {
5
+ return {
6
+ content: [{ type: "text", text: t }],
7
+ structuredContent: structured,
8
+ };
9
+ }
10
+ export const ListConnectionsInputShape = {};
11
+ export async function listConnectionsHandler() {
12
+ const providers = await listConnections();
13
+ const lines = providers.map((p) => {
14
+ const icon = p.status === "connected"
15
+ ? ICON.connected
16
+ : p.availability === "coming_soon"
17
+ ? "🔜"
18
+ : p.configured
19
+ ? ICON.open
20
+ : "⛔";
21
+ return (` ${icon} **${p.label}** (${p.provider}) — ${p.status}` +
22
+ (p.account?.name ? ` · ${p.account.name}` : "") +
23
+ (p.capabilities.length ? ` · _${p.capabilities.join(", ")}_` : ""));
24
+ });
25
+ return {
26
+ content: [
27
+ {
28
+ type: "text",
29
+ text: `${ICON.plug} **Provider connections**\n${lines.join("\n")}`,
30
+ },
31
+ ],
32
+ structuredContent: { providers },
33
+ };
34
+ }
35
+ export const ResolveRequirementInputShape = {
36
+ need: z
37
+ .enum([
38
+ "hosting",
39
+ "postgres",
40
+ "mysql",
41
+ "redis",
42
+ "object_storage",
43
+ "email",
44
+ "domain",
45
+ ])
46
+ .describe("The capability the app needs (e.g. 'postgres' for a Postgres database)."),
47
+ };
48
+ export async function resolveRequirementHandler(input) {
49
+ const { need, options, pasteEnvVar } = await resolveRequirement(input.need);
50
+ if (options.length === 0) {
51
+ return {
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: `No Offlocal providers can supply "${need}" yet.\n` +
56
+ `Ask the user to paste a value for it as an env var instead.`,
57
+ },
58
+ ],
59
+ structuredContent: { need, options, pasteEnvVar },
60
+ };
61
+ }
62
+ const lines = options.map((o) => {
63
+ const tag = o.status === "connected"
64
+ ? "connected ✓"
65
+ : o.connectable
66
+ ? "connect required"
67
+ : o.status === "coming_soon"
68
+ ? "coming soon"
69
+ : "not configured";
70
+ return ` - ${o.label} (${o.provider}) - ${tag}`;
71
+ });
72
+ return {
73
+ content: [
74
+ {
75
+ 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` +
78
+ `If they pick a provider that says "connect required", call offlocal_connect ` +
79
+ `with that provider to get a link for them to authorize. If one is already ` +
80
+ `connected, you can go straight to offlocal_provision.\n` +
81
+ `Alternative: ${pasteEnvVar.hint}`,
82
+ },
83
+ ],
84
+ structuredContent: { need, options, pasteEnvVar },
85
+ };
86
+ }
87
+ export const ConnectInputShape = {
88
+ provider: z
89
+ .string()
90
+ .min(1)
91
+ .describe("The provider to connect, e.g. 'supabase' (from offlocal_resolve_requirement options)."),
92
+ };
93
+ export async function connectHandler(input) {
94
+ const { provider, url } = await getConnectLink(input.provider);
95
+ return {
96
+ content: [
97
+ {
98
+ type: "text",
99
+ text: `${ICON.plug} **Connect ${provider}** — first time only (~30s)\n\n` +
100
+ `Open this link to authorize:\n\n${url}\n\n` +
101
+ `You'll be redirected back when it's done.\n\n` +
102
+ `———\n` +
103
+ `${ICON.arrow} **Next (you, the agent):** share the link above, then wait. Poll ` +
104
+ `\`offlocal_list_connections\` no faster than every 5s. When **${provider}** shows ` +
105
+ `${ICON.connected} connected, continue to \`offlocal_provision\`. You cannot ` +
106
+ `complete the OAuth yourself.`,
107
+ },
108
+ ],
109
+ structuredContent: { provider, url, pollWith: "offlocal_list_connections" },
110
+ };
111
+ }
112
+ export const ProvisionInputShape = {
113
+ provider: z.string().min(1).describe("The connected provider to provision on."),
114
+ type: z
115
+ .enum(["project", "database", "deployment", "env_vars", "branch"])
116
+ .describe("The kind of resource to create."),
117
+ confirm: z
118
+ .boolean()
119
+ .optional()
120
+ .describe("Leave false/omitted to get a PREVIEW + cost (no charge). Set true ONLY after the user explicitly confirmed the cost — this creates a real, billable resource."),
121
+ projectId: z
122
+ .string()
123
+ .optional()
124
+ .describe("The Offlocal projectId (from offlocal_deploy). If set, the resulting DATABASE_URL is stored on that project and auto-injected on its next deploy."),
125
+ name: z.string().max(128).optional(),
126
+ region: z.string().max(64).optional(),
127
+ framework: z.string().max(64).optional(),
128
+ repo: z.string().max(256).optional(),
129
+ envVars: z.record(z.string()).optional(),
130
+ metadata: z.record(z.unknown()).optional(),
131
+ };
132
+ export async function provisionHandler(input) {
133
+ const { provider, confirm, projectId, ...spec } = input;
134
+ const res = await provisionResource(provider, spec, {
135
+ confirm: confirm ?? false,
136
+ projectId,
137
+ });
138
+ if (res.error || res.action?.status === "failed") {
139
+ return text(`${ICON.fail} Provisioning ${spec.type} on ${provider} failed: ${res.error ?? res.result?.error ?? "unknown error"}`, res);
140
+ }
141
+ const result = res.result;
142
+ if (!result)
143
+ return text(`Provision ${spec.type} on ${provider}: no result.`, res);
144
+ if (res.preview || result.status === "preview") {
145
+ const cost = result.cost ? `\n${ICON.spark} **Cost:** ${result.cost.note}` : "";
146
+ return text(`${ICON.warn} **PREVIEW — nothing created yet, no charge.**\n${result.preview.summary}${cost}\n\n` +
147
+ `———\n` +
148
+ `${ICON.arrow} **Next (you, the agent):** show the user exactly what this creates AND the cost, ` +
149
+ `and get explicit confirmation. Only then call \`offlocal_provision\` again with ` +
150
+ `\`confirm: true\` and \`projectId\` (to auto-inject the DATABASE_URL).`, res);
151
+ }
152
+ if (result.status === "provisioning") {
153
+ return text(`${ICON.busy} **Provisioning on ${provider}** (a few minutes).\n\n` +
154
+ `———\n` +
155
+ `${ICON.arrow} **Next (you, the agent):** tell the user it's being created and you'll continue ` +
156
+ `when it's online. Poll \`offlocal_provision_status({ actionId: "${res.action?.id}" })\` every ~20s ` +
157
+ `until it reports ready.`, res);
158
+ }
159
+ const envKeys = result.envVars ? Object.keys(result.envVars) : [];
160
+ return text(`${ICON.done} ${result.preview.summary}` +
161
+ (envKeys.length ? `\nProduced: ${envKeys.join(", ")}.` : "") +
162
+ (res.envStored
163
+ ? `\n${ICON.spark} Stored on the project — **redeploy** to inject it.`
164
+ : envKeys.length
165
+ ? `\n(Pass \`projectId\` next time to auto-inject these.)`
166
+ : ""), res);
167
+ }
168
+ export const ProvisionStatusInputShape = {
169
+ actionId: z
170
+ .string()
171
+ .min(1)
172
+ .describe("The action id offlocal_provision returned when status was 'provisioning'."),
173
+ };
174
+ export async function provisionStatusHandler(input) {
175
+ const res = await pollProvision(input.actionId);
176
+ const result = res.result;
177
+ if (res.error || res.action?.status === "failed") {
178
+ return text(`${ICON.fail} Provisioning failed: ${res.error ?? result?.error ?? "unknown"}`, res);
179
+ }
180
+ if (res.action?.status === "succeeded" || result?.status === "succeeded") {
181
+ const envKeys = result?.envVars ? Object.keys(result.envVars) : [];
182
+ return text(`${ICON.done} **Ready.**${envKeys.length ? ` Produced: ${envKeys.join(", ")}.` : ""}` +
183
+ `${res.envStored ? ` ${ICON.spark} Stored on the project — **redeploy** to inject it.` : ""}`, res);
184
+ }
185
+ return text(`${ICON.busy} Still provisioning… ${result?.preview.summary ?? ""} Poll again in ~20s.`, res);
186
+ }
187
+ export const CreateManagedDatabaseInputShape = {
188
+ projectId: z
189
+ .string()
190
+ .min(1)
191
+ .describe("The Offlocal projectId (from offlocal_deploy) to attach an Offlocal Managed Postgres database to."),
192
+ };
193
+ export const SetEnvInputShape = {
194
+ projectId: z
195
+ .string()
196
+ .min(1)
197
+ .describe("The Offlocal projectId (from offlocal_deploy) to set env vars on."),
198
+ vars: z
199
+ .record(z.string())
200
+ .describe('Env vars to set, e.g. { "NEXT_PUBLIC_API_URL": "https://my-api.offlocal.ai" }. ' +
201
+ "Keys are normalized to UPPER_SNAKE_CASE. Stored encrypted, injected on the next deploy."),
202
+ };
203
+ export async function setEnvHandler(input) {
204
+ const res = await setProjectEnv(input.projectId, input.vars);
205
+ if (res.error) {
206
+ return text(`${ICON.fail} Couldn't set env vars: ${res.message ?? res.error}.`, res);
207
+ }
208
+ const keys = Object.keys(input.vars).map((k) => k.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_"));
209
+ return text(`${ICON.done} Set ${keys.length} env var${keys.length === 1 ? "" : "s"} (${keys.join(", ")}). ` +
210
+ `${ICON.spark} Stored encrypted — **redeploy** this project to apply ` +
211
+ `(build-time vars like \`NEXT_PUBLIC_*\` only take effect on a fresh build).`, res);
212
+ }
213
+ export async function createManagedDatabaseHandler(input) {
214
+ const res = await createManagedDatabase(input.projectId);
215
+ if (res.error) {
216
+ const upsell = res.error === "plan_not_allowed" || res.error === "limit_reached"
217
+ ? " Tell the user this needs a Hobby+ plan or they've hit their database limit — they can upgrade at /billing."
218
+ : "";
219
+ return text(`${ICON.fail} Couldn't create the managed database: ${res.message ?? res.error}.${upsell}`, res);
220
+ }
221
+ return text(`${ICON.db} **Offlocal Managed Postgres created** (${res.database?.status}). ` +
222
+ `${ICON.spark} DATABASE_URL is wired into this project automatically — **redeploy** to apply.`, res);
223
+ }