speculos-toolkit 1.2.5 → 1.2.7

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
@@ -30,7 +30,8 @@ host never runs your build.
30
30
 
31
31
  `--private` or `--org` without an account fails the deploy (`LOGIN_REQUIRED`)
32
32
  rather than publishing something public that you asked to keep private.
33
- A redeploy never changes the visibility of an app that is already live.
33
+ A redeploy without a visibility flag preserves the app's current visibility.
34
+ An explicit `--private`, `--org`, or `--public` changes it.
34
35
 
35
36
  ## What it prints
36
37
 
@@ -60,6 +61,24 @@ The first deploy mints a machine-global `~/.speculos/identity.json` (`{ userId,
60
61
  that owns every URL deployed from this machine; each project records its slug id in a
61
62
  gitignored `.speculos.json`. Keep both to retain ownership.
62
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
+
73
+ If either identity file is unreadable or damaged, the CLI stops so you can restore it
74
+ before deploying. A failed login or logout reports the failure; rerun the same command
75
+ to retry linking or revoking the device.
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
+
63
82
  ## Conventions
64
83
 
65
84
  - **Backend** listens on `process.env.PORT`, binds `0.0.0.0`. Node or Python.
@@ -75,20 +94,26 @@ speculos-toolkit status <jobId> poll a deployment
75
94
  speculos-toolkit teardown --slug <s> remove a deployment
76
95
  speculos-toolkit login [--token …] link this machine (browser approval at
77
96
  https://unified.speculos.ai/link)
78
- speculos-toolkit logout unlink it (revokes this device's token)
97
+ speculos-toolkit logout sign out (preserves apps and URLs)
79
98
  speculos-toolkit connectors [list|exec] your linked data sources, and one tool call
80
99
  speculos-toolkit install-skill install the Claude Code skill
81
100
 
82
101
  --frontend <dir> --backend <dir> --slug <name>
83
102
  --runtime node|python|bun --start "<cmd>" --build --static --output <dir>
84
103
  --private | --org | --public
85
- --env KEY=VAL --env-file <file> --api <url> --timeout <sec> --json
104
+ --env KEY=VAL --backend-env-file <file> --api <url> --timeout <sec> --json
86
105
  ```
87
106
 
88
107
  `--api` defaults to `https://unified-api.speculos.ai/toolkit` (the platform gateway,
89
108
  which proxies to the deploy orchestrator and records the deploy in the console's
90
109
  history); `SPECULOS_API` overrides it.
91
110
 
111
+ Use `--backend-env-file` for backend configuration. The legacy `--env-file` alias
112
+ still works when invoking Node with a delimiter, such as
113
+ `node -- /path/to/speculos-toolkit.js deploy --env-file .env.backend`.
114
+ Node 24/25 otherwise consume that flag before the CLI starts and can load backend
115
+ configuration into the CLI process itself.
116
+
92
117
  Docs: https://unified.speculos.ai · Source: https://github.com/speculosai/unified_platform
93
118
 
94
119
  MIT
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "speculos-toolkit",
3
- "version": "1.2.5",
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.7",
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
  },
8
8
  "type": "commonjs",
9
+ "scripts": {
10
+ "test": "node --test test/*.test.js"
11
+ },
9
12
  "engines": {
10
13
  "node": ">=18"
11
14
  },
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
@@ -304,7 +305,11 @@ Builds run locally (this machine already has the toolchain); only static output
304
305
  (Admins/CI can instead pass `--override <password>` to bypass account auth.)
305
306
  - If `detect` got the folders wrong, add `--frontend ./web` and/or `--backend ./api`.
306
307
  - Useful flags: `--slug <name>`, `--env KEY=VAL` (repeatable, backend env), `--build`
307
- (force the frontend through its build step), `--env-file <file>`.
308
+ (force the frontend through its build step), `--backend-env-file <file>`.
309
+ Prefer `--backend-env-file` over the legacy `--env-file` alias: recent Node
310
+ versions consume the legacy flag before the CLI starts and can load backend
311
+ configuration into the CLI process. Direct legacy invocations require
312
+ `node -- /path/to/speculos-toolkit.js deploy --env-file <file>`.
308
313
 
309
314
  ### Who can see the deployed app
310
315
 
package/src/build.js CHANGED
@@ -75,13 +75,21 @@ function setupNextConfig(dir, base, log) {
75
75
  const esm = pkgEsm;
76
76
  const file = path.join(dir, esm ? "next.config.mjs" : "next.config.js");
77
77
  const body = `{ ${over}, images: { unoptimized: true } }`;
78
- fs.writeFileSync(file, esm ? `export default ${body};\n` : `module.exports = ${body};\n`);
78
+ fs.writeFileSync(file, esm ? `export default ${body};\n` : `module.exports = ${body};\n`, { flag: "wx" });
79
79
  log && log(` next: wrote ${path.basename(file)} (basePath=${base.replace(/\/$/, "")})`);
80
- return () => { try { fs.unlinkSync(file); } catch { /* ignore */ } };
80
+ return () => { try { fs.unlinkSync(file); } catch (e) { if (e.code !== "ENOENT") throw e; } };
81
81
  }
82
82
 
83
83
  // existing config -> back it up under a name Next won't load, write a wrapper
84
84
  const bak = path.join(dir, `next.config.__speculos_orig__.${found.ext}`);
85
+ // A previous interrupted build may have left the only copy of the user's
86
+ // config here. Never replace that recovery copy with our generated wrapper.
87
+ try {
88
+ fs.lstatSync(bak);
89
+ const e = new Error(`Cannot wrap ${path.basename(found.p)}: ${path.basename(bak)} already exists. Restore or move that backup before building again.`);
90
+ e.code = "NEXT_CONFIG_BACKUP";
91
+ throw e;
92
+ } catch (e) { if (e.code !== "ENOENT") throw e; }
85
93
  fs.renameSync(found.p, bak);
86
94
  const imp = `./next.config.__speculos_orig__.${found.ext}`;
87
95
  const esm = found.ext === "mjs" || found.ext === "ts" || (found.ext === "js" && pkgEsm);
