speculos-toolkit 1.2.1 → 1.2.3

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 CHANGED
@@ -73,12 +73,22 @@ speculos-toolkit [deploy] detect, pack, deploy ./
73
73
  speculos-toolkit detect show what would deploy (no upload)
74
74
  speculos-toolkit status <jobId> poll a deployment
75
75
  speculos-toolkit teardown --slug <s> remove a deployment
76
+ speculos-toolkit login [--token …] link this machine (browser approval at
77
+ https://unified.speculos.ai/link)
78
+ speculos-toolkit logout unlink it (revokes this device's token)
79
+ speculos-toolkit connectors [list|exec] your linked data sources, and one tool call
80
+ speculos-toolkit install-skill install the Claude Code skill
76
81
 
77
82
  --frontend <dir> --backend <dir> --slug <name>
78
- --runtime node|python --start "<cmd>" --build --output <dir>
83
+ --runtime node|python|bun --start "<cmd>" --build --static --output <dir>
84
+ --private | --org | --public
79
85
  --env KEY=VAL --env-file <file> --api <url> --timeout <sec> --json
80
86
  ```
81
87
 
88
+ `--api` defaults to `https://unified-api.speculos.ai/toolkit` (the platform gateway,
89
+ which proxies to the deploy orchestrator and records the deploy in the console's
90
+ history); `SPECULOS_API` overrides it.
91
+
82
92
  Docs: https://unified.speculos.ai · Source: https://github.com/speculosai/unified_platform
83
93
 
84
94
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speculos-toolkit",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "The Speculos toolkit for coding agents \u2014 deploy any frontend/backend to a live URL and build against your linked data connectors (BigQuery, Postgres, Snowflake, Salesforce, \u2026). Built for Claude Code, Codex, Cursor, and friends.",
5
5
  "bin": {
6
6
  "speculos-toolkit": "bin/speculos-toolkit.js"
package/skill/SKILL.md CHANGED
@@ -222,6 +222,11 @@ export async function connector(alias, tool, args) {
222
222
  }
223
223
  ```
224
224
 
225
+ (The `x-speculos-gate` line is belt-and-braces: the deployed page also loads an
226
+ injected `speculos-env.js` that patches `fetch`/`XMLHttpRequest` to add that header
227
+ on every call to the backend or broker origin, so an app written WITHOUT this helper
228
+ still works when it is private. Keeping it costs nothing and makes the flow explicit.)
229
+
225
230
  Rules (and state to the user):
226
231
  - **Read-only only.** The broker rejects any non-read tool on this path (`WRITE_BLOCKED`).
227
232
  Use `LIST_`/`GET_`/`SEARCH_`/`QUERY` tools; `POSTGRES_QUERY` runs in a read-only transaction.
package/src/client.js CHANGED
@@ -36,7 +36,8 @@ async function linkPoll(code, opts = {}) { return post(base(opts) + "/api/cli/li
36
36
  // verify a pasted account token (login --token) and get its account/org
37
37
  async function whoami(token, opts = {}) { return get(base(opts) + "/api/account/whoami", token); }
38
38
  async function linkMachine(token, payload, opts = {}) { return post(base(opts) + "/api/account/link", payload, token); }
39
- async function logout(token, opts = {}) { return post(base(opts) + "/api/account/logout", {}, token); }
39
+ // The machine identity rides along so the server can unlink it too.
40
+ async function logout(token, machine = {}, opts = {}) { return post(base(opts) + "/api/account/logout", { userId: machine.userId, userKey: machine.userKey }, token); }
40
41
  // poll a backend job (owner-authenticated)
41
42
  // The machine credentials travel as headers, not a query string: a query
42
43
  // string is written to every access log between here and the orchestrator.
package/src/index.js CHANGED
@@ -100,14 +100,16 @@ WHO CAN SEE IT
100
100
  apps come with it — then deploy again with --private, or flip it
101
101
  on https://unified.speculos.ai/?tab=deploys.
102
102
 
103
- A redeploy never changes the visibility of an app that is already live.
103
+ A redeploy with no visibility flag never changes a live app's visibility;
104
+ an explicit --private / --org / --public applies immediately, both ways.
104
105
  Every Speculos account includes one backend app free (an isolated sandbox per app) —
105
106
  sign up at https://unified.speculos.ai and run 'speculos-toolkit login'.
106
107
  Without an account a detected backend is skipped (frontend still ships free).
107
108
 
108
109
  OPTIONS
109
110
  --frontend <dir> frontend dir (auto-detected: frontend/web/client/… or ./ if index.html)
110
- --backend <dir> backend dir (auto-detected: backend/api/server/…) — needs --override
111
+ --backend <dir> backend dir (auto-detected: backend/api/server/…) — needs a linked
112
+ account (one backend app is included) or --override
111
113
  --slug <slug> deployment slug (default: <folder>-<hash>)
112
114
  --runtime <r> backend runtime: node | python | bun (auto-detected)
113
115
  --start <cmd> backend start command (auto-detected; must bind 0.0.0.0:$PORT)
@@ -130,7 +132,7 @@ OPTIONS
130
132
  --args-file <f> connectors exec: tool arguments from a JSON file (preferred
131
133
  in agents — inline JSON with $ ( ) etc. may need approval)
132
134
  --api <url> platform API base (default https://unified-api.speculos.ai/toolkit)
133
- --timeout <sec> max seconds to wait for the backend (default 600)
135
+ --timeout <sec> max seconds to wait for the backend (default 960)
134
136
  --json machine-readable only (auto-on when non-TTY/CI)
135
137
 
136
138
  Identity: the first deploy mints a machine-global { userId, userKey } saved to
@@ -184,11 +186,16 @@ async function cmdDeploy(root, opts) {
184
186
  const accountToken = identity && identity.accountToken;
185
187
  if (accountToken) opts.token = accountToken; // sent as Bearer on allocate/backend
186
188
  const hadBackend = !!d.backend;
189
+ let backendNote = null;
187
190
  if (d.backend && !override && !accountToken) {
188
- 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.`);
191
+ // Said in the JSON too: an agent reads only stdout, and "ok: true" with a
192
+ // frontend URL used to hide that the API it built never shipped.
193
+ backendNote = "backend detected but skipped: this machine is not linked to a Speculos account (every account includes one backend app). Run `speculos-toolkit login`, then deploy again.";
194
+ log(opts, `↪ ${backendNote}`);
189
195
  d.backend = null;
190
196
  }
