speculos-toolkit 1.2.6 → 1.2.8

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
@@ -61,10 +61,24 @@ The first deploy mints a machine-global `~/.speculos/identity.json` (`{ userId,
61
61
  that owns every URL deployed from this machine; each project records its slug id in a
62
62
  gitignored `.speculos.json`. Keep both to retain ownership.
63
63
 
64
+ Set `SPECULOS_CONFIG_DIR` to an absolute directory path to use a separate identity
65
+ and account token stored at `<directory>/identity.json`. For example,
66
+ `SPECULOS_CONFIG_DIR=/absolute/path/to/profile speculos-toolkit login` signs into
67
+ that profile. Leave the variable unset to use `~/.speculos`. Relative paths,
68
+ unexpanded `~` paths, and empty values fail with `CREDENTIALS` before any API call
69
+ or credential write. Each project's `.speculos.json` stays in its project directory.
70
+ The credentials directory is excluded from frontend and backend upload archives.
71
+ It cannot also be selected as the deployment source or frontend build output.
72
+
64
73
  If either identity file is unreadable or damaged, the CLI stops so you can restore it
65
74
  before deploying. A failed login or logout reports the failure; rerun the same command
66
75
  to retry linking or revoking the device.
67
76
 
77
+ `logout` signs the terminal out and revokes its account token. Existing apps, URLs,
78
+ and their account ownership stay in place. The signed-out terminal cannot deploy,
79
+ change, remove, or poll those apps until an explicit `login` succeeds again. If the
80
+ server cannot confirm sign-out, the CLI keeps its credential so you can retry.
81
+
68
82
  ## Conventions
69
83
 
70
84
  - **Backend** listens on `process.env.PORT`, binds `0.0.0.0`. Node or Python.
@@ -80,7 +94,7 @@ speculos-toolkit status <jobId> poll a deployment
80
94
  speculos-toolkit teardown --slug <s> remove a deployment
81
95
  speculos-toolkit login [--token …] link this machine (browser approval at
82
96
  https://unified.speculos.ai/link)
83
- speculos-toolkit logout unlink it (revokes this device's token)
97
+ speculos-toolkit logout sign out (preserves apps and URLs)
84
98
  speculos-toolkit connectors [list|exec] your linked data sources, and one tool call
85
99
  speculos-toolkit install-skill install the Claude Code skill
86
100
 
@@ -103,3 +117,21 @@ configuration into the CLI process itself.
103
117
  Docs: https://unified.speculos.ai · Source: https://github.com/speculosai/unified_platform
104
118
 
105
119
  MIT
120
+
121
+ ### Deployment consistency (1.2.8)
122
+
123
+ An existing application's visibility changes only when the requested release is
124
+ published. Allocation or a backend quota refusal preserves the live audience.
125
+ Private code closes the frontend/backend gates before upload. A definitive bad
126
+ frontend archive restores the prior audience; an ambiguous network failure keeps
127
+ the application private until you retry. Use `npx -y speculos-toolkit@latest` for
128
+ visibility changes to existing applications; older clients receive an upgrade
129
+ message before any visibility change.
130
+
131
+ The CLI carries an app-specific deployment ID through backend and frontend
132
+ publication. Retried requests cannot publish different content under an already
133
+ used ID. A backend release reserves its matching frontend while that release is
134
+ unfinished, and a later dashboard visibility change invalidates an earlier
135
+ allocation. After linking an anonymous terminal, its anonymous deployment and
136
+ usage history is adopted into the verified account; pending adoption retries on
137
+ history reads.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "speculos-toolkit",
3
- "version": "1.2.6",
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.",
3
+ "version": "1.2.8",
4
+ "description": "The Speculos toolkit for coding agents — deploy any frontend/backend to a live URL and build against your linked data connectors (BigQuery, Postgres, Snowflake, Salesforce, …). Built for Claude Code, Codex, Cursor, and friends.",
5
5
  "bin": {
6
6
  "speculos-toolkit": "bin/speculos-toolkit.js"
7
7
  },
package/skill/SKILL.md CHANGED
@@ -292,8 +292,9 @@ Builds run locally (this machine already has the toolchain); only static output
292
292
  link from that first line and **relay it to the user** — ask them to open it, sign in, and
293
293
  click Approve. It links this machine to their account (already linked? it no-ops — pass
294
294
  `--relink` to switch accounts). Backend deploys then work with no password — **every
295
- account includes one backend app free**, so there's no enablement wait. Unlink with
296
- `speculos-toolkit logout`.
295
+ account includes one backend app free**, so there's no enablement wait. Sign out with
296
+ `speculos-toolkit logout`. Apps and URLs stay owned by the account; run `login`
297
+ again before this terminal can manage them.
297
298
  - **Frontend + backend (signed in):** just run the normal deploy — the saved sign-in
298
299
  authorizes the backend:
299
300
  ```bash
package/src/client.js CHANGED
@@ -48,7 +48,8 @@ async function linkPoll(code, opts = {}) { return post(base(opts) + "/api/cli/li
48
48
  // verify a pasted account token (login --token) and get its account/org
49
49
  async function whoami(token, opts = {}) { return get(base(opts) + "/api/account/whoami", token); }
50
50
  async function linkMachine(token, payload, opts = {}) { return post(base(opts) + "/api/account/link", payload, token); }
51
- // The machine identity rides along so the server can unlink it too.
51
+ // The machine identity rides along so the server can revoke its authority while
52
+ // preserving account ownership of its published applications.
52
53
  async function logout(token, machine = {}, opts = {}) { return post(base(opts) + "/api/account/logout", { userId: machine.userId, userKey: machine.userKey }, token); }
53
54
  // poll a backend job (owner-authenticated)
54
55
  // The machine credentials travel as headers, not a query string: a query
package/src/creds.js CHANGED
@@ -14,7 +14,12 @@ function credentialError(message, cause) {
14
14
 
15
15
  // ---- machine-global identity --------------------------------------------
16
16
 
17
- function identityDir() { return path.join(os.homedir(), ".speculos"); }
17
+ function identityDir() {
18
+ const configured = process.env.SPECULOS_CONFIG_DIR;
19
+ if (configured === undefined) return path.join(os.homedir(), ".speculos");
20
+ if (!path.isAbsolute(configured)) throw credentialError("SPECULOS_CONFIG_DIR must be an absolute path");
21
+ return configured;
22
+ }
18
23
  function identityFile() { return path.join(identityDir(), "identity.json"); }
19
24
 
20
25
  function loadIdentity() {
@@ -58,11 +63,15 @@ function saveAccountToken(accountToken) {
58
63
  const cur = loadIdentity() || {};
59
64
  writeIdentity({ ...cur, accountToken });
60
65
  }
61
- function clearAccountToken() {
66
+ function clearAccountToken(expectedToken) {
62
67
  const cur = loadIdentity();
63
- if (!cur) return;
68
+ if (!cur) return true;
69
+ // A login can finish while an earlier logout request is in flight. Never
70
+ // erase that newer credential when the old request eventually responds.
71
+ if (arguments.length && cur.accountToken !== expectedToken) return false;
64
72
  delete cur.accountToken;
65
73
  writeIdentity(cur);
74
+ return true;
66
75
  }
67
76
 
68
77
  // ---- per-project slug record --------------------------------------------
@@ -112,4 +121,4 @@ function ensureGitignored(root) {
112
121
  } catch { /* best effort */ }
113
122
  }
114
123
 
115
- module.exports = { loadIdentity, saveIdentity, saveAccountToken, clearAccountToken, load, save, remove, file };
124
+ module.exports = { identityDir, loadIdentity, saveIdentity, saveAccountToken, clearAccountToken, load, save, remove, file };
package/src/index.js CHANGED
@@ -96,7 +96,7 @@ USAGE
96
96
  --token <spec_tok_…> link with a token you already
97
97
  have (no browser); add --relink to move a device
98
98
  already linked to a different/personal account
99
- npx speculos-toolkit logout unlink this machine (revoke its token)
99
+ npx speculos-toolkit logout sign this terminal out (keep its apps and URLs)
100
100
  npx speculos-toolkit install-skill install the Claude Code /speculos-toolkit skill
101
101
  (+ allow the deploy command once, no more prompts)
102
102
  npx speculos-toolkit connectors [list]
@@ -163,7 +163,8 @@ OPTIONS
163
163
  --json machine-readable only (auto-on when non-TTY/CI)
164
164
 
165
165
  Identity: the first deploy mints a machine-global { userId, userKey } saved to
166
- ~/.speculos/identity.json — keep it to retain ownership of your URLs. Each project
166
+ ~/.speculos/identity.json (or an absolute SPECULOS_CONFIG_DIR) — keep it to retain
167
+ ownership of your URLs. Each project
167
168
  records its slug's stable id in .speculos.json (gitignored).
168
169
 
169
170
  The frontend is automatically pointed at the deployed backend URL via
@@ -210,7 +211,22 @@ async function cmdDeploy(root, opts) {
210
211
  // Keep the source directory even if an anonymous/frontend-only deploy skips
211
212
  // running it. Skipping its sandbox must never publish its source as frontend.
212
213
  const backendSourceDir = (d.backend && d.backend.dir) || (opts.backend && path.resolve(root, opts.backend));
213
- if (d.frontend && d.frontend.kind === "static" && backendSourceDir && path.resolve(d.frontend.dir) === path.resolve(backendSourceDir)) {
214
+ const canonicalPath = (dir) => { try { return fs.realpathSync(dir); } catch { return path.resolve(dir); } };
215
+ const credentialDir = creds.identityDir();
216
+ const privateDirExclusions = (archiveDir, privateDir) => {
217
+ const paths = new Set();
218
+ for (const [base, target] of [[path.resolve(archiveDir), path.resolve(privateDir)], [canonicalPath(archiveDir), canonicalPath(privateDir)]]) {
219
+ const rel = path.relative(base, target);
220
+ if (rel && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel)) paths.add(rel);
221
+ }
222
+ return paths;
223
+ };
224
+ const isCredentialDir = (dir) => canonicalPath(dir) === canonicalPath(credentialDir);
225
+ if ([d.frontend && d.frontend.dir, d.backend && d.backend.dir].filter(Boolean).some(isCredentialDir)) {
226
+ emit({ ok: false, error: "the deployment source is the credentials directory; select a separate frontend or backend directory", code: "CREDENTIALS_SCOPE" });
227
+ return 2;
228
+ }
229
+ if (d.frontend && d.frontend.kind === "static" && backendSourceDir && canonicalPath(d.frontend.dir) === canonicalPath(backendSourceDir)) {
214
230
  emit({ ok: false, error: "the static frontend and backend share a directory; select a separate frontend directory or build output so backend source is not published", code: "FRONTEND_SCOPE" });
215
231
  return 2;
216
232
  }
@@ -245,7 +261,7 @@ async function cmdDeploy(root, opts) {
245
261
  // ---- identity + slug allocation (mints a machine identity on first use) ----
246
262
  let alloc;
247
263
  try {
248
- alloc = await client.allocate({ userId: identity && identity.userId, userKey: identity && identity.userKey, slug: d.slug, visibility: opts.visibility }, opts);
264
+ alloc = await client.allocate({ userId: identity && identity.userId, userKey: identity && identity.userKey, slug: d.slug, visibility: opts.visibility, deploymentProtocol: 2 }, opts);
249
265
  } catch (e) {
250
266
  // Readable first: emit() is JSON for the agent case, but a person who typed
251
267
  // --private with no account needs to see WHY in plain words.
@@ -256,7 +272,7 @@ async function cmdDeploy(root, opts) {
256
272
  if (alloc.userKey) { // freshly minted on the server
257
273
  identity = { userId: alloc.userId, userKey: alloc.userKey };
258
274
  creds.saveIdentity(identity);
259
- log(opts, `→ new device identity ${alloc.userId} (saved to ~/.speculos/identity.json — keep it)`);
275
+ log(opts, `→ new device identity ${alloc.userId} (saved to ${path.join(credentialDir, "identity.json")} — keep it)`);
260
276
  }
261
277
  const userId = alloc.userId, userKey = identity.userKey, slugUuid = alloc.slugUuid;
262
278
  creds.save(root, { slug: d.slug, slugUuid });
@@ -265,12 +281,13 @@ async function cmdDeploy(root, opts) {
265
281
  let backendUrl = null, jobId = null, historyIds = [];
266
282
  if (d.backend) {
267
283
  log(opts, `→ packing backend (${path.relative(root, d.backend.dir) || "."}, ${d.backend.runtime})`);
268
- const backendTar = packDir(d.backend.dir, { dropBuildOutput: false });
284
+ const backendTar = packDir(d.backend.dir, { dropBuildOutput: false, excludeDirs: [...privateDirExclusions(d.backend.dir, credentialDir)] });
269
285
  log(opts, `→ deploying backend (${Math.round(backendTar.bytes / 1024)} KB) to an isolated sandbox`);
270
286
  let created;
271
287
  try {
272
288
  created = await client.startBackend({
273
289
  userId, userKey, slug: d.slug, slugUuid, override,
290
+ deploymentId: alloc.deploymentId, finalizeVisibility: !d.frontend,
274
291
  backend: { tarB64: backendTar.base64, runtime: d.backend.runtime, startCmd: d.backend.startCmd, env: opts.env },
275
292
  }, opts);
276
293
  } catch (e) {
@@ -343,29 +360,37 @@ async function cmdDeploy(root, opts) {
343
360
  });
344
361
  } 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; }
345
362
  }
363
+ if (isCredentialDir(outDir)) {
364
+ emit({ ok: false, error: "the frontend output is the credentials directory; select a separate build output directory", code: "CREDENTIALS_SCOPE" });
365
+ return 2;
366
+ }
346
367
  log(opts, `→ packing + uploading frontend (${path.relative(root, outDir) || "."})`);
347
- // If a plain static site is served straight from the repo root, exclude the
348
- // server code + secrets so they aren't published. Exclude the ACTUAL detected
349
- // backend dir (not a hardcoded name), plus any top-level backend-NAMED dir
350
- // that really looks like a backend (has package.json/requirements) — a
351
- // content dir that merely shares a name (e.g. a static api/) is left in.
352
- const rootStatic = path.resolve(outDir) === path.resolve(root) && d.frontend.kind === "static";
353
- let excludeDirs = [];
368
+ // Any static frontend can contain the selected backend, including a custom
369
+ // frontend directory. Exclude both the selected path and its real location
370
+ // so a symlink to the backend does not leave the source in the public bundle.
371
+ const set = privateDirExclusions(outDir, credentialDir);
372
+ if (d.frontend.kind === "static" && backendSourceDir) {
373
+ for (const rel of privateDirExclusions(outDir, backendSourceDir)) set.add(rel);
374
+ }
375
+ // Serving the repo root also excludes other top-level backend-looking dirs.
376
+ // Keep this heuristic at the repo root so ordinary static api/ content stays.
377
+ const rootStatic = d.frontend.kind === "static" && canonicalPath(outDir) === canonicalPath(root);
354
378
  if (rootStatic) {
355
- const set = new Set();
356
- if (backendSourceDir) { const rel = path.relative(root, backendSourceDir); if (rel && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel)) set.add(rel); }
357
379
  const looksBackend = (p) => fs.existsSync(path.join(p, "package.json")) || fs.existsSync(path.join(p, "requirements.txt")) || fs.existsSync(path.join(p, "pyproject.toml"));
358
380
  for (const name of BACKEND_DIRS) { const p = path.join(root, name); if (fs.existsSync(p) && looksBackend(p)) set.add(name); }
359
- excludeDirs = [...set];
381
+ }
382
+ const excludeDirs = [...set];
383
+ if (rootStatic) {
360
384
  log(opts, ` note: serving the repo root — excluding secrets${excludeDirs.length ? " + " + excludeDirs.join(", ") : ""} from the public bundle`);
361
385
  }
362
386
  // Every frontend bundle is downloadable, including explicitly selected
363
387
  // directories and build output that copied a public/ credential file.
364
388
  const feTar = packDir(outDir, { dropBuildOutput: false, frontend: true, excludeDirs });
365
389
  let fe;
366
- try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, tarB64: feTar.base64, backendUrl: feBackendUrl, unsetBackend: !!opts.unsetBackend }, opts); }
390
+ try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, deploymentId: alloc.deploymentId, tarB64: feTar.base64, backendUrl: feBackendUrl, unsetBackend: !!opts.unsetBackend }, opts); }
367
391
  catch (e) { emit({ ok: false, error: e.message, code: e.code || "FRONTEND" }); return 1; }
368
392
  frontendUrl = fe.frontendUrl;
393
+ if (fe.visibility) alloc.visibility = fe.visibility;
369
394
  if (fe.historyId) historyIds.push(fe.historyId);
370
395
  }