@@ -92,9 +100,18 @@ function setupNextConfig(dir, base, log) {
92
100
  const wrapper = esm
93
101
  ? `import orig from ${JSON.stringify(imp)};\nexport default async (phase, ctx) => {\n${merge}};\n`
94
102
  : `const orig = require(${JSON.stringify(imp)});\nmodule.exports = async (phase, ctx) => {\n${merge}};\n`;
95
- fs.writeFileSync(found.p, wrapper);
103
+ try { fs.writeFileSync(found.p, wrapper, { flag: "wx" }); }
104
+ catch (e) {
105
+ // setup itself happens before the build's finally block. Restore here too
106
+ // when the wrapper could not be written (for example, a full disk).
107
+ try { fs.renameSync(bak, found.p); }
108
+ catch (restoreError) { throw new AggregateError([e, restoreError], `Could not write or restore ${path.basename(found.p)}; original config is at ${bak}`); }
109
+ throw e;
110
+ }
96
111
  log && log(` next: set basePath/assetPrefix=${base.replace(/\/$/, "")} (wrapped ${path.basename(found.p)})`);
97
- return () => { try { fs.unlinkSync(found.p); fs.renameSync(bak, found.p); } catch { /* ignore */ } };
112
+ // Rename replaces the wrapper atomically and retains the original file mode.
113
+ // A failed restore must fail the deploy instead of silently changing source.
114
+ return () => fs.renameSync(bak, found.p);
98
115
  }
99
116
 
100
117
  // Frameworks prefix THEIR OWN bundled assets under the sub-path, but root-absolute
