speculos-toolkit 1.2.2 → 1.2.4
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/package.json +1 -1
- package/skill/SKILL.md +8 -0
- package/src/client.js +4 -2
- package/src/index.js +59 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speculos-toolkit",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.4",
|
|
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
|
@@ -149,6 +149,14 @@ The last stdout line is `{ ok, brokerUrl, connectors: [{ alias, name, kind, acco
|
|
|
149
149
|
- `ok:false` + `code:"LOGIN_REQUIRED"` on a deploy → `--private`/`--org` was asked for on a
|
|
150
150
|
machine with no account. Nothing was published. Either run `login` and deploy again, or
|
|
151
151
|
drop the flag (the app ships public) and lock it down after linking.
|
|
152
|
+
- `ok:false` + `code:"ALREADY_DEPLOYING"` (with `jobId`) → another deploy of this app is
|
|
153
|
+
still installing. Nothing was written. Watch it with `status <jobId>` and re-run after,
|
|
154
|
+
rather than racing it - two concurrent full-stack deploys used to clobber each other
|
|
155
|
+
inside the sandbox and leave the backend failed.
|
|
156
|
+
- **A frontend-only deploy keeps the app's backend.** `--no-backend` (or a repo where the
|
|
157
|
+
backend did not change) redeploys the frontend against the SAME backend URL and says so
|
|
158
|
+
(`backendKept: true`); it used to silently bake `null` and the live app started 404ing
|
|
159
|
+
its own API. Pass `--unset-backend` to actually detach it.
|
|
152
160
|
- `ok:true` with empty `connectors` → nothing linked (or nothing granted to this user).
|
|
153
161
|
If the app clearly wants external data, tell the user to link a source (or ask their org
|
|
154
162
|
admin for access) at **https://unified.speculos.ai/?tab=deploys**, then **re-run the list** —
|
package/src/client.js
CHANGED
|
@@ -13,7 +13,8 @@ async function post(url, body, token) {
|
|
|
13
13
|
if (token) headers["Authorization"] = "Bearer " + token;
|
|
14
14
|
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
15
15
|
const data = await res.json().catch(() => ({}));
|
|
16
|
-
|
|
16
|
+
// Some refusals name a job to follow (ALREADY_DEPLOYING); keep it on the error.
|
|
17
|
+
if (!res.ok) { const e = new Error(data.error || `HTTP ${res.status}`); e.code = data.code; e.status = res.status; e.jobId = data.jobId || null; throw e; }
|
|
17
18
|
return data;
|
|
18
19
|
}
|
|
19
20
|
async function get(url, token, extra) {
|
|
@@ -36,7 +37,8 @@ async function linkPoll(code, opts = {}) { return post(base(opts) + "/api/cli/li
|
|
|
36
37
|
// verify a pasted account token (login --token) and get its account/org
|
|
37
38
|
async function whoami(token, opts = {}) { return get(base(opts) + "/api/account/whoami", token); }
|
|
38
39
|
async function linkMachine(token, payload, opts = {}) { return post(base(opts) + "/api/account/link", payload, token); }
|
|
39
|
-
|
|
40
|
+
// The machine identity rides along so the server can unlink it too.
|
|
41
|
+
async function logout(token, machine = {}, opts = {}) { return post(base(opts) + "/api/account/logout", { userId: machine.userId, userKey: machine.userKey }, token); }
|
|
40
42
|
// poll a backend job (owner-authenticated)
|
|
41
43
|
// The machine credentials travel as headers, not a query string: a query
|
|
42
44
|
// string is written to every access log between here and the orchestrator.
|
package/src/index.js
CHANGED
|
@@ -41,6 +41,9 @@ function parseArgs(argv) {
|
|
|
41
41
|
else if (a === "--org") opts.visibility = "org";
|
|
42
42
|
else if (a === "--public") opts.visibility = "public";
|
|
43
43
|
else if (a === "--no-backend") opts.noBackend = true;
|
|
44
|
+
// Explicitly clear the app's saved backend URL. A deploy that simply ships
|
|
45
|
+
// no backend KEEPS it (see cmdDeploy) — this is how you say "no backend".
|
|
46
|
+
else if (a === "--unset-backend") opts.unsetBackend = true;
|
|
44
47
|
else if (a === "--no-frontend") opts.noFrontend = true;
|
|
45
48
|
else if (a === "--json") opts.json = true;
|
|
46
49
|
else if (a === "--help" || a === "-h") opts.help = true;
|
|
@@ -100,7 +103,8 @@ WHO CAN SEE IT
|
|
|
100
103
|
apps come with it — then deploy again with --private, or flip it
|
|
101
104
|
on https://unified.speculos.ai/?tab=deploys.
|
|
102
105
|
|
|
103
|
-
A redeploy
|
|
106
|
+
A redeploy with no visibility flag never changes a live app's visibility;
|
|
107
|
+
an explicit --private / --org / --public applies immediately, both ways.
|
|
104
108
|
Every Speculos account includes one backend app free (an isolated sandbox per app) —
|
|
105
109
|
sign up at https://unified.speculos.ai and run 'speculos-toolkit login'.
|
|
106
110
|
Without an account a detected backend is skipped (frontend still ships free).
|
|
@@ -114,7 +118,10 @@ OPTIONS
|
|
|
114
118
|
--start <cmd> backend start command (auto-detected; must bind 0.0.0.0:$PORT)
|
|
115
119
|
--build force the frontend through its build step (vs serve as static)
|
|
116
120
|
--static serve the frontend dir as-is even if it has a build script
|
|
117
|
-
--no-backend frontend-only: skip backend even if one is detected
|
|
121
|
+
--no-backend frontend-only: skip backend even if one is detected (the app
|
|
122
|
+
keeps pointing at the backend it already has)
|
|
123
|
+
--unset-backend clear this app's backend URL: the frontend stops calling a
|
|
124
|
+
backend (the sandbox itself stays up — use teardown for that)
|
|
118
125
|
--private only you can open the deployed app (needs an account;
|
|
119
126
|
the DEFAULT for a new app once this machine is linked)
|
|
120
127
|
--org anyone in your org can open it (needs an account)
|
|
@@ -185,11 +192,16 @@ async function cmdDeploy(root, opts) {
|
|
|
185
192
|
const accountToken = identity && identity.accountToken;
|
|
186
193
|
if (accountToken) opts.token = accountToken; // sent as Bearer on allocate/backend
|
|
187
194
|
const hadBackend = !!d.backend;
|
|
195
|
+
let backendNote = null;
|
|
188
196
|
if (d.backend && !override && !accountToken) {
|
|
189
|
-
|
|
197
|
+
// Said in the JSON too: an agent reads only stdout, and "ok: true" with a
|
|
198
|
+
// frontend URL used to hide that the API it built never shipped.
|
|
199
|
+
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.";
|
|
200
|
+
log(opts, `↪ ${backendNote}`);
|
|
190
201
|
d.backend = null;
|
|
191
202
|
}
|
|
192
203
|
if (!d.frontend && !d.backend) {
|
|
204
|
+
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; }
|
|
193
205
|
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; }
|
|
194
206
|
emit({ ok: false, error: `could not detect a frontend in ${root}. Pass --frontend <dir>.`, code: "DETECT" });
|
|
195
207
|
return 2;
|
|
@@ -221,7 +233,7 @@ async function cmdDeploy(root, opts) {
|
|
|
221
233
|
creds.save(root, { slug: d.slug, slugUuid });
|
|
222
234
|
|
|
223
235
|
// ---- backend first (so its URL can be baked into the frontend) ----
|
|
224
|
-
let backendUrl = null,
|
|
236
|
+
let backendUrl = null, jobId = null, historyIds = [];
|
|
225
237
|
if (d.backend) {
|
|
226
238
|
if (opts.envFileError) { emit({ ok: false, error: opts.envFileError, code: "ENV_FILE" }); log(opts, `✗ ${opts.envFileError}`); return 1; }
|
|
227
239
|
log(opts, `→ packing backend (${path.relative(root, d.backend.dir) || "."}, ${d.backend.runtime})`);
|
|
@@ -238,9 +250,19 @@ async function cmdDeploy(root, opts) {
|
|
|
238
250
|
// deploy; ship the frontend so the user still gets a live URL, and surface
|
|
239
251
|
// how to proceed. Only genuine backend build/start errors fail the deploy.
|
|
240
252
|
if (e.code === "BACKEND_DISABLED" || e.code === "TOO_MANY") { log(opts, `↪ backend skipped — ${e.message}`); d.backend = null; backendNote = e.message; }
|
|
253
|
+
// Another deploy of THIS app is still installing. Both would overwrite
|
|
254
|
+
// each other's files inside the sandbox, so stop and say so plainly —
|
|
255
|
+
// with the job to follow — rather than shipping a half-deploy.
|
|
256
|
+
else if (e.code === "ALREADY_DEPLOYING") {
|
|
257
|
+
log(opts, `✗ ${e.message}`);
|
|
258
|
+
emit({ ok: false, slug: d.slug, status: "error", error: e.message, code: "ALREADY_DEPLOYING", ...(e.jobId ? { jobId: e.jobId } : {}) });
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
241
261
|
else { emit({ ok: false, error: e.message, code: e.code || "BACKEND" }); return 1; }
|
|
242
262
|
}
|
|
243
263
|
if (created) {
|
|
264
|
+
jobId = created.jobId;
|
|
265
|
+
if (created.historyId) historyIds.push(created.historyId);
|
|
244
266
|
const st = await pollBackend(created.jobId, { userId, userKey }, opts);
|
|
245
267
|
if (st.status !== "success" || !(st.urls && st.urls.backend)) {
|
|
246
268
|
emit({ ok: false, slug: d.slug, jobId: created.jobId, status: "error", error: st.error || "backend failed", logTail: st.logTail });
|
|
@@ -253,6 +275,20 @@ async function cmdDeploy(root, opts) {
|
|
|
253
275
|
}
|
|
254
276
|
}
|
|
255
277
|
|
|
278
|
+
// A deploy that ships no backend this time is NOT a deploy of an app with no
|
|
279
|
+
// backend: `--no-backend` (or a skipped/absent backend) keeps the app pointed
|
|
280
|
+
// at the backend it already has, which allocate reports. Without this, the
|
|
281
|
+
// rebuilt bundle and the regenerated speculos-env.js both said "no backend"
|
|
282
|
+
// and a live app started 404ing its own API. --unset-backend is the explicit
|
|
283
|
+
// way to clear it (the backend sandbox itself stays up; `teardown` removes it).
|
|
284
|
+
let keptBackendUrl = null;
|
|
285
|
+
const liveBackendUrl = (alloc && alloc.backendUrl) || null;
|
|
286
|
+
if (!backendUrl && liveBackendUrl) {
|
|
287
|
+
if (opts.unsetBackend) log(opts, `↪ clearing this app's backend URL (--unset-backend) — the backend itself stays up`);
|
|
288
|
+
else { keptBackendUrl = liveBackendUrl; log(opts, `↪ keeping this app's existing backend: ${keptBackendUrl} (--unset-backend to clear it)`); }
|
|
289
|
+
}
|
|
290
|
+
const feBackendUrl = backendUrl || keptBackendUrl;
|
|
291
|
+
|
|
256
292
|
// ---- frontend: build locally (if needed), then upload static output ----
|
|
257
293
|
let frontendUrl = null;
|
|
258
294
|
if (d.frontend) {
|
|
@@ -262,7 +298,7 @@ async function cmdDeploy(root, opts) {
|
|
|
262
298
|
try {
|
|
263
299
|
outDir = runBuild({
|
|
264
300
|
dir: d.frontend.dir, framework: d.frontend.framework, buildCmd: d.frontend.buildCmd,
|
|
265
|
-
base: alloc.base, backendUrl,
|
|
301
|
+
base: alloc.base, backendUrl: feBackendUrl,
|
|
266
302
|
connectorsUrl: alloc.connectorsUrl, connectorsToken: alloc.connectorsToken,
|
|
267
303
|
outputDir: d.frontend.outputDir, log: (m) => log(opts, m),
|
|
268
304
|
});
|
|
@@ -288,17 +324,18 @@ async function cmdDeploy(root, opts) {
|
|
|
288
324
|
// output shouldn't have its assets stripped just because one is named like a key.
|
|
289
325
|
const feTar = packDir(outDir, { dropBuildOutput: false, frontend: rootStatic, excludeDirs });
|
|
290
326
|
let fe;
|
|
291
|
-
try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, tarB64: feTar.base64, backendUrl }, opts); }
|
|
327
|
+
try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, tarB64: feTar.base64, backendUrl: feBackendUrl, unsetBackend: !!opts.unsetBackend }, opts); }
|
|
292
328
|
catch (e) { emit({ ok: false, error: e.message, code: e.code || "FRONTEND" }); return 1; }
|
|
293
329
|
frontendUrl = fe.frontendUrl;
|
|
330
|
+
if (fe.historyId) historyIds.push(fe.historyId);
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
const urls = {};
|
|
297
334
|
if (frontendUrl) urls.frontend = frontendUrl;
|
|
298
|
-
if (
|
|
335
|
+
if (feBackendUrl) urls.backend = feBackendUrl;
|
|
299
336
|
log(opts, `✓ live`);
|
|
300
337
|
if (urls.frontend) log(opts, ` frontend: ${urls.frontend}`);
|
|
301
|
-
if (urls.backend) log(opts, ` backend: ${urls.backend}`);
|
|
338
|
+
if (urls.backend) log(opts, ` backend: ${urls.backend}${keptBackendUrl ? " (kept — not redeployed this time)" : ""}`);
|
|
302
339
|
if (backendNote) log(opts, ` note: ${backendNote}`);
|
|
303
340
|
// Say who can open it. A new app is private now, and a URL printed with no
|
|
304
341
|
// word about visibility is the kind of thing people paste into Slack and
|
|
@@ -310,6 +347,11 @@ async function cmdDeploy(root, opts) {
|
|
|
310
347
|
else if (visibility === "public") log(opts, ` visibility: public — anyone with the link.`);
|
|
311
348
|
if (alloc && alloc.visibilityNote) log(opts, ` note: ${alloc.visibilityNote}`);
|
|
312
349
|
const out = { ok: true, slug: d.slug, userId, urls };
|
|
350
|
+
// What an agent needs to follow up without a second lookup: the backend
|
|
351
|
+
// job id for `status`, and the console history ids the gateway filed.
|
|
352
|
+
if (jobId) out.jobId = jobId;
|
|
353
|
+
if (keptBackendUrl) out.backendKept = true; // the URL above is the live one, not a new deploy
|
|
354
|
+
if (historyIds.length) out.historyIds = historyIds;
|
|
313
355
|
if (visibility) out.visibility = visibility;
|
|
314
356
|
if (alloc && alloc.visibilityNote) out.visibilityNote = alloc.visibilityNote;
|
|
315
357
|
if (backendNote) out.backendNote = backendNote;
|
|
@@ -339,8 +381,9 @@ async function cmdTeardown(root, opts) {
|
|
|
339
381
|
if (identity.accountToken) opts.token = identity.accountToken;
|
|
340
382
|
const r = await client.teardown({ userId: identity.userId, userKey: identity.userKey, slug }, opts);
|
|
341
383
|
if (saved && saved.slug === slug) creds.remove(root);
|
|
384
|
+
if (r.notFound) log(opts, `! nothing was deployed under "${slug}" from this machine - nothing to tear down`);
|
|
342
385
|
emit({ ok: true, ...r });
|
|
343
|
-
return 0;
|
|
386
|
+
return r.notFound ? 3 : 0;
|
|
344
387
|
} catch (e) { emit({ ok: false, error: e.message, code: e.code || "TEARDOWN" }); return 1; }
|
|
345
388
|
}
|
|
346
389
|
|
|
@@ -429,12 +472,14 @@ async function cmdLogout(opts) {
|
|
|
429
472
|
const identity = creds.loadIdentity();
|
|
430
473
|
const token = identity && identity.accountToken;
|
|
431
474
|
if (!token) { emit({ ok: true, alreadyLoggedOut: true }); return 0; }
|
|
432
|
-
let revoked = false;
|
|
433
|
-
try {
|
|
434
|
-
|
|
475
|
+
let revoked = false, unlinked = false;
|
|
476
|
+
try {
|
|
477
|
+
const r = await client.logout(token, { userId: identity.userId, userKey: identity.userKey }, opts);
|
|
478
|
+
revoked = !!r.revoked; unlinked = !!r.unlinked;
|
|
479
|
+
} catch { /* revoke best-effort; still clear locally */ }
|
|
435
480
|
creds.clearAccountToken();
|
|
436
|
-
log(opts, `✓ this device is signed out of your Speculos account${revoked ? " (token revoked)" : ""}.`);
|
|
437
|
-
emit({ ok: true, loggedOut: true, revoked });
|
|
481
|
+
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" : ""}.`);
|
|
482
|
+
emit({ ok: true, loggedOut: true, revoked, unlinked });
|
|
438
483
|
return 0;
|
|
439
484
|
}
|
|
440
485
|
|