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 +72 -0
- package/bin/speculos-toolkit.js +7 -0
- package/package.json +36 -0
- package/skill/SKILL.md +347 -0
- package/src/build.js +197 -0
- package/src/client.js +55 -0
- package/src/creds.js +83 -0
- package/src/detect.js +129 -0
- package/src/index.js +547 -0
- package/src/pack.js +46 -0
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 };
|