@@ -107,6 +124,7 @@ function setupNextConfig(dir, base, log) {
107
124
  function rewritePublicAssetPaths(outDir, base, log) {
108
125
  const noSlash = base.replace(/\/$/, "");
109
126
  const TEXT = /\.(html?|js|mjs|cjs|css|json|txt|xml|svg|webmanifest)$/i;
127
+ const root = fs.realpathSync(outDir);
110
128
 
111
129
  // 1) collect root-absolute paths of real asset files (skip _next/* — already
112
130
  // prefixed by assetPrefix — and route .html files).
@@ -114,9 +132,19 @@ function rewritePublicAssetPaths(outDir, base, log) {
114
132
  (function walk(d, rel) {
115
133
  for (const name of fs.readdirSync(d)) {
116
134
  const abs = path.join(d, name), r = rel + "/" + name;
117
- let st; try { st = fs.statSync(abs); } catch { continue; }
135
+ let st; try { st = fs.lstatSync(abs); } catch { continue; }
136
+ if (st.isSymbolicLink()) {
137
+ // The host supports symlinks to files inside the bundle. Include their
138
+ // public URLs, but never follow directory links or edit through links.
139
+ try {
140
+ const target = fs.realpathSync(abs), relTarget = path.relative(root, target);
141
+ if (relTarget === ".." || relTarget.startsWith(".." + path.sep) || path.isAbsolute(relTarget)) continue;
142
+ if (fs.statSync(target).isFile() && !/\.html?$/i.test(name)) assets.push(r);
143
+ } catch { /* dangling/cyclic link */ }
144
+ continue;
145
+ }
118
146
  if (st.isDirectory()) { if (r === "/_next") continue; walk(abs, r); }
119
- else if (!/\.html?$/i.test(name)) assets.push(r);
147
+ else if (st.isFile() && !/\.html?$/i.test(name)) assets.push(r);
120
148
  }
121
149
  })(outDir, "");
122
150
  if (!assets.length) return;
@@ -132,9 +160,10 @@ function rewritePublicAssetPaths(outDir, base, log) {
132
160
  (function walk2(d) {
133
161
  for (const name of fs.readdirSync(d)) {
134
162
  const abs = path.join(d, name);
135
- let st; try { st = fs.statSync(abs); } catch { continue; }
163
+ let st; try { st = fs.lstatSync(abs); } catch { continue; }
164
+ if (st.isSymbolicLink()) continue;
136
165
  if (st.isDirectory()) walk2(abs);
137
- else if (TEXT.test(name)) {
166
+ else if (st.isFile() && TEXT.test(name)) {
138
167
  const s = fs.readFileSync(abs, "utf8");
139
168
  const n = s.replace(re, (_m, d1, a) => d1 + noSlash + a);
140
169
  if (n !== s) { fs.writeFileSync(abs, n); changed++; }
@@ -144,11 +173,28 @@ function rewritePublicAssetPaths(outDir, base, log) {
144
173
  log && log(` rewrote root-absolute refs to ${assets.length} public asset(s) under the sub-path (${changed} file(s))`);
145
174
  }
146
175
 
147
- function findOutput(dir, preferred) {
148
- const candidates = [preferred, "dist", "build", "out", ".output/public", ".svelte-kit/output/client", "_site"].filter(Boolean);
176
+ function findOutput(dir, preferred, framework) {
177
+ // A requested/framework-specific output is authoritative. Falling back to a
178
+ // stale directory from a previous build can publish an entirely different app.
179
+ const candidates = preferred ? [preferred] : ["dist", "build", "out", ".output/public", ".svelte-kit/output/client", "_site"];
149
180
  for (const c of candidates) {
150
- const p = path.join(dir, c);
151
- try { if (fs.statSync(p).isDirectory() && fs.readdirSync(p).length) return p; } catch { /* next */ }
181
+ const p = path.resolve(dir, c);
182
+ try {
183
+ if (!fs.lstatSync(p).isDirectory() || !fs.readdirSync(p).length) continue;
184
+ if (framework !== "angular" || fs.existsSync(path.join(p, "index.html"))) return p;
185
+ // Angular's application builder writes dist/<project>/browser, while
186
+ // older builders write dist/<project>. Upload the browser entry directory,
187
+ // never the parent containing server bundles or multiple applications.
188
+ const nested = [path.join(p, "browser")];
189
+ for (const name of fs.readdirSync(p)) {
190
+ const child = path.join(p, name);
191
+ if (fs.lstatSync(child).isDirectory()) nested.push(child, path.join(child, "browser"));
192
+ }
193
+ const matches = [...new Set(nested)].filter((d) => {
194
+ try { return fs.lstatSync(d).isDirectory() && fs.lstatSync(path.join(d, "index.html")).isFile(); } catch { return false; }
195
+ });
196
+ if (matches.length === 1) return matches[0];
197
+ } catch { /* next */ }
152
198
  }
153
199
  return null;
154
200
  }
@@ -163,7 +209,7 @@ function runBuild({ dir, framework, buildCmd, base, backendUrl, connectorsUrl, c
163
209
  const installEnv = Object.assign({}, process.env); delete installEnv.NODE_ENV;
164
210
  const lock = fs.existsSync(path.join(dir, "package-lock.json"));
165
211
  log && log(` installing dependencies…`);
166
- run(npm, [lock ? "ci" : "install", "--no-audit", "--no-fund"], dir, installEnv);
212
+ run(npm, [lock ? "ci" : "install", "--include=dev", "--no-audit", "--no-fund"], dir, installEnv);
167
213
 
168
214
  // 2) build with base path + backend URL baked in
169
215
  const bc = baseConfig(framework, base);
@@ -181,7 +227,7 @@ function runBuild({ dir, framework, buildCmd, base, backendUrl, connectorsUrl, c
181
227
  }
182
228
 
183
229
  // 3) locate the output
184
- const out = findOutput(dir, outputDir);
230
+ const out = findOutput(dir, outputDir, framework);
185
231
  if (!out) {
186
232
  if (framework === "next") { const e = new Error("Next.js produced no static export (out/). The app likely uses server features (SSR/API routes/server actions) that a static host can't run."); e.code = "NEXT_NOT_EXPORTED"; throw e; }
187
233
  const e = new Error(`build finished but no output dir found (looked for ${outputDir || "dist/build/out"})`); e.code = "BUILD_OUTPUT"; throw e;
package/src/client.js CHANGED
@@ -8,22 +8,33 @@ const DEFAULT_API = process.env.SPECULOS_API || "https://unified-api.speculos.ai
8
8
 
9
9
  function base(opts) { return (opts.api || DEFAULT_API).replace(/\/$/, ""); }
10
10
 
11
+ async function responseData(res) {
12
+ let data;
13
+ try { data = await res.json(); } catch { /* report a bounded, non-secret error below */ }
14
+ if (!res.ok) {
15
+ const error = data && typeof data === "object" ? data : {};
16
+ const e = new Error(typeof error.error === "string" ? error.error : `HTTP ${res.status}`);
17
+ e.code = error.code; e.status = res.status; e.jobId = error.jobId || null;
18
+ throw e;
19
+ }
20
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
21
+ throw Object.assign(new Error("the API returned an invalid JSON response"), { code: "BAD_RESPONSE", status: res.status });
22
+ }
23
+ return data;
24
+ }
25
+
11
26
  async function post(url, body, token) {
12
27
  const headers = { "Content-Type": "application/json" };
13
28
  if (token) headers["Authorization"] = "Bearer " + token;
14
29
  const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
15
- const data = await res.json().catch(() => ({}));
16
30
  // 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; }
18
- return data;
31
+ return responseData(res);
19
32
  }
20
33
  async function get(url, token, extra) {
21
34
  const headers = Object.assign({}, extra || {});
22
35
  if (token) headers["Authorization"] = "Bearer " + token;
23
36
  const res = await fetch(url, { headers });
24
- const data = await res.json().catch(() => ({}));
25
- if (!res.ok) { const e = new Error(data.error || `HTTP ${res.status}`); e.code = data.code; e.status = res.status; throw e; }
26
- return data;
37
+ return responseData(res);
27
38
  }
28
39
 
29
40
  // establish identity (mint on first call) + allocate the slug's stable uuid.
@@ -37,7 +48,8 @@ async function linkPoll(code, opts = {}) { return post(base(opts) + "/api/cli/li
37
48
  // verify a pasted account token (login --token) and get its account/org
38
49
  async function whoami(token, opts = {}) { return get(base(opts) + "/api/account/whoami", token); }
39
50
  async function linkMachine(token, payload, opts = {}) { return post(base(opts) + "/api/account/link", payload, token); }
40
- // 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.
41
53
  async function logout(token, machine = {}, opts = {}) { return post(base(opts) + "/api/account/logout", { userId: machine.userId, userKey: machine.userKey }, token); }
42
54
  // poll a backend job (owner-authenticated)
43
55
  // The machine credentials travel as headers, not a query string: a query
package/src/creds.js CHANGED
@@ -6,34 +6,52 @@
6
6
  const fs = require("fs");
7
7
  const os = require("os");
8
8
  const path = require("path");
9
+ const crypto = require("crypto");
10
+
11
+ function credentialError(message, cause) {
12
+ return Object.assign(new Error(message), { code: "CREDENTIALS", cause });
13
+ }
9
14
 
10
15
  // ---- machine-global identity --------------------------------------------
11
16
 
12
- 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
+ }
13
23
  function identityFile() { return path.join(identityDir(), "identity.json"); }
14
24
 
15
25
  function loadIdentity() {
16
26
  const f = identityFile();
17
27
  let raw;
18
- try { raw = fs.readFileSync(f, "utf8"); } catch { return null; } // no file yet
19
- try { return JSON.parse(raw); }
20
- catch {
21
- // The file EXISTS but is corrupt/truncated. Don't silently treat it as
22
- // "missing" — that would mint a fresh identity and overwrite it, orphaning
23
- // the URLs it owned. Preserve it under .corrupt for recovery and warn.
24
- try { fs.renameSync(f, f + ".corrupt"); process.stderr.write(`! ${f} was unreadable — backed up to ${f}.corrupt; minting a fresh identity.\n`); } catch { /* ignore */ }
25
- return null;
28
+ try { raw = fs.readFileSync(f, "utf8"); }
29
+ catch (e) {
30
+ if (e.code === "ENOENT") return null;
31
+ throw credentialError(`could not read ${f}; restore access before continuing`, e);
26
32
  }
33
+ let value;
34
+ try { value = JSON.parse(raw); } catch (e) { throw credentialError(`${f} contains invalid JSON; restore the identity file before continuing`, e); }
35
+ const hasMachine = value && (value.userId !== undefined || value.userKey !== undefined);
36
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
37
+ (hasMachine && (typeof value.userId !== "string" || typeof value.userKey !== "string" || !/^[a-z0-9]{6,24}$/.test(value.userId) || !/^[A-Za-z0-9_-]{16,128}$/.test(value.userKey))) ||
38
+ (value.accountToken !== undefined && (typeof value.accountToken !== "string" || !value.accountToken.startsWith("spec_tok_")))) {
39
+ throw credentialError(`${f} contains an invalid identity; restore the identity file before continuing`);
40
+ }
41
+ return value;
27
42
  }
28
43
  // Merge-write so we never drop a field the other writer set (e.g. accountToken).
29
44
  // Atomic (tmp + rename) so a crash/full-disk mid-write can't truncate the file.
30
45
  function writeIdentity(obj) {
46
+ const tmp = identityFile() + `.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
31
47
  try {
32
- fs.mkdirSync(identityDir(), { recursive: true });
33
- const tmp = identityFile() + ".tmp";
34
- fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
48
+ fs.mkdirSync(identityDir(), { recursive: true, mode: 0o700 });
49
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600, flag: "wx" });
35
50
  fs.renameSync(tmp, identityFile());
36
- } catch { /* best effort */ }
51
+ } catch (e) {
52
+ try { fs.unlinkSync(tmp); } catch { /* no temporary file */ }
53
+ throw credentialError(`could not save ${identityFile()}; deployment ownership was not saved`, e);
54
+ }
37
55
  }
38
56
  function saveIdentity({ userId, userKey }) {
39
57
  const cur = loadIdentity() || {};
@@ -45,11 +63,15 @@ function saveAccountToken(accountToken) {
45
63
  const cur = loadIdentity() || {};
46
64
  writeIdentity({ ...cur, accountToken });
47
65
  }
48
- function clearAccountToken() {
66
+ function clearAccountToken(expectedToken) {
49
67
  const cur = loadIdentity();
50
- 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;
51
72
  delete cur.accountToken;
52
73
  writeIdentity(cur);
74
+ return true;
53
75
  }
54
76
 
55
77
  // ---- per-project slug record --------------------------------------------
@@ -57,10 +79,29 @@ function clearAccountToken() {
57
79
  function file(root) { return path.join(root, ".speculos.json"); }
58
80
 
59
81
  function load(root) {
60
- try { return JSON.parse(fs.readFileSync(file(root), "utf8")); } catch { return null; }
82
+ let raw;
83
+ try { raw = fs.readFileSync(file(root), "utf8"); }
84
+ catch (e) {
85
+ if (e.code === "ENOENT") return null;
86
+ throw Object.assign(new Error(`could not read ${file(root)}; restore access before continuing`), { code: "PROJECT_STATE", cause: e });
87
+ }
88
+ let value;
89
+ try { value = JSON.parse(raw); } catch { /* rejected below */ }
90
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
91
+ typeof value.slug !== "string" || !/^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(value.slug)) {
92
+ throw Object.assign(new Error(`${file(root)} contains an invalid project record; restore it before continuing`), { code: "PROJECT_STATE" });
93
+ }
94
+ return value;
61
95
  }
62
96
  function save(root, data) {
63
- fs.writeFileSync(file(root), JSON.stringify(data, null, 2) + "\n");
97
+ const tmp = file(root) + `.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
98
+ try {
99
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 });
100
+ fs.renameSync(tmp, file(root));
101
+ } catch (e) {
102
+ try { fs.unlinkSync(tmp); } catch { /* no temporary file */ }
103
+ throw Object.assign(new Error(`could not save ${file(root)}; project ownership was not saved`), { code: "PROJECT_STATE", cause: e });
104
+ }
64
105
  ensureGitignored(root);
65
106
  }
66
107
  function remove(root) {
@@ -80,4 +121,4 @@ function ensureGitignored(root) {
80
121
  } catch { /* best effort */ }
81
122
  }
82
123
 
83
- module.exports = { loadIdentity, saveIdentity, saveAccountToken, clearAccountToken, load, save, remove, file };
124
+ module.exports = { identityDir, loadIdentity, saveIdentity, saveAccountToken, clearAccountToken, load, save, remove, file };
package/src/detect.js CHANGED
@@ -30,6 +30,9 @@ function classifyFrontend(dir, opts = {}) {
30
30
  const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
31
31
  let outputDir = null, framework = "other";
32
32
  if (deps.next) { outputDir = "out"; framework = "next"; } // next export; SSR not covered by a static host
33
+ // SvelteKit also depends on Vite, but its static adapter writes build/ and
34
+ // owns routing/base configuration. Treating it as plain Vite looks in dist/.
35
+ else if (deps["@sveltejs/kit"]) { outputDir = "build"; framework = "svelte"; }
33
36
  else if (deps.vite || deps["@vitejs/plugin-react"]) { outputDir = "dist"; framework = "vite"; }
34
37
  else if (deps["react-scripts"]) { outputDir = "build"; framework = "cra"; }
35
38
  else if (deps["@angular/core"]) { outputDir = "dist"; framework = "angular"; }
package/src/index.js CHANGED
@@ -19,27 +19,38 @@ const VERSION = require("../package.json").version;
19
19
 
20
20
  function parseArgs(argv) {
21
21
  const opts = { env: {}, _: [] };
22
+ const bad = (message) => { opts.argError ||= message; };
22
23
  const valueFlags = {
23
24
  "--frontend": "frontend", "--backend": "backend", "--slug": "slug",
24
25
  "--start": "start", "--runtime": "runtime", "--output": "output",
25
- "--api": "api", "--timeout": "timeout", "--env-file": "envFile",
26
+ "--api": "api", "--timeout": "timeout", "--backend-env-file": "envFile", "--env-file": "envFile",
26
27
  "--override": "override",
27
28
  "--connector": "connector", "--tool": "tool", "--args": "args", "--args-file": "argsFile",
28
29
  "--token": "pasteToken",
29
30
  };
30
31
  for (let i = 0; i < argv.length; i++) {
31
32
  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];
33
+ if (a === "--env" || valueFlags[a]) {
34
+ const value = argv[i + 1];
35
+ if (value === undefined || value.startsWith("--")) { bad(`${a} requires a value`); continue; }
36
+ i++;
37
+ if (a === "--env") {
38
+ const j = value.indexOf("=");
39
+ if (j < 1 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value.slice(0, j))) bad("--env requires KEY=VALUE");
40
+ else opts.env[value.slice(0, j)] = value.slice(j + 1);
41
+ } else opts[valueFlags[a]] = value;
42
+ }
34
43
  else if (a === "--build") opts.build = true;
35
44
  else if (a === "--static" || a === "--no-build") opts.static = true;
36
45
  else if (a === "--relink" || a === "--force") opts.relink = true;
37
46
  else if (a === "--project") opts.project = true;
38
47
  // Who can open the deployed app. A new app is private unless told
39
48
  // otherwise; these also re-set an existing app on redeploy.
40
- else if (a === "--private") opts.visibility = "private";
41
- else if (a === "--org") opts.visibility = "org";
42
- else if (a === "--public") opts.visibility = "public";
49
+ else if (["--private", "--org", "--public"].includes(a)) {
50
+ const visibility = a.slice(2);
51
+ if (opts.visibility && opts.visibility !== visibility) bad("choose only one of --private, --org, or --public");
52
+ opts.visibility = visibility;
53
+ }
43
54
  else if (a === "--no-backend") opts.noBackend = true;
44
55
  // Explicitly clear the app's saved backend URL. A deploy that simply ships
45
56
  // no backend KEEPS it (see cmdDeploy) — this is how you say "no backend".
@@ -48,17 +59,23 @@ function parseArgs(argv) {
48
59
  else if (a === "--json") opts.json = true;
49
60
  else if (a === "--help" || a === "-h") opts.help = true;
50
61
  else if (a === "--version" || a === "-v") opts.version = true;
62
+ else if (a.startsWith("-")) bad(`unknown option: ${a}`);
51
63
  else opts._.push(a);
52
64
  }
65
+ if (opts.timeout !== undefined && (!Number.isFinite(Number(opts.timeout)) || Number(opts.timeout) <= 0)) bad("--timeout must be a positive finite number of seconds");
66
+ if (opts.build && opts.static) bad("choose either --build or --static");
67
+ if (opts.runtime && !["node", "python", "bun"].includes(opts.runtime)) bad("--runtime must be node, python, or bun");
53
68
  if (opts.envFile) {
54
- // Surface a bad --env-file instead of silently shipping the backend with no
69
+ // Surface a bad --backend-env-file instead of silently shipping the backend with no
55
70
  // env — a missing/unreadable file otherwise fails later as an opaque timeout.
56
71
  try {
72
+ const fromFile = {};
57
73
  for (const line of fs.readFileSync(opts.envFile, "utf8").split("\n")) {
58
74
  const t = line.trim(); if (!t || t.startsWith("#")) continue;
59
- const j = t.indexOf("="); if (j > 0) opts.env[t.slice(0, j).trim()] = t.slice(j + 1).trim();
75
+ const j = t.indexOf("="); if (j > 0) fromFile[t.slice(0, j).trim()] = t.slice(j + 1).trim();
60
76
  }
61
- } catch (e) { opts.envFileError = `could not read --env-file ${opts.envFile}: ${e.message}`; }
77
+ opts.env = { ...fromFile, ...opts.env };
78
+ } catch (e) { opts.envFileError = `could not read --backend-env-file ${opts.envFile}: ${e.message}`; }
62
79
  }
63
80
  return opts;
64
81
  }
@@ -79,7 +96,7 @@ USAGE
79
96
  --token <spec_tok_…> link with a token you already
80
97
  have (no browser); add --relink to move a device
81
98
  already linked to a different/personal account
82
- npx speculos-toolkit logout unlink this machine (revoke its token)
99
+ npx speculos-toolkit logout sign this terminal out (keep its apps and URLs)
83
100
  npx speculos-toolkit install-skill install the Claude Code /speculos-toolkit skill
84
101
  (+ allow the deploy command once, no more prompts)
85
102
  npx speculos-toolkit connectors [list]
@@ -132,7 +149,10 @@ OPTIONS
132
149
  --override <pw> admin override password to deploy a backend without an account
133
150
  (also via SPECULOS_OVERRIDE env)
134
151
  --env KEY=VAL backend env var (repeatable)
135
- --env-file <file> load backend env vars from a file
152
+ --backend-env-file <file>
153
+ load backend env vars from a file
154
+ --env-file <file> legacy alias; prefer --backend-env-file because recent Node
155
+ versions consume --env-file before the CLI starts
136
156
  --connector <a> connectors exec: the source alias from \`connectors list\`
137
157
  --tool <TOOL> connectors exec: the tool slug to run
138
158
  --args <json> connectors exec: tool arguments as inline JSON
@@ -143,7 +163,8 @@ OPTIONS
143
163
  --json machine-readable only (auto-on when non-TTY/CI)
144
164
 
145
165
  Identity: the first deploy mints a machine-global { userId, userKey } saved to
146
- ~/.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
147
168
  records its slug's stable id in .speculos.json (gitignored).
148
169
 
149
170
  The frontend is automatically pointed at the deployed backend URL via
@@ -185,7 +206,30 @@ async function pollBackend(jobId, creds, opts) {
185
206
  }
186
207
 
187
208
  async function cmdDeploy(root, opts) {
209
+ if (opts.envFileError) { emit({ ok: false, error: opts.envFileError, code: "ENV_FILE" }); log(opts, `✗ ${opts.envFileError}`); return 1; }
188
210
  const d = detect(root, opts);
211
+ // Keep the source directory even if an anonymous/frontend-only deploy skips
212
+ // running it. Skipping its sandbox must never publish its source as frontend.
213
+ const backendSourceDir = (d.backend && d.backend.dir) || (opts.backend && path.resolve(root, opts.backend));
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)) {
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" });
231
+ return 2;
232
+ }
189
233
  // Backend hosting needs either an admin --override OR a signed-in account
190
234
  // (speculos-toolkit login) — one backend app included free. Frontend is always free.
191
235
  const override = opts.override || process.env.SPECULOS_OVERRIDE || null;
@@ -228,7 +272,7 @@ async function cmdDeploy(root, opts) {
228
272
  if (alloc.userKey) { // freshly minted on the server
229
273
  identity = { userId: alloc.userId, userKey: alloc.userKey };
230
274
  creds.saveIdentity(identity);
231
- 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)`);
232
276
  }
233
277
  const userId = alloc.userId, userKey = identity.userKey, slugUuid = alloc.slugUuid;
234
278
  creds.save(root, { slug: d.slug, slugUuid });
@@ -236,9 +280,8 @@ async function cmdDeploy(root, opts) {
236
280
  // ---- backend first (so its URL can be baked into the frontend) ----
237
281
  let backendUrl = null, jobId = null, historyIds = [];
238
282
  if (d.backend) {
239
- if (opts.envFileError) { emit({ ok: false, error: opts.envFileError, code: "ENV_FILE" }); log(opts, `✗ ${opts.envFileError}`); return 1; }
240
283
  log(opts, `→ packing backend (${path.relative(root, d.backend.dir) || "."}, ${d.backend.runtime})`);
241
- const backendTar = packDir(d.backend.dir, { dropBuildOutput: false });
284
+ const backendTar = packDir(d.backend.dir, { dropBuildOutput: false, excludeDirs: [...privateDirExclusions(d.backend.dir, credentialDir)] });
242
285
  log(opts, `→ deploying backend (${Math.round(backendTar.bytes / 1024)} KB) to an isolated sandbox`);
243
286
  let created;
244
287
  try {
@@ -247,10 +290,19 @@ async function cmdDeploy(root, opts) {
247
290
  backend: { tarB64: backendTar.base64, runtime: d.backend.runtime, startCmd: d.backend.startCmd, env: opts.env },
248
291
  }, opts);
249
292
  } catch (e) {
250
- // Backends not enabled (or the account is at its limit) — don't fail the
251
- // deploy; ship the frontend so the user still gets a live URL, and surface
252
- // how to proceed. Only genuine backend build/start errors fail the deploy.
253
- if (e.code === "BACKEND_DISABLED" || e.code === "TOO_MANY") { log(opts, `↪ backend skipped — ${e.message}`); d.backend = null; backendNote = e.message; }
293
+ // A refused backend can still ship its frontend. With no frontend there
294
+ // is no deployment to report as successful; keep the actionable refusal
295
+ // code and identify an existing backend without claiming it was deployed.
296
+ if (e.code === "BACKEND_DISABLED" || e.code === "TOO_MANY") {
297
+ log(opts, `↪ backend skipped — ${e.message}`);
298
+ if (!d.frontend) {
299
+ emit({ ok: false, slug: d.slug, status: "error", error: e.message, code: e.code,
300
+ urls: alloc.backendUrl ? { backend: alloc.backendUrl } : {},
301
+ ...(alloc.backendUrl ? { backendKept: true } : {}) });
302
+ return 1;
303
+ }
304
+ d.backend = null; backendNote = e.message;
305
+ }
254
306
  // Another deploy of THIS app is still installing. Both would overwrite
255
307
  // each other's files inside the sandbox, so stop and say so plainly —
256
308
  // with the job to follow — rather than shipping a half-deploy.
@@ -307,25 +359,32 @@ async function cmdDeploy(root, opts) {
307
359
  });
308
360
  } 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; }
309
361
  }
362
+ if (isCredentialDir(outDir)) {
363
+ emit({ ok: false, error: "the frontend output is the credentials directory; select a separate build output directory", code: "CREDENTIALS_SCOPE" });
364
+ return 2;
365
+ }
310
366
  log(opts, `→ packing + uploading frontend (${path.relative(root, outDir) || "."})`);
311
- // If a plain static site is served straight from the repo root, exclude the
312
- // server code + secrets so they aren't published. Exclude the ACTUAL detected
313
- // backend dir (not a hardcoded name), plus any top-level backend-NAMED dir
314
- // that really looks like a backend (has package.json/requirements) — a
315
- // content dir that merely shares a name (e.g. a static api/) is left in.
316
- const rootStatic = path.resolve(outDir) === path.resolve(root) && d.frontend.kind === "static";
317
- let excludeDirs = [];
367
+ // Any static frontend can contain the selected backend, including a custom
368
+ // frontend directory. Exclude both the selected path and its real location
369
+ // so a symlink to the backend does not leave the source in the public bundle.
370
+ const set = privateDirExclusions(outDir, credentialDir);
371
+ if (d.frontend.kind === "static" && backendSourceDir) {
372
+ for (const rel of privateDirExclusions(outDir, backendSourceDir)) set.add(rel);
373
+ }
374
+ // Serving the repo root also excludes other top-level backend-looking dirs.
375
+ // Keep this heuristic at the repo root so ordinary static api/ content stays.
376
+ const rootStatic = d.frontend.kind === "static" && canonicalPath(outDir) === canonicalPath(root);
318
377
  if (rootStatic) {
319
- const set = new Set();
320
- if (d.backend && d.backend.dir) { const rel = path.relative(root, d.backend.dir); if (rel && rel !== "" && !rel.startsWith("..")) set.add(rel); }
321
378
  const looksBackend = (p) => fs.existsSync(path.join(p, "package.json")) || fs.existsSync(path.join(p, "requirements.txt")) || fs.existsSync(path.join(p, "pyproject.toml"));
322
379
  for (const name of BACKEND_DIRS) { const p = path.join(root, name); if (fs.existsSync(p) && looksBackend(p)) set.add(name); }
323
- excludeDirs = [...set];
380
+ }
381
+ const excludeDirs = [...set];
382
+ if (rootStatic) {
324
383
  log(opts, ` note: serving the repo root — excluding secrets${excludeDirs.length ? " + " + excludeDirs.join(", ") : ""} from the public bundle`);
325
384
  }
326
- // SECRET_EXCLUDE (frontend:true) only for the root-served case — a scoped build
327
- // output shouldn't have its assets stripped just because one is named like a key.
328
- const feTar = packDir(outDir, { dropBuildOutput: false, frontend: rootStatic, excludeDirs });
385
+ // Every frontend bundle is downloadable, including explicitly selected
386
+ // directories and build output that copied a public/ credential file.
387
+ const feTar = packDir(outDir, { dropBuildOutput: false, frontend: true, excludeDirs });
329
388
  let fe;
330
389
  try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, tarB64: feTar.base64, backendUrl: feBackendUrl, unsetBackend: !!opts.unsetBackend }, opts); }
331
390
  catch (e) { emit({ ok: false, error: e.message, code: e.code || "FRONTEND" }); return 1; }
@@ -365,7 +424,7 @@ async function cmdDeploy(root, opts) {
365
424
  async function cmdStatus(jobId, opts) {
366
425
  if (!jobId) { emit({ ok: false, error: "jobId required" }); return 2; }
367
426
  const identity = creds.loadIdentity();
368
- if (!identity || !identity.userId) { emit({ ok: false, error: "no identity (~/.speculos/identity.json) — cannot poll" }); return 1; }
427
+ if (!identity || !identity.userId) { emit({ ok: false, error: `no identity (${path.join(creds.identityDir(), "identity.json")}) — cannot poll` }); return 1; }
369
428
  const st = await client.backendStatus(jobId, { userId: identity.userId, userKey: identity.userKey }, opts);
370
429
  // `ok` for contract-consistency with every other command (false only on a
371
430
  // terminal failure; a still-running poll is ok:true).
@@ -379,7 +438,7 @@ async function cmdTeardown(root, opts) {
379
438
  const identity = creds.loadIdentity();
380
439
  const slug = opts.slug || (saved && saved.slug);
381
440
  if (!slug) { emit({ ok: false, error: "--slug required (or run from the project dir with a .speculos.json)" }); return 2; }
382
- if (!identity || !identity.userId) { emit({ ok: false, error: "no identity found (~/.speculos/identity.json) — nothing to tear down from this machine" }); return 1; }
441
+ 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; }
383
442
  try {
384
443
  if (identity.accountToken) opts.token = identity.accountToken;
385
444
  const r = await client.teardown({ userId: identity.userId, userKey: identity.userKey, slug }, opts);
@@ -395,11 +454,27 @@ async function cmdTeardown(root, opts) {
395
454
  async function cmdLogin(opts) {
396
455
  // Already linked? Don't run a redundant device flow (pass --relink to switch
397
456
  // to a different account, incl. moving a personal-linked device onto an org).
398
- const existing = creds.loadIdentity();
457
+ let existing = creds.loadIdentity();
399
458
  if (existing && existing.accountToken && !opts.relink && !opts.pasteToken) {
400
- log(opts, `✓ this device is already linked to your Speculos account. Pass --relink to link a different account. Manage deployments at https://unified.speculos.ai/?tab=deploys.`);
401
- emit({ ok: true, alreadyLinked: true });
402
- return 0;
459
+ try {
460
+ const who = await client.whoami(existing.accountToken, opts);
461
+ let reparented = false, previousAccount = null;
462
+ // The dashboard may have removed the machine since the last login, or a
463
+ // previous link request may have failed after the token was saved.
464
+ if (existing.userId && existing.userKey) {
465
+ const linked = await client.linkMachine(existing.accountToken, { userId: existing.userId, userKey: existing.userKey }, opts);
466
+ reparented = !!linked.reparented; previousAccount = linked.previousEmail || null;
467
+ }
468
+ if (reparented) process.stderr.write(`\n⚠ This device's deployments moved from ${previousAccount || "another account"} to ${who.email || "this account"}.\n`);
469
+ log(opts, `✓ this device is linked${who.email ? ` to ${who.email}` : ""}. Manage deployments at https://unified.speculos.ai/?tab=deploys.`);
470
+ emit({ ok: true, alreadyLinked: true, account: who.email || null, org: who.org || null, reparented, previousAccount });
471
+ return 0;
472
+ } catch (e) {
473
+ if (e.status !== 401) { emit({ ok: false, linked: false, error: e.message, code: e.code || "LOGIN" }); return 1; }
474
+ creds.clearAccountToken();
475
+ existing = creds.loadIdentity();
476
+ log(opts, "Your saved account token expired or was revoked; starting a new login.");
477
+ }
403
478
  }
404
479
 
405
480
  // Paste-a-token path: a user who already has an account (and a token from the
@@ -420,7 +495,10 @@ async function cmdLogin(opts) {
420
495
  try {
421
496
  const r = await client.linkMachine(token, { userId: identity.userId, userKey: identity.userKey }, opts);
422
497
  reparented = !!r.reparented; previousEmail = r.previousEmail || null;
423
- } catch { /* links on next deploy via the token */ }
498
+ } catch (e) {
499
+ emit({ ok: false, linked: false, error: `account verified, but this machine's apps could not be linked: ${e.message}. Run login again to retry.`, code: e.code || "LINK_MACHINE" });
500
+ return 1;
501
+ }
424
502
  }
425
503
  if (reparented) process.stderr.write(`\n⚠ This device's deployments moved from ${previousEmail || "another account"} to ${who.email || "this account"}.\n`);
426
504
  log(opts, `✓ this device is linked${who.email ? ` to ${who.email}` : ""}${who.org ? ` (org: ${who.org})` : ""} — manage at https://unified.speculos.ai/?tab=deploys.`);
@@ -460,7 +538,10 @@ async function cmdLogin(opts) {
460
538
  try {
461
539
  const r = await client.linkMachine(token, { userId: identity.userId, userKey: identity.userKey }, opts);
462
540
  linked = true; reparented = !!r.reparented; previousEmail = r.previousEmail || null;
463
- } catch { /* will link on next deploy via the token */ }
541
+ } catch (e) {
542
+ emit({ ok: false, linked: false, error: `account approved, but this machine's apps could not be linked: ${e.message}. Run login again to retry.`, code: e.code || "LINK_MACHINE" });
543
+ return 1;
544
+ }
464
545
  }
465
546
  if (reparented) {
466
547
  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`);
@@ -470,19 +551,38 @@ async function cmdLogin(opts) {
470
551
  return 0;
471
552
  }
472
553
 
473
- // ---- logout: revoke this device's token (server + local) ----
554
+ // ---- logout: revoke this terminal's authority, preserve its published apps ----
474
555
  async function cmdLogout(opts) {
475
556
  const identity = creds.loadIdentity();
476
557
  const token = identity && identity.accountToken;
477
- if (!token) { emit({ ok: true, alreadyLoggedOut: true }); return 0; }
478
- let revoked = false, unlinked = false;
558
+ if (!token && !(identity && identity.userId)) { emit({ ok: true, alreadyLoggedOut: true }); return 0; }
559
+ let revoked = false, unlinked = false, signedOut = false, ownershipPreserved = false, alreadyRevoked = false, tokenAbsent = false;
479
560
  try {
480
561
  const r = await client.logout(token, { userId: identity.userId, userKey: identity.userKey }, opts);
481
- revoked = !!r.revoked; unlinked = !!r.unlinked;
482
- } catch { /* revoke best-effort; still clear locally */ }
483
- creds.clearAccountToken();
484
- 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" : ""}.`);
485
- emit({ ok: true, loggedOut: true, revoked, unlinked });
562
+ const hasMachine = !!(identity.userId || identity.userKey);
563
+ // Older servers can return HTTP 200 after swallowing revocation/unlink
564
+ // failures. Success must establish both token revocation and (when there
565
+ // is a machine) loss of machine authority before discarding our retry key.
566
+ if (r.ok !== true || !(r.revoked === true || r.alreadyRevoked === true || (!token && r.tokenAbsent === true)) ||
567
+ (hasMachine && r.signedOut !== true && r.unlinked !== true)) {
568
+ throw Object.assign(new Error("the server did not confirm that this terminal was signed out"), { code: "LOGOUT_INCOMPLETE" });
569
+ }
570
+ revoked = r.revoked === true; alreadyRevoked = r.alreadyRevoked === true;
571
+ tokenAbsent = r.tokenAbsent === true;
572
+ unlinked = r.unlinked === true; signedOut = r.signedOut === true || unlinked;
573
+ ownershipPreserved = r.ownershipPreserved === true;
574
+ } catch (e) {
575
+ // Keep the token so a retry can revoke it and sign the machine out. Clearing
576
+ // it during an outage makes an active server credential unrecoverable.
577
+ emit({ ok: false, loggedOut: false, error: `could not sign out: ${e.message}; retry logout when the service is reachable`, code: e.code || "LOGOUT" });
578
+ return 1;
579
+ }
580
+ if (!creds.clearAccountToken(token)) {
581
+ emit({ ok: false, loggedOut: false, revoked, code: "LOGOUT_SUPERSEDED", error: "the saved sign-in changed while logout was pending; the newer credential was kept" });
582
+ return 1;
583
+ }
584
+ log(opts, `✓ this terminal is signed out of your Speculos account.${ownershipPreserved ? " Its apps and URLs are preserved; run `login` to manage them again." : ""}`);
585
+ emit({ ok: true, loggedOut: true, revoked, alreadyRevoked, tokenAbsent, signedOut, ownershipPreserved, unlinked });
486
586
  return 0;
487
587
  }
488
588
 
@@ -544,6 +644,10 @@ async function cmdConnectors(opts) {
544
644
  emit({ ok: false, error: `couldn't read tool arguments: ${e.message}`, code: "BAD_ARGS" });
545
645
  return 2;
546
646
  }
647
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
648
+ emit({ ok: false, error: "tool arguments must be a JSON object", code: "BAD_ARGS" });
649
+ return 2;
650
+ }
547
651
  let r;
548
652
  try { r = await client.connectorsExec(token, { connector: opts.connector, tool: opts.tool, arguments: args }, opts); }
549
653
  catch (e) { emit({ ok: false, error: e.message, code: e.code || "EXEC" }); return 1; }
@@ -626,6 +730,7 @@ async function main(argv) {
626
730
  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)
627
731
  if (opts.version) { emit({ version: VERSION }); return 0; }
628
732
  if (opts.help) { process.stderr.write(HELP + "\n"); return 0; }
733
+ if (opts.argError) { emit({ ok: false, error: opts.argError, code: "BAD_ARGS" }); return 2; }
629
734
  const cmd = opts._[0] || "deploy";
630
735
  const root = process.cwd();
631
736
  try {
package/src/pack.js CHANGED
@@ -20,7 +20,8 @@ const SECRET_EXCLUDE = [
20
20
  "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa", "id_rsa.*", "id_ed25519", "id_ed25519.*",
21
21
  "id_dsa", "credentials.json", "credentials.yaml", "credentials.yml",
22
22
  "secrets.json", "secrets.yaml", "secrets.yml", "service-account*.json",
23
- ".npmrc", ".netrc", ".pgpass", ".ssh", ".aws", ".gcloud",
23
+ ".npmrc", ".netrc", ".pgpass", ".ssh", ".aws", ".gcloud", ".speculos",
24
+ ".git-credentials", ".envrc", "*.env", "*.env.*", "id_ecdsa", "id_ecdsa.*", "id_dsa.*",
24
25
  ];
25
26
  // backend source dirs — excluded when the frontend IS the repo root, so serving
26
27
  // a root static site doesn't publish the server code sitting next to it.
@@ -30,17 +31,28 @@ function packDir(dir, { dropBuildOutput = false, frontend = false, excludeDirs =
30
31
  const excludes = ALWAYS_EXCLUDE
31
32
  .concat(dropBuildOutput ? BUILD_EXCLUDE : [])
32
33
  .concat(frontend ? SECRET_EXCLUDE : []);
33
- const tmp = path.join(os.tmpdir(), `speculos-${Date.now()}-${Math.floor(process.hrtime()[1] % 1e6)}.tgz`);
34
+ // A private directory prevents collisions or symlink replacement of a
35
+ // predictable temporary archive, which may contain private backend source.
36
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "speculos-pack-"));
37
+ const tmp = path.join(tmpDir, "bundle.tgz");
34
38
  const args = ["czf", tmp];
35
39
  for (const e of excludes) args.push("--exclude=" + e);
36
40
  // excludeDirs are TOP-LEVEL paths (e.g. a backend dir) — anchor with ./ so they
37
41
  // 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(/\/+$/, ""));
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
+ }
39
48
  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 };
49
+ try {
50
+ execFileSync("tar", args, { stdio: ["ignore", "ignore", "pipe"] });
51
+ const buf = fs.readFileSync(tmp);
52
+ return { base64: buf.toString("base64"), bytes: buf.length };
53
+ } finally {
54
+ fs.rmSync(tmpDir, { recursive: true, force: true });
55
+ }
44
56
  }
45
57
 
46
58
  module.exports = { packDir, BACKEND_DIRS };