speculos-toolkit 1.0.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 ADDED
@@ -0,0 +1,72 @@
1
+ # speculos-toolkit
2
+
3
+ > Previously published as **speculos-deploy** — same tool, renamed now that it also builds
4
+ > against your linked data connectors. `speculos-deploy` is deprecated; install `speculos-toolkit`.
5
+
6
+ One command to deploy a **frontend** to a live URL — built for coding agents
7
+ (Claude Code, Codex, Cursor, …) and humans alike. No account, no API key, no config.
8
+
9
+ ```bash
10
+ npx -y speculos-toolkit@latest deploy
11
+ ```
12
+
13
+ It auto-detects your frontend, builds it locally, and hosts it at
14
+ `https://user-deployed.speculos.ai/<userId>/<slugUuid>`. Build-step frontends
15
+ (Vite/Next/CRA/…) are built on your machine — only the static output is uploaded, so the
16
+ host never runs your build.
17
+
18
+ > **Frontend hosting is free; every Speculos account includes one backend app free.** Sign in
19
+ > once with `speculos-toolkit login` and a detected backend deploys too (sign up at
20
+ > https://deploy.speculos.ai). Without an account the backend is skipped and the frontend still
21
+ > ships free.
22
+
23
+ ## What it prints
24
+
25
+ The last line of stdout is a single JSON object:
26
+
27
+ ```json
28
+ { "ok": true, "slug": "myapp-1a2b", "userId": "ab12cd34", "status": "success",
29
+ "urls": { "frontend": "https://user-deployed.speculos.ai/ab12cd34/9z8y7x6w/" } }
30
+ ```
31
+
32
+ ## Claude Code skill
33
+
34
+ Install the `/speculos-toolkit` skill once — it grants the deploy command permission (no more
35
+ prompts) and teaches the agent to detect your project, make it deploy-ready, wire the
36
+ frontend's API calls to the deployed backend, and ship it:
37
+
38
+ ```bash
39
+ npx -y speculos-toolkit@latest install-skill
40
+ ```
41
+
42
+ Then run `/speculos-toolkit` in any project. (Use `--project` to install into the current
43
+ repo's `.claude/` instead of your home directory.)
44
+
45
+ ## Identity
46
+
47
+ The first deploy mints a machine-global `~/.speculos/identity.json` (`{ userId, userKey }`)
48
+ that owns every URL deployed from this machine; each project records its slug id in a
49
+ gitignored `.speculos.json`. Keep both to retain ownership.
50
+
51
+ ## Conventions
52
+
53
+ - **Backend** listens on `process.env.PORT`, binds `0.0.0.0`. Node or Python.
54
+ - **Frontend** reads `window.SPECULOS_API_URL` (static) or `VITE_API_URL` /
55
+ `NEXT_PUBLIC_API_URL` / `API_URL` (build). Speculos sets it for you.
56
+
57
+ ## Commands & flags
58
+
59
+ ```
60
+ speculos-toolkit [deploy] detect, pack, deploy ./
61
+ speculos-toolkit detect show what would deploy (no upload)
62
+ speculos-toolkit status <jobId> poll a deployment
63
+ speculos-toolkit teardown --slug <s> remove a deployment
64
+
65
+ --frontend <dir> --backend <dir> --slug <name>
66
+ --runtime node|python --start "<cmd>" --build --output <dir>
67
+ --env KEY=VAL --env-file <file> --api <url> --timeout <sec> --json
68
+ ```
69
+
70
+ Docs: https://deploy.speculos.ai · Source: https://github.com/speculosai/agent_deploy_infra
71
+
72
+ MIT
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const { main } = require("../src/index");
4
+ main(process.argv.slice(2)).then((code) => process.exit(code || 0)).catch((e) => {
5
+ process.stdout.write(JSON.stringify({ ok: false, error: e.message }) + "\n");
6
+ process.exit(1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "speculos-toolkit",
3
+ "version": "1.0.0",
4
+ "description": "The Speculos toolkit for coding agents — deploy any frontend/backend to a live URL and build against your linked data connectors (BigQuery, Postgres, Snowflake, Salesforce, …). Built for Claude Code, Codex, Cursor, and friends.",
5
+ "bin": {
6
+ "speculos-toolkit": "bin/speculos-toolkit.js"
7
+ },
8
+ "type": "commonjs",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "skill",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "deploy",
20
+ "connectors",
21
+ "toolkit",
22
+ "agent",
23
+ "ai",
24
+ "claude",
25
+ "codex",
26
+ "cursor",
27
+ "daytona",
28
+ "speculos"
29
+ ],
30
+ "license": "MIT",
31
+ "homepage": "https://deploy.speculos.ai",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/speculosai/agent_deploy_infra.git"
35
+ }
36
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,347 @@
1
+ ---
2
+ name: speculos-toolkit
3
+ description: Deploy the current project to a live public URL with Speculos, and build against the user's linked data sources (connectors). Builds the frontend locally, hosts it, wires its API calls to the deployed backend, and reports the URLs. Use when the user says "deploy", "ship it", "publish", "put it live", "get me a URL", "deploy to speculos", "deploy the frontend/backend", or wants an app built on their connected data (BigQuery, Postgres, Snowflake, Salesforce, ...). Handles plain static sites and Vite / Next / CRA / Angular / Svelte frontends; deploys Node/Python/Bun backends once the user has signed in (`speculos-toolkit login`) — one backend app is included with every Speculos account (frontend-only needs no account).
4
+ ---
5
+
6
+ # Speculos Toolkit
7
+
8
+ Deploy the project in the current working directory to live URLs. **Frontend hosting is
9
+ free** (`https://user-deployed.speculos.ai/<userId>/<slugUuid>`) and needs no account.
10
+ **Every Speculos account includes one backend app free** — the user signs in once with
11
+ `speculos-toolkit login` (a quick browser approval) and the backend deploys. Without signing
12
+ in, only the frontend ships (a static preview).
13
+
14
+ Do all of the steps below yourself — detect, edit the code to be deploy-ready, run the
15
+ deploy, verify. Don't just tell the user to do it. Don't run `vercel`/`daytona` directly.
16
+
17
+ ## 1. Detect what this project is
18
+
19
+ ```bash
20
+ npx -y speculos-toolkit@latest detect --json
21
+ ```
22
+
23
+ Read the JSON: `detected.frontend` = `{ dir, kind, framework }` where `kind` is `static`
24
+ (serve as-is) or `build` (Vite/Next/CRA/Angular/Svelte); `detected.backend` =
25
+ `{ dir, runtime, startCmd }` (node, python, or bun — a `bun.lockb`/`bunfig.toml` selects
26
+ Bun, which runs TS/JS natively) or `null`. If detection picks the wrong folders, override
27
+ with `--frontend`/`--backend` in step 4 (or force a build dir to serve as-is with `--static`).
28
+
29
+ ### If the project has a backend, ASK the user how to deploy (before you build)
30
+
31
+ Frontends are **free** and need no account; **every Speculos account includes one backend
32
+ app free** (sign in at https://deploy.speculos.ai). So when `detect` finds a backend, DON'T silently
33
+ skip it — ask the user with your question UI (e.g. AskUserQuestion). Tailor the “what won't
34
+ work” line to THIS app (a counter button, a form, login, saved data…). For example:
35
+
36
+ > Speculos hosts frontends for free. Backends need a quick sign-in — every Speculos account
37
+ > includes one backend app free. Without signing in, I can only ship the static page — and
38
+ > the button won't actually count anything.
39
+ >
40
+ > How do you want to deploy this app?
41
+ >
42
+ > 1. **Frontend and backend** — sign in to your Speculos account. I'll run
43
+ > `speculos-toolkit login`, which prints a link; open it, approve. Signing in includes one
44
+ > backend app free, so the backend deploys too.
45
+ > 2. **Frontend only (free)** — ships only the static page; no backend. Mostly a visual preview.
46
+
47
+ - They pick **1** → run `speculos-toolkit login` (step 4, “Link this device”), then deploy.
48
+ - They pick **2**, or aren't signed in → deploy **frontend-only**.
49
+ - No backend in the project → just deploy the frontend; don't ask.
50
+
51
+ > **Connector data dashboards don't need a backend.** If the app just READS from a linked
52
+ > data source (a "pull sales prospects" dashboard, a metrics view), build it **frontend-only**
53
+ > and call the broker directly from the browser (see §3.5 → "a read-only dashboard needs NO
54
+ > backend"). A backend is only for connector **writes** or other server secrets.
55
+
56
+ ## 2. Make the frontend deploy-ready (do these edits)
57
+
58
+ The site is served under a **sub-path** and talks to a **different-origin** backend, so fix
59
+ two things in the frontend source. Keep edits minimal and idempotent.
60
+
61
+ ### 2a. Route EVERY API call through the injected backend URL ← most important
62
+
63
+ Speculos injects the deployed backend's Daytona URL at deploy time. Your job is to make the
64
+ frontend READ that injected value instead of any hard-coded address, so every request hits
65
+ the deployed backend. Search the frontend for hard-coded API bases and rewrite them:
66
+
67
+ - search for: `http://localhost:<port>`, `http://127.0.0.1:<port>`, `:8080`, `:3001`,
68
+ `fetch("http://...")`, `axios.create({ baseURL: ... })`, a `const API_URL = "..."`,
69
+ `.env` values like `VITE_API_URL=http://localhost...`, etc. **Rewrite all of them** — leave
70
+ none hard-coded.
71
+ - replace the base with the framework's injected variable, keeping a localhost fallback for
72
+ local dev:
73
+
74
+ | Frontend kind | Use this as the API base |
75
+ |---|---|
76
+ | Vite | `import.meta.env.VITE_API_URL` |
77
+ | Next | `process.env.NEXT_PUBLIC_API_URL` |
78
+ | CRA | `process.env.REACT_APP_API_URL` |
79
+ | plain static (HTML/JS) | `window.SPECULOS_API_URL` |
80
+
81
+ Examples:
82
+ ```js
83
+ // Vite/Next/CRA — define one base and use it everywhere
84
+ const API = (import.meta.env.VITE_API_URL ?? "http://localhost:8080").replace(/\/$/, "");
85
+ fetch(`${API}/api/things`);
86
+ ```
87
+ ```html
88
+ <!-- plain static site -->
89
+ <script>
90
+ const API = (window.SPECULOS_API_URL ?? "http://localhost:8080").replace(/\/$/, "");
91
+ fetch(API + "/api/things");
92
+ </script>
93
+ ```
94
+
95
+ Net effect: after deploy, the build/runtime has the real Daytona backend URL baked in, so
96
+ **all** API calls go to the deployed backend. (The URL changes per deploy — never hard-code
97
+ the literal Daytona URL; always read the injected variable so re-deploys keep working.)
98
+
99
+ ### 2b. Assets resolve under the sub-path automatically
100
+
101
+ For build frameworks the CLI sets the correct base automatically — including **Next.js**
102
+ (it injects `basePath`/`assetPrefix`, so `_next/*` assets, fonts referenced in CSS, and
103
+ client-side `<Link>` navigation all resolve under the sub-path; and root-absolute
104
+ `/public` asset references — `<img src="/x">`, `next/image`, `fetch("/data.json")` — are
105
+ rewritten to the sub-path in the build output, so they work after client-side re-renders
106
+ too). A Next app must be **static-export-able** (no SSR / API routes / server actions). For plain static sites,
107
+ prefer **relative** asset paths (`./styles.css`); root-absolute refs in HTML are
108
+ auto-rewritten by the host. Don't ship secrets in the frontend — the bundle is public.
109
+
110
+ ## 3. Make the backend deploy-ready (only for a full-stack deploy)
111
+
112
+ Only if deploying a backend (signed in). In the backend code:
113
+
114
+ - Listen on `process.env.PORT` and bind `0.0.0.0` (NOT `127.0.0.1`/`localhost`).
115
+ Speculos runs your app on `$PORT` for you — Node `process.env.PORT`, and for
116
+ Python it sets the framework's host/port (uvicorn/gunicorn/Flask/Django are
117
+ launched bound to `0.0.0.0:$PORT` automatically). Don't hard-code a port.
118
+ - **CORS is handled for you** — Speculos injects permissive CORS at the edge, so the
119
+ cross-origin frontend can call your backend **even if you set none**. You don't need
120
+ to add CORS code (if you do, it's normalized at the edge). Use **Bearer-token** auth
121
+ rather than cross-site cookies.
122
+ - Optional but nice: a `GET /health` returning 200 for a faster readiness check.
123
+
124
+ > **Runtime, resources & lifecycle (tell the user):** backends run on **Node 22 /
125
+ > Python 3.12 / Bun** (auto-detected; pin Node/Python with `.nvmrc` / `.python-version`),
126
+ > capped at **~0.5 vCPU and 512 MB RAM** per app. The app is supervised (auto-restarts on
127
+ > crash, revived after a pause). **Data persists across redeploys** — a local SQLite file
128
+ > (and files under `data/` / `uploads/` / `storage/`) is preserved when you re-`deploy` the
129
+ > same app, because the backend reuses its sandbox (which also keeps the backend URL stable).
130
+ > It's a single sandbox with no backups, so use an external database for anything critical.
131
+ > TypeScript is built automatically (`tsc`/`npm run build`, or run natively under Bun); set a
132
+ > `start` script if it's non-standard.
133
+
134
+ ## 3.5 Connectors: build against the user's real data
135
+
136
+ Speculos accounts (and orgs) can link **data sources** — BigQuery, Snowflake, Salesforce,
137
+ Postgres, … — in the dashboard. When the app needs real data, discover what's linked and
138
+ build against it instead of inventing schemas.
139
+
140
+ ### Check what's linked (do this BEFORE writing data-layer code)
141
+
142
+ ```bash
143
+ npx -y speculos-toolkit@latest connectors list --json
144
+ ```
145
+
146
+ The last stdout line is `{ ok, brokerUrl, connectors: [{ alias, name, kind, accountIdentifier, tools: [...] }] }`.
147
+
148
+ - `ok:false` + `code:"NO_TOKEN"` → the device isn't logged in; run `login` (step 4) first.
149
+ - `ok:true` with empty `connectors` → nothing linked (or nothing granted to this user).
150
+ If the app clearly wants external data, tell the user to link a source (or ask their org
151
+ admin for access) at **https://deploy.speculos.ai/dashboard**, then **re-run the list** —
152
+ access is resolved server-side on every call, so a source linked or granted seconds ago
153
+ shows up immediately with no re-login and no session restart. Never block a deploy on this.
154
+ - A `403 NO_ACCESS` on execute means an org admin hasn't granted that source to this user —
155
+ same recovery: ask, then simply retry.
156
+
157
+ ### Live discovery (read-only!)
158
+
159
+ Learn the real schema and a few sample rows before coding. Execute read/list/get/describe
160
+ tools only — never create/update/delete/send tools during discovery. **Always pass
161
+ arguments via `--args-file`** (inline JSON containing `$`, `(`, `)` can trip the
162
+ auto-approval guard):
163
+
164
+ ```bash
165
+ cat > /tmp/args.json << 'EOF'
166
+ { "sql": "SELECT * FROM analytics.orders ORDER BY created_at DESC LIMIT 20" }
167
+ EOF
168
+ npx -y speculos-toolkit@latest connectors exec --connector postgres \
169
+ --tool POSTGRES_QUERY --args-file /tmp/args.json
170
+ ```
171
+
172
+ Pick tools from the `tools` array in `connectors list` (e.g. `POSTGRES_SCHEMA`,
173
+ `GOOGLEBIGQUERY_LIST_DATASETS`, `SALESFORCE_QUERY`). Fetch SMALL samples (`LIMIT 20`,
174
+ narrow ranges) — enough to see real column names and value shapes, never whole datasets.
175
+ Every `exec` call needs a `--connector <alias>` (the broker requires it — a call
176
+ without one is rejected, never silently run against the wrong source).
177
+
178
+ ### Prefer the connector over a direct API key (migrate existing apps)
179
+
180
+ If the project already calls a service **directly** — an Airtable/Stripe/BigQuery
181
+ SDK, a hard-coded API key, a `pg`/`psycopg` connection string in the code — and the
182
+ user has a linked connector for that same service, **rewrite those calls to go
183
+ through the broker** and drop the embedded credential. The connector is safer (the
184
+ secret stays on the platform, rotates, and is access-controlled) and it's what keeps
185
+ working once deployed. For example, an app the user named "airtable-dashboard" that
186
+ imports `airtable` with a PAT: replace the SDK calls with `connector("airtable",
187
+ "AIRTABLE_LIST_RECORDS", {...})` via the helper below, and delete the PAT. Only do
188
+ this for services the user actually has a connector for (check `connectors list`);
189
+ leave other integrations untouched.
190
+
191
+ ### The runtime pipe — a read-only dashboard needs NO backend
192
+
193
+ **Default to frontend-only for read dashboards.** Speculos bakes an app-scoped, **read-only**
194
+ broker token into the deployed frontend, so the browser calls the shared broker **directly** —
195
+ no backend to deploy or maintain. The connector credential (OAuth grant / DB password) never
196
+ leaves the platform, and nothing sensitive lives in your code. Write this helper into the
197
+ frontend and route every connector **read** through it:
198
+
199
+ ```js
200
+ // speculos.js — read-only connector client (safe in the browser; no backend needed)
201
+ const G = typeof window !== "undefined" ? window : {};
202
+ const E = (typeof import.meta !== "undefined" && import.meta.env) || (typeof process !== "undefined" && process.env) || {};
203
+ const BROKER = E.VITE_SPECULOS_CONNECTORS_URL || E.NEXT_PUBLIC_SPECULOS_CONNECTORS_URL || E.REACT_APP_SPECULOS_CONNECTORS_URL || G.SPECULOS_CONNECTORS_URL;
204
+ const TOKEN = E.VITE_SPECULOS_CONNECTORS_TOKEN || E.NEXT_PUBLIC_SPECULOS_CONNECTORS_TOKEN || E.REACT_APP_SPECULOS_CONNECTORS_TOKEN || G.SPECULOS_CONNECTORS_TOKEN;
205
+ export async function connector(alias, tool, args) {
206
+ const gate = document.cookie.split("; ").find((c) => c.startsWith("spec_gate="))?.slice(10);
207
+ const r = await fetch(`${BROKER}/execute`, {
208
+ method: "POST",
209
+ headers: {
210
+ "content-type": "application/json",
211
+ authorization: `Bearer ${TOKEN}`,
212
+ ...(gate ? { "x-speculos-gate": gate } : {}), // forwards the viewer's access for private/org apps
213
+ },
214
+ body: JSON.stringify({ connector: alias, tool, arguments: args || {} }),
215
+ });
216
+ const out = await r.json().catch(() => ({}));
217
+ if (!r.ok || !out.ok) throw new Error(out.error || `connector ${r.status}`);
218
+ return out.data;
219
+ }
220
+ ```
221
+
222
+ Rules (and state to the user):
223
+ - **Read-only only.** The broker rejects any non-read tool on this path (`WRITE_BLOCKED`).
224
+ Use `LIST_`/`GET_`/`SEARCH_`/`QUERY` tools; `POSTGRES_QUERY` runs in a read-only transaction.
225
+ - **Access control is automatic and visibility-driven.** A **private** app requires the viewer
226
+ to be the owner; an **org** app requires org membership — the browser forwards its `spec_gate`
227
+ cookie (the helper does this) and the broker verifies it. A **public** app needs no gate. You
228
+ can promote **private → org → public** from the dashboard, live — **no redeploy**.
229
+ - **Sensitive data → keep it private** (the default for connector-enabled accounts). Only a
230
+ **public** app's data is readable by anyone with the link.
231
+ - Handle `ok:false` gracefully (provider/tool errors are data). Responses cap at 1000 rows / 10s.
232
+
233
+ ### When you DO need a backend
234
+
235
+ Only if the app must **write** to a connector (create/update/send/delete) or holds other
236
+ server secrets. Then deploy a backend; it automatically receives `SPECULOS_CONNECTORS_URL` +
237
+ `SPECULOS_CONNECTORS_TOKEN` (a full-access, server-side token — never put it in the frontend)
238
+ and calls the broker from server code:
239
+
240
+ ```js
241
+ // backend only — full access incl. writes. NEVER import in the frontend.
242
+ const BROKER = process.env.SPECULOS_CONNECTORS_URL;
243
+ const TOKEN = process.env.SPECULOS_CONNECTORS_TOKEN; // injected on deploy; absent locally
244
+ async function connector(alias, tool, args) {
245
+ const r = await fetch(`${BROKER}/execute`, {
246
+ method: "POST",
247
+ headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` },
248
+ body: JSON.stringify({ connector: alias, tool, arguments: args || {} }),
249
+ });
250
+ const out = await r.json().catch(() => ({}));
251
+ if (!r.ok || !out.ok) throw new Error(out.error || `connector ${r.status}`);
252
+ return out.data;
253
+ }
254
+ ```
255
+
256
+ (Locally the injected vars are absent — guard with a clear error or mock. Grants and access
257
+ are re-checked per call, so revoking access applies live to a running app.)
258
+
259
+ ## 4. Deploy
260
+
261
+ Builds run locally (this machine already has the toolchain); only static output is uploaded.
262
+
263
+ - **Frontend only (free):**
264
+ ```bash
265
+ npx -y speculos-toolkit@latest deploy
266
+ ```
267
+ - **Link this device (once):**
268
+ ```bash
269
+ npx -y speculos-toolkit@latest login
270
+ ```
271
+ `login` **blocks while it waits for approval** (up to 10 min), and prints the approval link
272
+ both to stderr and as its FIRST stdout JSON line (`{"action":"login","url":"…","code":"…"}`).
273
+ **Run it in the background** (e.g. Claude Code Bash with `run_in_background: true`, or a long
274
+ `timeout`) so it isn't killed by a default command timeout before the user approves. Read the
275
+ link from that first line and **relay it to the user** — ask them to open it, sign in, and
276
+ click Approve. It links this machine to their account (already linked? it no-ops — pass
277
+ `--relink` to switch accounts). Backend deploys then work with no password — **every
278
+ account includes one backend app free**, so there's no enablement wait. Unlink with
279
+ `speculos-toolkit logout`.
280
+ - **Frontend + backend (signed in):** just run the normal deploy — the saved sign-in
281
+ authorizes the backend:
282
+ ```bash
283
+ npx -y speculos-toolkit@latest deploy
284
+ ```
285
+ The backend deploys to an isolated sandbox first; its Daytona URL is injected into the
286
+ frontend (step 2a), the frontend is built, and both go live.
287
+ (Admins/CI can instead pass `--override <password>` to bypass account auth.)
288
+ - If `detect` got the folders wrong, add `--frontend ./web` and/or `--backend ./api`.
289
+ - Useful flags: `--slug <name>`, `--env KEY=VAL` (repeatable, backend env), `--build`
290
+ (force the frontend through its build step), `--env-file <file>`.
291
+
292
+ The **last line of stdout is one JSON object**:
293
+ ```json
294
+ { "ok": true, "userId": "...", "urls": { "frontend": "https://user-deployed.speculos.ai/...", "backend": "https://...daytonaproxy01.net" } }
295
+ ```
296
+ On `ok:false`, read `error`/`logTail`, fix the cause **once**, and re-run. Do not loop.
297
+
298
+ > **`BACKEND_DISABLED`** means the device isn't signed in to an account yet — run
299
+ > `speculos-toolkit login` to fix it. **`TOO_MANY`** means the account is at its backend-app
300
+ > limit (one is included; more come with Team plans). In both cases the deploy still **ships
301
+ > the frontend** and returns `ok:true` with a `backendNote` — so the user already has a live
302
+ > URL. To ship the backend: for `BACKEND_DISABLED`, sign in; for `TOO_MANY`, take an app
303
+ > offline at https://deploy.speculos.ai/dashboard, redeploy an existing backend app, or talk
304
+ > to our team at https://calendar.app.google/VMGTvK3FmyDMAsix6 about more capacity. Always write the URL as
305
+ > **https://deploy.speculos.ai** — never `speculos.ai`. Don't retry the backend; once signed
306
+ > in / under the limit, re-running `deploy` ships it. Redeploying an app that already has a
307
+ > backend never counts against the limit (it reuses its sandbox).
308
+
309
+ ## 5. Report + verify
310
+
311
+ - Give the user `urls.frontend` (and `urls.backend` if deployed). They can manage their
312
+ published apps and take them offline anytime at https://deploy.speculos.ai/dashboard. The public URL is
313
+ `user-deployed.speculos.ai/<username>/<app-slug>/`, and BOTH segments are renameable there:
314
+ "Edit link" changes an app's slug (the second segment), and the account's "your link name"
315
+ changes the first segment (default a random id) for every app on that device. Accounts on
316
+ Team plans (and beta testers) can also connect their own domain — a **two-step** flow: (1) add the domain, which shows a
317
+ **CNAME** target (`cname-user.speculos.ai`) plus a **TXT** ownership record to publish, then
318
+ (2) click Verify once both are set (we confirm the domain points here AND the TXT proves the
319
+ account controls it before activating). A connected domain serves the SAME app and honors the
320
+ username/app-slug you chose. After any rename, the URL from an older deploy output is stale
321
+ (re-deploys automatically target the new URL) — **warn the user that a complex app (one with
322
+ client-side routing, hashed asset URLs baked at build time, etc.) may need a fresh `deploy`
323
+ to fully pick up a slug/username rename**, since the rename rewrites the already-deployed
324
+ files rather than rebuilding them.
325
+ - Quick check: `curl -sS -o /dev/null -w '%{http_code}\n' <frontendUrl>` → expect `200`.
326
+ - Keep `~/.speculos/identity.json` and the project's gitignored `.speculos.json` — they own
327
+ your URLs; re-deploys reuse the same URL. Don't commit or delete them.
328
+
329
+ ## Notes
330
+
331
+ - Until the user signs in, the backend is skipped (frontend-only). Run `speculos-toolkit login`,
332
+ or point users to https://deploy.speculos.ai.
333
+ - Permission is granted once at skill install, so the deploy command runs without prompting.
334
+ - To remove a deployment: `npx -y speculos-toolkit@latest teardown --slug <slug>` (or use the
335
+ dashboard at https://deploy.speculos.ai/dashboard).
336
+ - **Already have this skill from before connectors existed?** Re-run
337
+ `npx -y speculos-toolkit@latest install-skill` to refresh it (safe to run repeatedly — it
338
+ overwrites the skill file and re-grants the command). The CLI itself is always current
339
+ because every command runs `npx -y speculos-toolkit@latest`.
340
+ - **Sign in without the browser flow:** a user who already has an account (or whose platform
341
+ issues them a token) can paste it: `npx -y speculos-toolkit@latest login --token spec_tok_…`.
342
+ Add `--relink` to move a device that was linked to the wrong account (e.g. a personal one
343
+ instead of the org) onto the token's account. Backends and connectors then resolve under
344
+ that account/org.
345
+ - **Redeploys persist data.** Re-running `deploy` on the same app reuses its sandbox, so a
346
+ local SQLite file and anything under `data/`/`uploads/`/`storage/` survive — the connector
347
+ broker credentials are re-injected each deploy and don't affect on-disk data.
package/src/build.js ADDED
@@ -0,0 +1,197 @@
1
+ // Client-side frontend build. Runs on the user's own machine (which already has
2
+ // the source + toolchain), so the host never executes untrusted code. The built
3
+ // static output is what gets uploaded.
4
+ //
5
+ // Because frontends are hosted under a sub-path (/<userId>/<slugUuid>), we set
6
+ // each framework's base-path knob so asset URLs AND client-side routing resolve
7
+ // correctly, and bake the backend URL into the build via the usual env vars.
8
+ const { spawnSync } = require("child_process");
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+
12
+ function run(cmd, args, cwd, env) {
13
+ // stream build output to stderr so stdout stays a clean JSON channel.
14
+ // On Windows, npm is a .cmd shim — since Node's CVE-2024-27980 fix, spawning a
15
+ // .cmd without shell:true throws EINVAL, so run through the shell there. (Args
16
+ // here are controlled — npm subcommands + a slash/hyphen base path — no spaces.)
17
+ const win = process.platform === "win32";
18
+ const r = spawnSync(cmd, args, { cwd, env, stdio: ["ignore", 2, 2], shell: win });
19
+ if (r.error) throw r.error;
20
+ if (r.status !== 0) { const e = new Error(`${cmd} ${args.join(" ")} exited ${r.status}`); e.code = "BUILD"; throw e; }
21
+ }
22
+
23
+ function apiEnv(backendUrl) {
24
+ if (!backendUrl) return {};
25
+ return {
26
+ VITE_API_URL: backendUrl, NEXT_PUBLIC_API_URL: backendUrl,
27
+ REACT_APP_API_URL: backendUrl, PUBLIC_API_URL: backendUrl, API_URL: backendUrl,
28
+ };
29
+ }
30
+
31
+ // Read-only connector broker URL + token, baked into the build so a data
32
+ // dashboard can call the broker directly (no backend). Exposed under every
33
+ // framework's public-env prefix.
34
+ function connectorEnv(url, token) {
35
+ if (!url || !token) return {};
36
+ const e = {};
37
+ for (const p of ["VITE_", "NEXT_PUBLIC_", "REACT_APP_", "PUBLIC_", ""]) {
38
+ e[`${p}SPECULOS_CONNECTORS_URL`] = url;
39
+ e[`${p}SPECULOS_CONNECTORS_TOKEN`] = token;
40
+ }
41
+ return e;
42
+ }
43
+
44
+ // base is "/<userId>/<slugUuid>/" — extraArgs/env that point each framework at it
45
+ function baseConfig(framework, base) {
46
+ const noSlash = base.replace(/\/$/, "");
47
+ switch (framework) {
48
+ case "vite": return { args: [`--base=${base}`], env: {} };
49
+ case "cra": return { args: [], env: { PUBLIC_URL: noSlash } };
50
+ case "angular": return { args: [`--base-href=${base}`, `--deploy-url=${base}`], env: {} };
51
+ case "svelte": return { args: [], env: { BASE_PATH: noSlash } }; // SvelteKit reads paths.base from this if wired
52
+ default: return { args: [], env: {} };
53
+ }
54
+ }
55
+
56
+ // Next.js needs basePath + assetPrefix set in next.config — it's the only way to
57
+ // make the static export's _next/* asset URLs (incl. @font-face url() inside CSS)
58
+ // AND the client-side router resolve under the sub-path. We can't pass these on
59
+ // the CLI, so we temporarily wrap the user's next.config, build, then restore it.
60
+ // Returns a restore() that always puts the project back the way it was.
61
+ function setupNextConfig(dir, base, log) {
62
+ const noSlash = JSON.stringify(base.replace(/\/$/, ""));
63
+ const over = `basePath: ${noSlash}, assetPrefix: ${noSlash}, output: "export", trailingSlash: true`;
64
+ let pkgEsm = false;
65
+ try { pkgEsm = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).type === "module"; } catch { /* default cjs */ }
66
+
67
+ let found = null;
68
+ for (const ext of ["js", "mjs", "cjs", "ts"]) {
69
+ const p = path.join(dir, `next.config.${ext}`);
70
+ if (fs.existsSync(p)) { found = { p, ext }; break; }
71
+ }
72
+
73
+ // no config -> create a minimal one, remove it after
74
+ if (!found) {
75
+ const esm = pkgEsm;
76
+ const file = path.join(dir, esm ? "next.config.mjs" : "next.config.js");
77
+ const body = `{ ${over}, images: { unoptimized: true } }`;
78
+ fs.writeFileSync(file, esm ? `export default ${body};\n` : `module.exports = ${body};\n`);
79
+ log && log(` next: wrote ${path.basename(file)} (basePath=${base.replace(/\/$/, "")})`);
80
+ return () => { try { fs.unlinkSync(file); } catch { /* ignore */ } };
81
+ }
82
+
83
+ // existing config -> back it up under a name Next won't load, write a wrapper
84
+ const bak = path.join(dir, `next.config.__speculos_orig__.${found.ext}`);
85
+ fs.renameSync(found.p, bak);
86
+ const imp = `./next.config.__speculos_orig__.${found.ext}`;
87
+ const esm = found.ext === "mjs" || found.ext === "ts" || (found.ext === "js" && pkgEsm);
88
+ const merge =
89
+ ` const m = orig && orig.default ? orig.default : orig;\n` +
90
+ ` const o = (typeof m === "function" ? await m(phase, ctx) : m) || {};\n` +
91
+ ` return { ...o, ${over}, images: { ...(o.images || {}), unoptimized: true } };\n`;
92
+ const wrapper = esm
93
+ ? `import orig from ${JSON.stringify(imp)};\nexport default async (phase, ctx) => {\n${merge}};\n`
94
+ : `const orig = require(${JSON.stringify(imp)});\nmodule.exports = async (phase, ctx) => {\n${merge}};\n`;
95
+ fs.writeFileSync(found.p, wrapper);
96
+ log && log(` next: set basePath/assetPrefix=${base.replace(/\/$/, "")} (wrapped ${path.basename(found.p)})`);
97
+ return () => { try { fs.unlinkSync(found.p); fs.renameSync(bak, found.p); } catch { /* ignore */ } };
98
+ }
99
+
100
+ // Frameworks prefix THEIR OWN bundled assets under the sub-path, but root-absolute
101
+ // references to /public files (e.g. `<img src="/images/x">`, `next/image` with a
102
+ // /public src, `fetch("/data.json")`) are NOT prefixed and break under a sub-path —
103
+ // especially after a client-side re-render. Fix it at the source: after the build,
104
+ // rewrite every reference that EXACTLY matches a real asset file in the output to
105
+ // prepend the base path, across HTML/CSS/JS. Matching only real files (with quote/
106
+ // paren delimiters) means it can't false-match or double-prefix already-based refs.
107
+ function rewritePublicAssetPaths(outDir, base, log) {
108
+ const noSlash = base.replace(/\/$/, "");
109
+ const TEXT = /\.(html?|js|mjs|cjs|css|json|txt|xml|svg|webmanifest)$/i;
110
+
111
+ // 1) collect root-absolute paths of real asset files (skip _next/* — already
112
+ // prefixed by assetPrefix — and route .html files).
113
+ const assets = [];
114
+ (function walk(d, rel) {
115
+ for (const name of fs.readdirSync(d)) {
116
+ const abs = path.join(d, name), r = rel + "/" + name;
117
+ let st; try { st = fs.statSync(abs); } catch { continue; }
118
+ if (st.isDirectory()) { if (r === "/_next") continue; walk(abs, r); }
119
+ else if (!/\.html?$/i.test(name)) assets.push(r);
120
+ }
121
+ })(outDir, "");
122
+ if (!assets.length) return;
123
+
124
+ // 2) one regex, longest paths first; require a delimiter before and after so we
125
+ // only touch real references and never a substring of an already-based path.
126
+ assets.sort((a, b) => b.length - a.length);
127
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
128
+ const re = new RegExp(`(["'(=])(${assets.map(esc).join("|")})(?=["')?#\\s\\\\>])`, "g");
129
+
130
+ // 3) rewrite text files in place (the output is a build artifact, not the source).
131
+ let changed = 0;
132
+ (function walk2(d) {
133
+ for (const name of fs.readdirSync(d)) {
134
+ const abs = path.join(d, name);
135
+ let st; try { st = fs.statSync(abs); } catch { continue; }
136
+ if (st.isDirectory()) walk2(abs);
137
+ else if (TEXT.test(name)) {
138
+ const s = fs.readFileSync(abs, "utf8");
139
+ const n = s.replace(re, (_m, d1, a) => d1 + noSlash + a);
140
+ if (n !== s) { fs.writeFileSync(abs, n); changed++; }
141
+ }
142
+ }
143
+ })(outDir);
144
+ log && log(` rewrote root-absolute refs to ${assets.length} public asset(s) under the sub-path (${changed} file(s))`);
145
+ }
146
+
147
+ function findOutput(dir, preferred) {
148
+ const candidates = [preferred, "dist", "build", "out", ".output/public", ".svelte-kit/output/client", "_site"].filter(Boolean);
149
+ for (const c of candidates) {
150
+ const p = path.join(dir, c);
151
+ try { if (fs.statSync(p).isDirectory() && fs.readdirSync(p).length) return p; } catch { /* next */ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+ // Returns the absolute path of the built static output directory.
157
+ function runBuild({ dir, framework, buildCmd, base, backendUrl, connectorsUrl, connectorsToken, outputDir, log }) {
158
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
159
+ if (framework && !["vite", "cra", "angular", "next"].includes(framework)) {
160
+ log && log(` note: ${framework} sub-path base is best-effort — the host rewrites root-absolute refs in HTML, but JS-imported assets may still need a relative base if they 404`);
161
+ }
162
+ // 1) install deps (need devDependencies → never NODE_ENV=production here)
163
+ const installEnv = Object.assign({}, process.env); delete installEnv.NODE_ENV;
164
+ const lock = fs.existsSync(path.join(dir, "package-lock.json"));
165
+ log && log(` installing dependencies…`);
166
+ run(npm, [lock ? "ci" : "install", "--no-audit", "--no-fund"], dir, installEnv);
167
+
168
+ // 2) build with base path + backend URL baked in
169
+ const bc = baseConfig(framework, base);
170
+ const buildEnv = Object.assign({}, process.env, apiEnv(backendUrl), connectorEnv(connectorsUrl, connectorsToken), bc.env);
171
+ const args = ["run", "build"];
172
+ if (bc.args.length) args.push("--", ...bc.args); // forward base args after `--`
173
+
174
+ // Next.js: wrap next.config with basePath/assetPrefix, restore no matter what.
175
+ const restore = framework === "next" ? setupNextConfig(dir, base, log) : null;
176
+ try {
177
+ log && log(` building (${framework})…`);
178
+ run(npm, args, dir, buildEnv);
179
+ } finally {
180
+ if (restore) restore();
181
+ }
182
+
183
+ // 3) locate the output
184
+ const out = findOutput(dir, outputDir);
185
+ if (!out) {
186
+ if (framework === "next") { const e = new Error("Next.js produced no static export (out/). The app likely uses server features (SSR/API routes/server actions) that a static host can't run."); e.code = "NEXT_NOT_EXPORTED"; throw e; }
187
+ const e = new Error(`build finished but no output dir found (looked for ${outputDir || "dist/build/out"})`); e.code = "BUILD_OUTPUT"; throw e;
188
+ }
189
+
190
+ // 4) prepend the sub-path to any root-absolute /public asset refs the framework
191
+ // left un-prefixed (fixes e.g. next/image client re-renders).
192
+ try { rewritePublicAssetPaths(out, base, log); }
193
+ catch (e) { log && log(` note: public-asset rewrite skipped (${e.message})`); }
194
+ return out;
195
+ }
196
+
197
+ module.exports = { runBuild };