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/src/index.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// speculos-toolkit CLI. Machine-readable JSON on stdout, human progress on stderr.
|
|
2
|
+
//
|
|
3
|
+
// Flow: allocate identity + slugUuid -> deploy backend to Daytona (if any) ->
|
|
4
|
+
// build the frontend locally with the backend URL baked in -> upload the static
|
|
5
|
+
// output to the host. Backends run on Daytona; frontends are hosted on EC2 at
|
|
6
|
+
// user-deployed.speculos.ai/<userId>/<slugUuid>/.
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const os = require("os");
|
|
10
|
+
const { detect } = require("./detect");
|
|
11
|
+
const { packDir, BACKEND_DIRS } = require("./pack");
|
|
12
|
+
const { runBuild } = require("./build");
|
|
13
|
+
const client = require("./client");
|
|
14
|
+
const creds = require("./creds");
|
|
15
|
+
|
|
16
|
+
const VERSION = require("../package.json").version;
|
|
17
|
+
|
|
18
|
+
// ---- arg parsing --------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const opts = { env: {}, _: [] };
|
|
22
|
+
const valueFlags = {
|
|
23
|
+
"--frontend": "frontend", "--backend": "backend", "--slug": "slug",
|
|
24
|
+
"--start": "start", "--runtime": "runtime", "--output": "output",
|
|
25
|
+
"--api": "api", "--timeout": "timeout", "--env-file": "envFile",
|
|
26
|
+
"--override": "override",
|
|
27
|
+
"--connector": "connector", "--tool": "tool", "--args": "args", "--args-file": "argsFile",
|
|
28
|
+
"--token": "pasteToken",
|
|
29
|
+
};
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const a = argv[i];
|
|
32
|
+
if (a === "--env") { const kv = argv[++i] || ""; const j = kv.indexOf("="); if (j > 0) opts.env[kv.slice(0, j)] = kv.slice(j + 1); }
|
|
33
|
+
else if (valueFlags[a]) opts[valueFlags[a]] = argv[++i];
|
|
34
|
+
else if (a === "--build") opts.build = true;
|
|
35
|
+
else if (a === "--static" || a === "--no-build") opts.static = true;
|
|
36
|
+
else if (a === "--relink" || a === "--force") opts.relink = true;
|
|
37
|
+
else if (a === "--project") opts.project = true;
|
|
38
|
+
else if (a === "--no-backend") opts.noBackend = true;
|
|
39
|
+
else if (a === "--no-frontend") opts.noFrontend = true;
|
|
40
|
+
else if (a === "--json") opts.json = true;
|
|
41
|
+
else if (a === "--help" || a === "-h") opts.help = true;
|
|
42
|
+
else if (a === "--version" || a === "-v") opts.version = true;
|
|
43
|
+
else opts._.push(a);
|
|
44
|
+
}
|
|
45
|
+
if (opts.envFile) {
|
|
46
|
+
// Surface a bad --env-file instead of silently shipping the backend with no
|
|
47
|
+
// env — a missing/unreadable file otherwise fails later as an opaque timeout.
|
|
48
|
+
try {
|
|
49
|
+
for (const line of fs.readFileSync(opts.envFile, "utf8").split("\n")) {
|
|
50
|
+
const t = line.trim(); if (!t || t.startsWith("#")) continue;
|
|
51
|
+
const j = t.indexOf("="); if (j > 0) opts.env[t.slice(0, j).trim()] = t.slice(j + 1).trim();
|
|
52
|
+
}
|
|
53
|
+
} catch (e) { opts.envFileError = `could not read --env-file ${opts.envFile}: ${e.message}`; }
|
|
54
|
+
}
|
|
55
|
+
return opts;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function log(opts, msg) { if (!opts.json) process.stderr.write(msg + "\n"); }
|
|
59
|
+
function emit(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); }
|
|
60
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
61
|
+
|
|
62
|
+
const HELP = `speculos-toolkit v${VERSION} — deploy + data connectors for coding agents
|
|
63
|
+
|
|
64
|
+
USAGE
|
|
65
|
+
npx speculos-toolkit [deploy] detect, build, and deploy ./ (frontend and/or backend)
|
|
66
|
+
npx speculos-toolkit detect show what would be deployed (no upload)
|
|
67
|
+
npx speculos-toolkit status <jobId> poll a backend deployment
|
|
68
|
+
npx speculos-toolkit teardown --slug <slug>
|
|
69
|
+
npx speculos-toolkit login link this machine to your Speculos account
|
|
70
|
+
(browser approve) so backend deploys just work
|
|
71
|
+
--token <spec_tok_…> link with a token you already
|
|
72
|
+
have (no browser); add --relink to move a device
|
|
73
|
+
already linked to a different/personal account
|
|
74
|
+
npx speculos-toolkit logout unlink this machine (revoke its token)
|
|
75
|
+
npx speculos-toolkit install-skill install the Claude Code /speculos-toolkit skill
|
|
76
|
+
(+ allow the deploy command once, no more prompts)
|
|
77
|
+
npx speculos-toolkit connectors [list]
|
|
78
|
+
show the data sources linked to your account/org
|
|
79
|
+
(with their tools) — needs login
|
|
80
|
+
npx speculos-toolkit connectors exec --connector <alias> --tool <TOOL> \\
|
|
81
|
+
[--args '<json>' | --args-file <path>]
|
|
82
|
+
run one connector tool through the broker
|
|
83
|
+
(read-only discovery, live queries)
|
|
84
|
+
|
|
85
|
+
Frontends are hosted FREE at user-deployed.speculos.ai/<userId>/<slugUuid>.
|
|
86
|
+
Every Speculos account includes one backend app free (an isolated sandbox per app) —
|
|
87
|
+
sign up at https://deploy.speculos.ai and run 'speculos-toolkit login'.
|
|
88
|
+
Without an account a detected backend is skipped (frontend still ships free).
|
|
89
|
+
|
|
90
|
+
OPTIONS
|
|
91
|
+
--frontend <dir> frontend dir (auto-detected: frontend/web/client/… or ./ if index.html)
|
|
92
|
+
--backend <dir> backend dir (auto-detected: backend/api/server/…) — needs --override
|
|
93
|
+
--slug <slug> deployment slug (default: <folder>-<hash>)
|
|
94
|
+
--runtime <r> backend runtime: node | python | bun (auto-detected)
|
|
95
|
+
--start <cmd> backend start command (auto-detected; must bind 0.0.0.0:$PORT)
|
|
96
|
+
--build force the frontend through its build step (vs serve as static)
|
|
97
|
+
--static serve the frontend dir as-is even if it has a build script
|
|
98
|
+
--no-backend frontend-only: skip backend even if one is detected
|
|
99
|
+
--no-frontend backend-only: skip frontend even if one is detected
|
|
100
|
+
--output <dir> frontend build output dir (dist/build/out — auto-detected)
|
|
101
|
+
--override <pw> admin override password to deploy a backend without an account
|
|
102
|
+
(also via SPECULOS_OVERRIDE env)
|
|
103
|
+
--env KEY=VAL backend env var (repeatable)
|
|
104
|
+
--env-file <file> load backend env vars from a file
|
|
105
|
+
--connector <a> connectors exec: the source alias from \`connectors list\`
|
|
106
|
+
--tool <TOOL> connectors exec: the tool slug to run
|
|
107
|
+
--args <json> connectors exec: tool arguments as inline JSON
|
|
108
|
+
--args-file <f> connectors exec: tool arguments from a JSON file (preferred
|
|
109
|
+
in agents — inline JSON with $ ( ) etc. may need approval)
|
|
110
|
+
--api <url> orchestrator base url (default https://deploy-orch-38hd4.speculos.ai)
|
|
111
|
+
--timeout <sec> max seconds to wait for the backend (default 600)
|
|
112
|
+
--json machine-readable only (auto-on when non-TTY/CI)
|
|
113
|
+
|
|
114
|
+
Identity: the first deploy mints a machine-global { userId, userKey } saved to
|
|
115
|
+
~/.speculos/identity.json — keep it to retain ownership of your URLs. Each project
|
|
116
|
+
records its slug's stable id in .speculos.json (gitignored).
|
|
117
|
+
|
|
118
|
+
The frontend is automatically pointed at the deployed backend URL via
|
|
119
|
+
VITE_API_URL / NEXT_PUBLIC_API_URL / API_URL (build) or window.SPECULOS_API_URL.`;
|
|
120
|
+
|
|
121
|
+
// ---- commands -----------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
async function cmdDetect(root, opts) {
|
|
124
|
+
const d = detect(root, opts);
|
|
125
|
+
if (!opts.json) {
|
|
126
|
+
log(opts, `slug: ${d.slug}`);
|
|
127
|
+
log(opts, `frontend: ${d.frontend ? `${path.relative(root, d.frontend.dir) || "."} (${d.frontend.kind}${d.frontend.framework ? "/" + d.frontend.framework : ""})` : "none"}`);
|
|
128
|
+
log(opts, `backend: ${d.backend ? `${path.relative(root, d.backend.dir) || "."} (${d.backend.runtime}: ${d.backend.startCmd})` : "none"}`);
|
|
129
|
+
}
|
|
130
|
+
emit({ ok: true, detected: d });
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function pollBackend(jobId, creds, opts) {
|
|
135
|
+
// Default wait exceeds the server-side install timeout so we don't give up
|
|
136
|
+
// while a slow install is still legitimately running.
|
|
137
|
+
const timeoutMs = (Number(opts.timeout) || 960) * 1000;
|
|
138
|
+
const t0 = Date.now();
|
|
139
|
+
let lastPhase = "";
|
|
140
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
141
|
+
await sleep(3000);
|
|
142
|
+
let st;
|
|
143
|
+
try { st = await client.backendStatus(jobId, creds, opts); }
|
|
144
|
+
catch (e) { log(opts, ` (status check retry: ${e.message})`); continue; }
|
|
145
|
+
if (st.phase && st.phase !== lastPhase) { log(opts, ` … backend ${st.phase}`); lastPhase = st.phase; }
|
|
146
|
+
if (st.done) return st;
|
|
147
|
+
}
|
|
148
|
+
return { done: true, status: "error", jobId, error: `backend still starting after ${Math.round(timeoutMs / 1000)}s — it may finish shortly; check with \`speculos-toolkit status ${jobId}\`` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function cmdDeploy(root, opts) {
|
|
152
|
+
const d = detect(root, opts);
|
|
153
|
+
// Backend hosting needs either an admin --override OR a signed-in account
|
|
154
|
+
// (speculos-toolkit login) — one backend app included free. Frontend is always free.
|
|
155
|
+
const override = opts.override || process.env.SPECULOS_OVERRIDE || null;
|
|
156
|
+
let identity = creds.loadIdentity();
|
|
157
|
+
const accountToken = identity && identity.accountToken;
|
|
158
|
+
if (accountToken) opts.token = accountToken; // sent as Bearer on allocate/backend
|
|
159
|
+
const hadBackend = !!d.backend;
|
|
160
|
+
if (d.backend && !override && !accountToken) {
|
|
161
|
+
log(opts, `↪ backend detected but skipped — sign in to deploy it (every Speculos account includes one free backend app). Run \`speculos-toolkit login\`, then re-deploy. Shipping the frontend only for now.`);
|
|
162
|
+
d.backend = null;
|
|
163
|
+
}
|
|
164
|
+
if (!d.frontend && !d.backend) {
|
|
165
|
+
if (hadBackend) { emit({ ok: false, error: `nothing to deploy: Speculos hosts frontends free, but this project is backend-only — sign in to deploy the backend (every Speculos account includes one free backend app). Run \`speculos-toolkit login\` (https://deploy.speculos.ai), or add a frontend.`, code: "BETA_BACKEND_ONLY" }); return 2; }
|
|
166
|
+
emit({ ok: false, error: `could not detect a frontend in ${root}. Pass --frontend <dir>.`, code: "DETECT" });
|
|
167
|
+
return 2;
|
|
168
|
+
}
|
|
169
|
+
// reuse this project's saved slug when present
|
|
170
|
+
const saved = creds.load(root);
|
|
171
|
+
if (!opts.slug && saved && saved.slug) d.slug = saved.slug;
|
|
172
|
+
log(opts, `slug: ${d.slug}`);
|
|
173
|
+
const parts = [d.frontend ? `frontend (${d.frontend.kind})` : null, d.backend ? "backend" : null].filter(Boolean).join(" + ");
|
|
174
|
+
log(opts, `deploying: ${parts}`);
|
|
175
|
+
|
|
176
|
+
// ---- identity + slug allocation (mints a machine identity on first use) ----
|
|
177
|
+
let alloc;
|
|
178
|
+
try {
|
|
179
|
+
alloc = await client.allocate({ userId: identity && identity.userId, userKey: identity && identity.userKey, slug: d.slug }, opts);
|
|
180
|
+
} catch (e) { emit({ ok: false, error: e.message, code: e.code || "ALLOCATE" }); return 1; }
|
|
181
|
+
if (alloc.userKey) { // freshly minted on the server
|
|
182
|
+
identity = { userId: alloc.userId, userKey: alloc.userKey };
|
|
183
|
+
creds.saveIdentity(identity);
|
|
184
|
+
log(opts, `→ new device identity ${alloc.userId} (saved to ~/.speculos/identity.json — keep it)`);
|
|
185
|
+
}
|
|
186
|
+
const userId = alloc.userId, userKey = identity.userKey, slugUuid = alloc.slugUuid;
|
|
187
|
+
creds.save(root, { slug: d.slug, slugUuid });
|
|
188
|
+
|
|
189
|
+
// ---- backend first (so its URL can be baked into the frontend) ----
|
|
190
|
+
let backendUrl = null, backendNote = null;
|
|
191
|
+
if (d.backend) {
|
|
192
|
+
if (opts.envFileError) { emit({ ok: false, error: opts.envFileError, code: "ENV_FILE" }); log(opts, `✗ ${opts.envFileError}`); return 1; }
|
|
193
|
+
log(opts, `→ packing backend (${path.relative(root, d.backend.dir) || "."}, ${d.backend.runtime})`);
|
|
194
|
+
const backendTar = packDir(d.backend.dir, { dropBuildOutput: false });
|
|
195
|
+
log(opts, `→ deploying backend (${Math.round(backendTar.bytes / 1024)} KB) to an isolated sandbox`);
|
|
196
|
+
let created;
|
|
197
|
+
try {
|
|
198
|
+
created = await client.startBackend({
|
|
199
|
+
userId, userKey, slug: d.slug, slugUuid, override,
|
|
200
|
+
backend: { tarB64: backendTar.base64, runtime: d.backend.runtime, startCmd: d.backend.startCmd, env: opts.env },
|
|
201
|
+
}, opts);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// Backends not enabled (or the account is at its limit) — don't fail the
|
|
204
|
+
// deploy; ship the frontend so the user still gets a live URL, and surface
|
|
205
|
+
// how to proceed. Only genuine backend build/start errors fail the deploy.
|
|
206
|
+
if (e.code === "BACKEND_DISABLED" || e.code === "TOO_MANY") { log(opts, `↪ backend skipped — ${e.message}`); d.backend = null; backendNote = e.message; }
|
|
207
|
+
else { emit({ ok: false, error: e.message, code: e.code || "BACKEND" }); return 1; }
|
|
208
|
+
}
|
|
209
|
+
if (created) {
|
|
210
|
+
const st = await pollBackend(created.jobId, { userId, userKey }, opts);
|
|
211
|
+
if (st.status !== "success" || !(st.urls && st.urls.backend)) {
|
|
212
|
+
emit({ ok: false, slug: d.slug, jobId: created.jobId, status: "error", error: st.error || "backend failed", logTail: st.logTail });
|
|
213
|
+
log(opts, `✗ backend failed: ${st.error || "unknown"}`);
|
|
214
|
+
if (st.logTail) log(opts, st.logTail);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
backendUrl = st.urls.backend;
|
|
218
|
+
log(opts, ` backend: ${backendUrl}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---- frontend: build locally (if needed), then upload static output ----
|
|
223
|
+
let frontendUrl = null;
|
|
224
|
+
if (d.frontend) {
|
|
225
|
+
let outDir = d.frontend.dir;
|
|
226
|
+
if (d.frontend.kind === "build") {
|
|
227
|
+
log(opts, `→ building frontend locally (${d.frontend.framework})`);
|
|
228
|
+
try {
|
|
229
|
+
outDir = runBuild({
|
|
230
|
+
dir: d.frontend.dir, framework: d.frontend.framework, buildCmd: d.frontend.buildCmd,
|
|
231
|
+
base: alloc.base, backendUrl,
|
|
232
|
+
connectorsUrl: alloc.connectorsUrl, connectorsToken: alloc.connectorsToken,
|
|
233
|
+
outputDir: d.frontend.outputDir, log: (m) => log(opts, m),
|
|
234
|
+
});
|
|
235
|
+
} catch (e) { emit({ ok: false, slug: d.slug, status: "error", error: e.message, code: e.code || "BUILD" }); log(opts, `✗ build failed: ${e.message}`); return 1; }
|
|
236
|
+
}
|
|
237
|
+
log(opts, `→ packing + uploading frontend (${path.relative(root, outDir) || "."})`);
|
|
238
|
+
// If a plain static site is served straight from the repo root, exclude the
|
|
239
|
+
// server code + secrets so they aren't published. Exclude the ACTUAL detected
|
|
240
|
+
// backend dir (not a hardcoded name), plus any top-level backend-NAMED dir
|
|
241
|
+
// that really looks like a backend (has package.json/requirements) — a
|
|
242
|
+
// content dir that merely shares a name (e.g. a static api/) is left in.
|
|
243
|
+
const rootStatic = path.resolve(outDir) === path.resolve(root) && d.frontend.kind === "static";
|
|
244
|
+
let excludeDirs = [];
|
|
245
|
+
if (rootStatic) {
|
|
246
|
+
const set = new Set();
|
|
247
|
+
if (d.backend && d.backend.dir) { const rel = path.relative(root, d.backend.dir); if (rel && rel !== "" && !rel.startsWith("..")) set.add(rel); }
|
|
248
|
+
const looksBackend = (p) => fs.existsSync(path.join(p, "package.json")) || fs.existsSync(path.join(p, "requirements.txt")) || fs.existsSync(path.join(p, "pyproject.toml"));
|
|
249
|
+
for (const name of BACKEND_DIRS) { const p = path.join(root, name); if (fs.existsSync(p) && looksBackend(p)) set.add(name); }
|
|
250
|
+
excludeDirs = [...set];
|
|
251
|
+
log(opts, ` note: serving the repo root — excluding secrets${excludeDirs.length ? " + " + excludeDirs.join(", ") : ""} from the public bundle`);
|
|
252
|
+
}
|
|
253
|
+
// SECRET_EXCLUDE (frontend:true) only for the root-served case — a scoped build
|
|
254
|
+
// output shouldn't have its assets stripped just because one is named like a key.
|
|
255
|
+
const feTar = packDir(outDir, { dropBuildOutput: false, frontend: rootStatic, excludeDirs });
|
|
256
|
+
let fe;
|
|
257
|
+
try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, tarB64: feTar.base64, backendUrl }, opts); }
|
|
258
|
+
catch (e) { emit({ ok: false, error: e.message, code: e.code || "FRONTEND" }); return 1; }
|
|
259
|
+
frontendUrl = fe.frontendUrl;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const urls = {};
|
|
263
|
+
if (frontendUrl) urls.frontend = frontendUrl;
|
|
264
|
+
if (backendUrl) urls.backend = backendUrl;
|
|
265
|
+
log(opts, `✓ live`);
|
|
266
|
+
if (urls.frontend) log(opts, ` frontend: ${urls.frontend}`);
|
|
267
|
+
if (urls.backend) log(opts, ` backend: ${urls.backend}`);
|
|
268
|
+
if (backendNote) log(opts, ` note: ${backendNote}`);
|
|
269
|
+
const out = { ok: true, slug: d.slug, userId, urls };
|
|
270
|
+
if (backendNote) out.backendNote = backendNote;
|
|
271
|
+
emit({ ...out });
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function cmdStatus(jobId, opts) {
|
|
276
|
+
if (!jobId) { emit({ ok: false, error: "jobId required" }); return 2; }
|
|
277
|
+
const identity = creds.loadIdentity();
|
|
278
|
+
if (!identity || !identity.userId) { emit({ ok: false, error: "no identity (~/.speculos/identity.json) — cannot poll" }); return 1; }
|
|
279
|
+
const st = await client.backendStatus(jobId, { userId: identity.userId, userKey: identity.userKey }, opts);
|
|
280
|
+
// `ok` for contract-consistency with every other command (false only on a
|
|
281
|
+
// terminal failure; a still-running poll is ok:true).
|
|
282
|
+
emit({ ok: st.status !== "error", ...st });
|
|
283
|
+
// 0 = done+success, 1 = done+failed, 2 = still running
|
|
284
|
+
return st.status === "success" ? 0 : st.done ? 1 : 2;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function cmdTeardown(root, opts) {
|
|
288
|
+
const saved = creds.load(root);
|
|
289
|
+
const identity = creds.loadIdentity();
|
|
290
|
+
const slug = opts.slug || (saved && saved.slug);
|
|
291
|
+
if (!slug) { emit({ ok: false, error: "--slug required (or run from the project dir with a .speculos.json)" }); return 2; }
|
|
292
|
+
if (!identity || !identity.userId) { emit({ ok: false, error: "no identity found (~/.speculos/identity.json) — nothing to tear down from this machine" }); return 1; }
|
|
293
|
+
try {
|
|
294
|
+
const r = await client.teardown({ userId: identity.userId, userKey: identity.userKey, slug }, opts);
|
|
295
|
+
if (saved && saved.slug === slug) creds.remove(root);
|
|
296
|
+
emit({ ok: true, ...r });
|
|
297
|
+
return 0;
|
|
298
|
+
} catch (e) { emit({ ok: false, error: e.message, code: e.code || "TEARDOWN" }); return 1; }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- login: link this machine to a Speculos account via browser device flow ----
|
|
302
|
+
|
|
303
|
+
async function cmdLogin(opts) {
|
|
304
|
+
// Already linked? Don't run a redundant device flow (pass --relink to switch
|
|
305
|
+
// to a different account, incl. moving a personal-linked device onto an org).
|
|
306
|
+
const existing = creds.loadIdentity();
|
|
307
|
+
if (existing && existing.accountToken && !opts.relink && !opts.pasteToken) {
|
|
308
|
+
log(opts, `✓ this device is already linked to your Speculos account. Pass --relink to link a different account. Manage deployments at https://deploy.speculos.ai/dashboard.`);
|
|
309
|
+
emit({ ok: true, alreadyLinked: true });
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Paste-a-token path: a user who already has an account (and a token from the
|
|
314
|
+
// dashboard / their platform) skips the browser device flow entirely.
|
|
315
|
+
// npx speculos-toolkit login --token spec_tok_...
|
|
316
|
+
// Combine with --relink to move a mis-linked (e.g. personal) device onto the
|
|
317
|
+
// account/org the token belongs to.
|
|
318
|
+
if (opts.pasteToken) {
|
|
319
|
+
const token = String(opts.pasteToken).trim();
|
|
320
|
+
if (!/^spec_tok_/.test(token)) { emit({ ok: false, error: "that doesn't look like a Speculos account token (expected spec_tok_…)", code: "BAD_TOKEN" }); return 1; }
|
|
321
|
+
let who;
|
|
322
|
+
try { who = await client.whoami(token, opts); }
|
|
323
|
+
catch (e) { emit({ ok: false, error: e.status === 401 ? "that token is invalid or has been revoked" : e.message, code: e.code || "LOGIN" }); return 1; }
|
|
324
|
+
creds.saveAccountToken(token);
|
|
325
|
+
let reparented = false, previousEmail = null;
|
|
326
|
+
const identity = creds.loadIdentity();
|
|
327
|
+
if (identity && identity.userId && identity.userKey) {
|
|
328
|
+
try {
|
|
329
|
+
const r = await client.linkMachine(token, { userId: identity.userId, userKey: identity.userKey }, opts);
|
|
330
|
+
reparented = !!r.reparented; previousEmail = r.previousEmail || null;
|
|
331
|
+
} catch { /* links on next deploy via the token */ }
|
|
332
|
+
}
|
|
333
|
+
if (reparented) process.stderr.write(`\n⚠ This device's deployments moved from ${previousEmail || "another account"} to ${who.email || "this account"}.\n`);
|
|
334
|
+
log(opts, `✓ this device is linked${who.email ? ` to ${who.email}` : ""}${who.org ? ` (org: ${who.org})` : ""} — manage at https://deploy.speculos.ai/dashboard.`);
|
|
335
|
+
emit({ ok: true, linked: true, account: who.email || null, org: who.org || null, reparented, previousAccount: previousEmail });
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
let start;
|
|
339
|
+
try { start = await client.linkStart(opts); }
|
|
340
|
+
catch (e) { emit({ ok: false, error: e.message, code: e.code || "LOGIN" }); return 1; }
|
|
341
|
+
// Emit the approval URL on stdout IMMEDIATELY (a JSON line) so an agent that
|
|
342
|
+
// captures stdout gets the link right away — even if the poll is later killed
|
|
343
|
+
// by a command timeout. The final result is still the last stdout line.
|
|
344
|
+
emit({ ok: true, action: "login", pending: true, url: start.url, code: start.code });
|
|
345
|
+
// And on stderr (even in --json) for a human watching.
|
|
346
|
+
process.stderr.write(`\nLink this machine to your Speculos account to enable backend hosting:\n`);
|
|
347
|
+
process.stderr.write(`\n 1. Open: ${start.url}\n`);
|
|
348
|
+
process.stderr.write(` (or go to https://deploy.speculos.ai/link and enter code ${start.code})\n`);
|
|
349
|
+
process.stderr.write(` 2. Sign in / sign up, then click Approve.\n\n`);
|
|
350
|
+
process.stderr.write(`Waiting for approval…\n`);
|
|
351
|
+
|
|
352
|
+
const deadline = Date.now() + (start.expiresInSec || 600) * 1000;
|
|
353
|
+
let token = null, approvedEmail = null;
|
|
354
|
+
while (Date.now() < deadline) {
|
|
355
|
+
await sleep(2500);
|
|
356
|
+
let r;
|
|
357
|
+
try { r = await client.linkPoll(start.code, opts); }
|
|
358
|
+
catch (e) { emit({ ok: false, error: e.message, code: e.code || "LOGIN" }); return 1; }
|
|
359
|
+
if (r.approved && r.token) { token = r.token; approvedEmail = r.email || null; break; }
|
|
360
|
+
}
|
|
361
|
+
if (!token) { emit({ ok: false, error: "login timed out — run `speculos-toolkit login` again", code: "TIMEOUT" }); return 1; }
|
|
362
|
+
|
|
363
|
+
creds.saveAccountToken(token);
|
|
364
|
+
// Link the current machine (and its existing deploys) to the account now.
|
|
365
|
+
let linked = false, reparented = false, previousEmail = null;
|
|
366
|
+
const identity = creds.loadIdentity();
|
|
367
|
+
if (identity && identity.userId && identity.userKey) {
|
|
368
|
+
try {
|
|
369
|
+
const r = await client.linkMachine(token, { userId: identity.userId, userKey: identity.userKey }, opts);
|
|
370
|
+
linked = true; reparented = !!r.reparented; previousEmail = r.previousEmail || null;
|
|
371
|
+
} catch { /* will link on next deploy via the token */ }
|
|
372
|
+
}
|
|
373
|
+
if (reparented) {
|
|
374
|
+
process.stderr.write(`\n⚠ This device's deployments moved from ${previousEmail || "another account"} to ${approvedEmail || "this account"}. They now appear only in the new account's dashboard.\n`);
|
|
375
|
+
}
|
|
376
|
+
log(opts, `✓ this device is linked${approvedEmail ? ` to ${approvedEmail}` : ""} — manage your deployments at https://deploy.speculos.ai/dashboard.`);
|
|
377
|
+
emit({ ok: true, linked, account: approvedEmail, reparented, previousAccount: previousEmail });
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---- logout: revoke this device's token (server + local) ----
|
|
382
|
+
async function cmdLogout(opts) {
|
|
383
|
+
const identity = creds.loadIdentity();
|
|
384
|
+
const token = identity && identity.accountToken;
|
|
385
|
+
if (!token) { emit({ ok: true, alreadyLoggedOut: true }); return 0; }
|
|
386
|
+
let revoked = false;
|
|
387
|
+
try { const r = await client.logout(token, opts); revoked = !!r.revoked; }
|
|
388
|
+
catch { /* revoke best-effort; still clear locally */ }
|
|
389
|
+
creds.clearAccountToken();
|
|
390
|
+
log(opts, `✓ this device is signed out of your Speculos account${revoked ? " (token revoked)" : ""}.`);
|
|
391
|
+
emit({ ok: true, loggedOut: true, revoked });
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---- connectors: list the account/org data sources; execute a tool ----------
|
|
396
|
+
//
|
|
397
|
+
// Access is resolved server-side ON EVERY CALL (org membership, per-member
|
|
398
|
+
// grants, freshly OAuth'd sources) — nothing is cached locally, so a grant or
|
|
399
|
+
// a new link made in the dashboard applies to the very next command with no
|
|
400
|
+
// re-login and no session restart.
|
|
401
|
+
|
|
402
|
+
async function readStdinAll() {
|
|
403
|
+
return new Promise((resolve) => {
|
|
404
|
+
let data = "";
|
|
405
|
+
process.stdin.setEncoding("utf8");
|
|
406
|
+
process.stdin.on("data", (c) => (data += c));
|
|
407
|
+
process.stdin.on("end", () => resolve(data));
|
|
408
|
+
process.stdin.on("error", () => resolve(""));
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function cmdConnectors(opts) {
|
|
413
|
+
const identity = creds.loadIdentity();
|
|
414
|
+
const token = identity && identity.accountToken;
|
|
415
|
+
if (!token) {
|
|
416
|
+
emit({ ok: false, code: "NO_TOKEN", error: "not signed in — run `npx -y speculos-toolkit@latest login`, then link data sources at https://deploy.speculos.ai/dashboard" });
|
|
417
|
+
return 1;
|
|
418
|
+
}
|
|
419
|
+
const sub = opts._[1] || "list";
|
|
420
|
+
|
|
421
|
+
if (sub === "list") {
|
|
422
|
+
let r;
|
|
423
|
+
try { r = await client.connectorsList(token, opts); }
|
|
424
|
+
catch (e) { emit({ ok: false, error: e.message, code: e.code || "CONNECTORS" }); return 1; }
|
|
425
|
+
const conns = r.connectors || [];
|
|
426
|
+
if (!opts.json) {
|
|
427
|
+
if (!conns.length) log(opts, "no data sources linked — link one at https://deploy.speculos.ai/dashboard");
|
|
428
|
+
for (const c of conns) log(opts, ` ${c.alias} (${c.name}${c.accountIdentifier ? " · " + c.accountIdentifier : ""}) — ${(c.tools || []).length} tools`);
|
|
429
|
+
}
|
|
430
|
+
emit({ ok: true, brokerUrl: client.base(opts) + "/api/connectors", connectors: conns });
|
|
431
|
+
return 0;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (sub === "exec") {
|
|
435
|
+
if (!opts.tool) { emit({ ok: false, error: "--tool required (pick one from `connectors list`)", code: "BAD_ARGS" }); return 2; }
|
|
436
|
+
// arguments: --args-file wins, then --args, then piped stdin, then {}
|
|
437
|
+
let args = {};
|
|
438
|
+
try {
|
|
439
|
+
if (opts.argsFile) args = JSON.parse(fs.readFileSync(opts.argsFile, "utf8"));
|
|
440
|
+
else if (opts.args) args = JSON.parse(opts.args);
|
|
441
|
+
else if (!process.stdin.isTTY) { const raw = (await readStdinAll()).trim(); if (raw) args = JSON.parse(raw); }
|
|
442
|
+
} catch (e) {
|
|
443
|
+
emit({ ok: false, error: `couldn't read tool arguments: ${e.message}`, code: "BAD_ARGS" });
|
|
444
|
+
return 2;
|
|
445
|
+
}
|
|
446
|
+
let r;
|
|
447
|
+
try { r = await client.connectorsExec(token, { connector: opts.connector, tool: opts.tool, arguments: args }, opts); }
|
|
448
|
+
catch (e) { emit({ ok: false, error: e.message, code: e.code || "EXEC" }); return 1; }
|
|
449
|
+
emit(r);
|
|
450
|
+
return r.ok ? 0 : 1;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
emit({ ok: false, error: `unknown connectors subcommand "${sub}" — use list or exec` });
|
|
454
|
+
return 2;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---- install-skill: drop the Claude Code skill + allow the deploy command once ----
|
|
458
|
+
|
|
459
|
+
const SKILL_PERMISSIONS = [
|
|
460
|
+
"Bash(npx -y speculos-toolkit@latest:*)",
|
|
461
|
+
"Bash(npx speculos-toolkit@latest:*)",
|
|
462
|
+
"Bash(npx -y speculos-toolkit:*)",
|
|
463
|
+
"Bash(npx speculos-toolkit:*)",
|
|
464
|
+
];
|
|
465
|
+
|
|
466
|
+
function cmdInstallSkill(opts) {
|
|
467
|
+
// user-level (~/.claude) by default so it's available in every project; --project for ./.claude
|
|
468
|
+
const base = opts.project ? path.join(process.cwd(), ".claude") : path.join(os.homedir(), ".claude");
|
|
469
|
+
const skillDir = path.join(base, "skills", "speculos-toolkit");
|
|
470
|
+
const settingsPath = path.join(base, "settings.json");
|
|
471
|
+
const src = path.join(__dirname, "..", "skill", "SKILL.md");
|
|
472
|
+
|
|
473
|
+
if (!fs.existsSync(src)) { emit({ ok: false, error: "bundled SKILL.md not found in this package", code: "NO_SKILL" }); return 1; }
|
|
474
|
+
|
|
475
|
+
// 0) remove any stale old-name skill dir so the renamed skill doesn't compete
|
|
476
|
+
// with the old one. Clean BOTH the user-level and project locations regardless
|
|
477
|
+
// of --project, since the old skill could have been installed in either.
|
|
478
|
+
for (const b of new Set([path.join(os.homedir(), ".claude"), path.join(process.cwd(), ".claude")])) {
|
|
479
|
+
const stale = path.join(b, "skills", "speculos-deploy");
|
|
480
|
+
try {
|
|
481
|
+
if (fs.existsSync(stale)) { fs.rmSync(stale, { recursive: true, force: true }); log(opts, `✓ removed stale skill → ${stale}`); }
|
|
482
|
+
} catch { /* best effort */ }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 1) install the skill file
|
|
486
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
487
|
+
fs.copyFileSync(src, path.join(skillDir, "SKILL.md"));
|
|
488
|
+
log(opts, `✓ installed skill → ${path.join(skillDir, "SKILL.md")}`);
|
|
489
|
+
|
|
490
|
+
// 2) grant the deploy command once (merge into settings.json, never clobber)
|
|
491
|
+
let settings = {}, settingsOk = true, added = 0;
|
|
492
|
+
try {
|
|
493
|
+
const raw = fs.existsSync(settingsPath) ? fs.readFileSync(settingsPath, "utf8") : "";
|
|
494
|
+
if (raw.trim()) settings = JSON.parse(raw);
|
|
495
|
+
} catch { settingsOk = false; }
|
|
496
|
+
|
|
497
|
+
if (settingsOk) {
|
|
498
|
+
// guard against a non-object `permissions` (e.g. an array) — assigning
|
|
499
|
+
// `.allow` to it would be silently dropped by JSON.stringify.
|
|
500
|
+
if (!settings.permissions || typeof settings.permissions !== "object" || Array.isArray(settings.permissions)) settings.permissions = {};
|
|
501
|
+
const allow = Array.isArray(settings.permissions.allow) ? settings.permissions.allow : [];
|
|
502
|
+
const set = new Set(allow);
|
|
503
|
+
for (const r of SKILL_PERMISSIONS) if (!set.has(r)) { set.add(r); added++; }
|
|
504
|
+
settings.permissions.allow = [...set];
|
|
505
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
506
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
507
|
+
log(opts, `✓ allowed the deploy command in ${settingsPath} (${added} rule(s) added — no more prompts)`);
|
|
508
|
+
} else {
|
|
509
|
+
log(opts, `! ${settingsPath} isn't valid JSON — add these to permissions.allow yourself:`);
|
|
510
|
+
for (const r of SKILL_PERMISSIONS) log(opts, ` ${r}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
log(opts, `→ restart Claude Code (or reload skills) and run /speculos-toolkit in any project.`);
|
|
514
|
+
emit({ ok: true, skill: path.join(skillDir, "SKILL.md"), settings: settingsOk ? settingsPath : null, permissionsAdded: added, permissions: SKILL_PERMISSIONS });
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function main(argv) {
|
|
519
|
+
const opts = parseArgs(argv);
|
|
520
|
+
// Node < 18 has no global fetch — every network command would die with a
|
|
521
|
+
// confusing "fetch is not defined". Fail fast with a clear message instead.
|
|
522
|
+
const major = parseInt(process.versions.node, 10);
|
|
523
|
+
if (major < 18) { emit({ ok: false, error: `Node ${process.versions.node} is too old — Speculos deploy needs Node 18+ (global fetch).`, code: "NODE_TOO_OLD" }); return 1; }
|
|
524
|
+
const isTTY = process.stdout.isTTY && !process.env.CI && !process.env.SPECULOS_NONINTERACTIVE;
|
|
525
|
+
if (!isTTY && opts.json === undefined) opts.json = true; // non-TTY/CI: machine-readable (stdout stays a clean JSON channel; the login URL still prints to stderr)
|
|
526
|
+
if (opts.version) { emit({ version: VERSION }); return 0; }
|
|
527
|
+
if (opts.help) { process.stderr.write(HELP + "\n"); return 0; }
|
|
528
|
+
const cmd = opts._[0] || "deploy";
|
|
529
|
+
const root = process.cwd();
|
|
530
|
+
try {
|
|
531
|
+
if (cmd === "deploy") return await cmdDeploy(root, opts);
|
|
532
|
+
if (cmd === "detect") return await cmdDetect(root, opts);
|
|
533
|
+
if (cmd === "status") return await cmdStatus(opts._[1], opts);
|
|
534
|
+
if (cmd === "teardown") return await cmdTeardown(root, opts);
|
|
535
|
+
if (cmd === "login") return await cmdLogin(opts);
|
|
536
|
+
if (cmd === "logout") return await cmdLogout(opts);
|
|
537
|
+
if (cmd === "connectors") return await cmdConnectors(opts);
|
|
538
|
+
if (cmd === "install-skill") return cmdInstallSkill(opts);
|
|
539
|
+
emit({ ok: false, error: "unknown command: " + cmd });
|
|
540
|
+
return 2;
|
|
541
|
+
} catch (e) {
|
|
542
|
+
emit({ ok: false, error: e.message, code: e.code || "FATAL" });
|
|
543
|
+
return 1;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
module.exports = { main, parseArgs };
|
package/src/pack.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Pack a directory into a base64 gzipped tar, excluding junk (node_modules, .git,
|
|
2
|
+
// secrets, build artifacts). Uses the system `tar` (present on macOS/Linux/CI).
|
|
3
|
+
const { execFileSync } = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const ALWAYS_EXCLUDE = [
|
|
9
|
+
".git", "node_modules", ".env", ".env.*", "*.log", ".DS_Store",
|
|
10
|
+
".vercel", ".cache", "__pycache__", ".venv", "venv", ".pytest_cache",
|
|
11
|
+
".idea", ".vscode", "*.tgz", "coverage", ".speculos.json", ".gitignore",
|
|
12
|
+
];
|
|
13
|
+
// for "build" frontends the server rebuilds, so drop prebuilt output too
|
|
14
|
+
const BUILD_EXCLUDE = ["dist", "build", "out", ".next", ".nuxt", ".svelte-kit", ".output"];
|
|
15
|
+
// A frontend bundle is served PUBLICLY, so never ship credentials in it — this
|
|
16
|
+
// matters most when a plain static site is served straight from the repo root
|
|
17
|
+
// (a sibling secret file would otherwise be downloadable). Applied to frontend
|
|
18
|
+
// packs only, NOT backends (a backend may legitimately ship a cert/key).
|
|
19
|
+
const SECRET_EXCLUDE = [
|
|
20
|
+
"*.pem", "*.key", "*.p12", "*.pfx", "id_rsa", "id_rsa.*", "id_ed25519", "id_ed25519.*",
|
|
21
|
+
"id_dsa", "credentials.json", "credentials.yaml", "credentials.yml",
|
|
22
|
+
"secrets.json", "secrets.yaml", "secrets.yml", "service-account*.json",
|
|
23
|
+
".npmrc", ".netrc", ".pgpass", ".ssh", ".aws", ".gcloud",
|
|
24
|
+
];
|
|
25
|
+
// backend source dirs — excluded when the frontend IS the repo root, so serving
|
|
26
|
+
// a root static site doesn't publish the server code sitting next to it.
|
|
27
|
+
const BACKEND_DIRS = ["backend", "api", "server", "svc", "service"];
|
|
28
|
+
|
|
29
|
+
function packDir(dir, { dropBuildOutput = false, frontend = false, excludeDirs = [] } = {}) {
|
|
30
|
+
const excludes = ALWAYS_EXCLUDE
|
|
31
|
+
.concat(dropBuildOutput ? BUILD_EXCLUDE : [])
|
|
32
|
+
.concat(frontend ? SECRET_EXCLUDE : []);
|
|
33
|
+
const tmp = path.join(os.tmpdir(), `speculos-${Date.now()}-${Math.floor(process.hrtime()[1] % 1e6)}.tgz`);
|
|
34
|
+
const args = ["czf", tmp];
|
|
35
|
+
for (const e of excludes) args.push("--exclude=" + e);
|
|
36
|
+
// excludeDirs are TOP-LEVEL paths (e.g. a backend dir) — anchor with ./ so they
|
|
37
|
+
// exclude only that top-level dir, never a same-named dir nested in the site.
|
|
38
|
+
for (const e of (excludeDirs || [])) args.push("--exclude=./" + String(e).replace(/^\.?\/*/, "").replace(/\/+$/, ""));
|
|
39
|
+
args.push("-C", dir, ".");
|
|
40
|
+
execFileSync("tar", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
41
|
+
const buf = fs.readFileSync(tmp);
|
|
42
|
+
fs.unlinkSync(tmp);
|
|
43
|
+
return { base64: buf.toString("base64"), bytes: buf.length };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { packDir, BACKEND_DIRS };
|