191
197
  if (!d.frontend && !d.backend) {
198
+ if (opts.noFrontend && opts.noBackend) { emit({ ok: false, error: "both --no-frontend and --no-backend were given - nothing to deploy", code: "NOTHING_SELECTED" }); return 2; }
192
199
  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://unified.speculos.ai), or add a frontend.`, code: "BETA_BACKEND_ONLY" }); return 2; }
193
200
  emit({ ok: false, error: `could not detect a frontend in ${root}. Pass --frontend <dir>.`, code: "DETECT" });
194
201
  return 2;
@@ -220,7 +227,7 @@ async function cmdDeploy(root, opts) {
220
227
  creds.save(root, { slug: d.slug, slugUuid });
221
228
 
222
229
  // ---- backend first (so its URL can be baked into the frontend) ----
223
- let backendUrl = null, backendNote = null;
230
+ let backendUrl = null, jobId = null, historyIds = [];
224
231
  if (d.backend) {
225
232
  if (opts.envFileError) { emit({ ok: false, error: opts.envFileError, code: "ENV_FILE" }); log(opts, `✗ ${opts.envFileError}`); return 1; }
226
233
  log(opts, `→ packing backend (${path.relative(root, d.backend.dir) || "."}, ${d.backend.runtime})`);
@@ -240,6 +247,8 @@ async function cmdDeploy(root, opts) {
240
247
  else { emit({ ok: false, error: e.message, code: e.code || "BACKEND" }); return 1; }
241
248
  }
242
249
  if (created) {
250
+ jobId = created.jobId;
251
+ if (created.historyId) historyIds.push(created.historyId);
243
252
  const st = await pollBackend(created.jobId, { userId, userKey }, opts);
244
253
  if (st.status !== "success" || !(st.urls && st.urls.backend)) {
245
254
  emit({ ok: false, slug: d.slug, jobId: created.jobId, status: "error", error: st.error || "backend failed", logTail: st.logTail });
@@ -290,6 +299,7 @@ async function cmdDeploy(root, opts) {
290
299
  try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, tarB64: feTar.base64, backendUrl }, opts); }
291
300
  catch (e) { emit({ ok: false, error: e.message, code: e.code || "FRONTEND" }); return 1; }
292
301
  frontendUrl = fe.frontendUrl;
302
+ if (fe.historyId) historyIds.push(fe.historyId);
293
303
  }
294
304
 
295
305
  const urls = {};
@@ -309,6 +319,10 @@ async function cmdDeploy(root, opts) {
309
319
  else if (visibility === "public") log(opts, ` visibility: public — anyone with the link.`);
310
320
  if (alloc && alloc.visibilityNote) log(opts, ` note: ${alloc.visibilityNote}`);
311
321
  const out = { ok: true, slug: d.slug, userId, urls };
322
+ // What an agent needs to follow up without a second lookup: the backend
323
+ // job id for `status`, and the console history ids the gateway filed.
324
+ if (jobId) out.jobId = jobId;
325
+ if (historyIds.length) out.historyIds = historyIds;
312
326
  if (visibility) out.visibility = visibility;
313
327
  if (alloc && alloc.visibilityNote) out.visibilityNote = alloc.visibilityNote;
314
328
  if (backendNote) out.backendNote = backendNote;
@@ -338,8 +352,9 @@ async function cmdTeardown(root, opts) {
338
352
  if (identity.accountToken) opts.token = identity.accountToken;
339
353
  const r = await client.teardown({ userId: identity.userId, userKey: identity.userKey, slug }, opts);
340
354
  if (saved && saved.slug === slug) creds.remove(root);
355
+ if (r.notFound) log(opts, `! nothing was deployed under "${slug}" from this machine - nothing to tear down`);
341
356
  emit({ ok: true, ...r });
342
- return 0;
357
+ return r.notFound ? 3 : 0;
343
358
  } catch (e) { emit({ ok: false, error: e.message, code: e.code || "TEARDOWN" }); return 1; }
344
359
  }
345
360
 
@@ -428,12 +443,14 @@ async function cmdLogout(opts) {
428
443
  const identity = creds.loadIdentity();
429
444
  const token = identity && identity.accountToken;
430
445
  if (!token) { emit({ ok: true, alreadyLoggedOut: true }); return 0; }
431
- let revoked = false;
432
- try { const r = await client.logout(token, opts); revoked = !!r.revoked; }
433
- catch { /* revoke best-effort; still clear locally */ }
446
+ let revoked = false, unlinked = false;
447
+ try {
448
+ const r = await client.logout(token, { userId: identity.userId, userKey: identity.userKey }, opts);
449
+ revoked = !!r.revoked; unlinked = !!r.unlinked;
450
+ } catch { /* revoke best-effort; still clear locally */ }
434
451
  creds.clearAccountToken();
435
- log(opts, `✓ this device is signed out of your Speculos account${revoked ? " (token revoked)" : ""}.`);
436
- emit({ ok: true, loggedOut: true, revoked });
452
+ log(opts, `✓ this device is signed out of your Speculos account${revoked ? " (token revoked)" : ""}${unlinked ? "; the machine is no longer linked to it - `login` links it again" : ""}.`);
453
+ emit({ ok: true, loggedOut: true, revoked, unlinked });
437
454
  return 0;
438
455
  }
439
456
 
@@ -471,9 +488,10 @@ async function cmdConnectors(opts) {
471
488
  if (!opts.json) {
472
489
  if (!conns.length) log(opts, "no data sources linked — link one at https://unified.speculos.ai/?tab=deploys");
473
490
  for (const c of conns) {
474
- // Say which product a source came from: the deploy plane and the
475
- // Cloud console hold separate connections, often to the same SaaS,
476
- // and running a tool against the wrong one is silent and wrong.
491
+ // Mark the org-wide sources: a person in an org sees those plus their
492
+ // own, and which one a tool runs against matters. (Console and toolkit
493
+ // now share ONE Composio workspace, so there is no "which product"
494
+ // left to disambiguate — a source linked in the console is this source.)
477
495
  const scope = c.orgWide ? " · org" : "";
478
496
  log(opts, ` ${c.alias} (${c.name}${c.accountIdentifier ? " · " + c.accountIdentifier : ""}${scope}) — ${(c.tools || []).length} tools`);
479
497
  }