371
396
 
@@ -401,7 +426,7 @@ async function cmdDeploy(root, opts) {
401
426
  async function cmdStatus(jobId, opts) {
402
427
  if (!jobId) { emit({ ok: false, error: "jobId required" }); return 2; }
403
428
  const identity = creds.loadIdentity();
404
- if (!identity || !identity.userId) { emit({ ok: false, error: "no identity (~/.speculos/identity.json) — cannot poll" }); return 1; }
429
+ if (!identity || !identity.userId) { emit({ ok: false, error: `no identity (${path.join(creds.identityDir(), "identity.json")}) — cannot poll` }); return 1; }
405
430
  const st = await client.backendStatus(jobId, { userId: identity.userId, userKey: identity.userKey }, opts);
406
431
  // `ok` for contract-consistency with every other command (false only on a
407
432
  // terminal failure; a still-running poll is ok:true).
@@ -415,7 +440,7 @@ async function cmdTeardown(root, opts) {
415
440
  const identity = creds.loadIdentity();
416
441
  const slug = opts.slug || (saved && saved.slug);
417
442
  if (!slug) { emit({ ok: false, error: "--slug required (or run from the project dir with a .speculos.json)" }); return 2; }
418
- if (!identity || !identity.userId) { emit({ ok: false, error: "no identity found (~/.speculos/identity.json) — nothing to tear down from this machine" }); return 1; }
443
+ if (!identity || !identity.userId) { emit({ ok: false, error: `no identity found (${path.join(creds.identityDir(), "identity.json")}) — nothing to tear down from this machine` }); return 1; }
419
444
  try {
420
445
  if (identity.accountToken) opts.token = identity.accountToken;
421
446
  const r = await client.teardown({ userId: identity.userId, userKey: identity.userKey, slug }, opts);
@@ -528,24 +553,38 @@ async function cmdLogin(opts) {
528
553
  return 0;
529
554
  }
530
555
 
531
- // ---- logout: revoke this device's token (server + local) ----
556
+ // ---- logout: revoke this terminal's authority, preserve its published apps ----
532
557
  async function cmdLogout(opts) {
533
558
  const identity = creds.loadIdentity();
534
559
  const token = identity && identity.accountToken;
535
- if (!token) { emit({ ok: true, alreadyLoggedOut: true }); return 0; }
536
- let revoked = false, unlinked = false;
560
+ if (!token && !(identity && identity.userId)) { emit({ ok: true, alreadyLoggedOut: true }); return 0; }
561
+ let revoked = false, unlinked = false, signedOut = false, ownershipPreserved = false, alreadyRevoked = false, tokenAbsent = false;
537
562
  try {
538
563
  const r = await client.logout(token, { userId: identity.userId, userKey: identity.userKey }, opts);
539
- revoked = !!r.revoked; unlinked = !!r.unlinked;
564
+ const hasMachine = !!(identity.userId || identity.userKey);
565
+ // Older servers can return HTTP 200 after swallowing revocation/unlink
566
+ // failures. Success must establish both token revocation and (when there
567
+ // is a machine) loss of machine authority before discarding our retry key.
568
+ if (r.ok !== true || !(r.revoked === true || r.alreadyRevoked === true || (!token && r.tokenAbsent === true)) ||
569
+ (hasMachine && r.signedOut !== true && r.unlinked !== true)) {
570
+ throw Object.assign(new Error("the server did not confirm that this terminal was signed out"), { code: "LOGOUT_INCOMPLETE" });
571
+ }
572
+ revoked = r.revoked === true; alreadyRevoked = r.alreadyRevoked === true;
573
+ tokenAbsent = r.tokenAbsent === true;
574
+ unlinked = r.unlinked === true; signedOut = r.signedOut === true || unlinked;
575
+ ownershipPreserved = r.ownershipPreserved === true;
540
576
  } catch (e) {
541
- // Keep the token so a retry can revoke it and unlink the machine. Clearing
577
+ // Keep the token so a retry can revoke it and sign the machine out. Clearing
542
578
  // it during an outage makes an active server credential unrecoverable.
543
579
  emit({ ok: false, loggedOut: false, error: `could not sign out: ${e.message}; retry logout when the service is reachable`, code: e.code || "LOGOUT" });
544
580
  return 1;
545
581
  }
546
- creds.clearAccountToken();
547
- 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" : ""}.`);
548
- emit({ ok: true, loggedOut: true, revoked, unlinked });
582
+ if (!creds.clearAccountToken(token)) {
583
+ emit({ ok: false, loggedOut: false, revoked, code: "LOGOUT_SUPERSEDED", error: "the saved sign-in changed while logout was pending; the newer credential was kept" });
584
+ return 1;
585
+ }
586
+ log(opts, `✓ this terminal is signed out of your Speculos account.${ownershipPreserved ? " Its apps and URLs are preserved; run `login` to manage them again." : ""}`);
587
+ emit({ ok: true, loggedOut: true, revoked, alreadyRevoked, tokenAbsent, signedOut, ownershipPreserved, unlinked });
549
588
  return 0;
550
589
  }
551
590
 
package/src/pack.js CHANGED
@@ -39,7 +39,12 @@ function packDir(dir, { dropBuildOutput = false, frontend = false, excludeDirs =
39
39
  for (const e of excludes) args.push("--exclude=" + e);
40
40
  // excludeDirs are TOP-LEVEL paths (e.g. a backend dir) — anchor with ./ so they
41
41
  // exclude only that top-level dir, never a same-named dir nested in the site.
42
- for (const e of (excludeDirs || [])) args.push("--exclude=./" + String(e).replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, ""));
42
+ for (const e of (excludeDirs || [])) {
43
+ // These are literal paths, unlike the wildcard patterns above. A custom
44
+ // credentials directory may itself contain brackets, stars or question marks.
45
+ const literal = String(e).replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "").replace(/[\\*?\[\]]/g, (character) => "\\" + character);
46
+ args.push("--exclude=./" + literal);
47
+ }
43
48
  args.push("-C", dir, ".");
44
49
  try {
45
50
  execFileSync("tar", args, { stdio: ["ignore", "ignore", "pipe"] });