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/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 };