speculos-toolkit 1.2.4 → 1.2.6

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,10 @@ 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
+ If either identity file is unreadable or damaged, the CLI stops so you can restore it
65
+ before deploying. A failed login or logout reports the failure; rerun the same command
66
+ to retry linking or revoking the device.
67
+
63
68
  ## Conventions
64
69
 
65
70
  - **Backend** listens on `process.env.PORT`, binds `0.0.0.0`. Node or Python.
@@ -82,13 +87,19 @@ speculos-toolkit install-skill install the Claude Code skill
82
87
  --frontend <dir> --backend <dir> --slug <name>
83
88
  --runtime node|python|bun --start "<cmd>" --build --static --output <dir>
84
89
  --private | --org | --public
85
- --env KEY=VAL --env-file <file> --api <url> --timeout <sec> --json
90
+ --env KEY=VAL --backend-env-file <file> --api <url> --timeout <sec> --json
86
91
  ```
87
92
 
88
93
  `--api` defaults to `https://unified-api.speculos.ai/toolkit` (the platform gateway,
89
94
  which proxies to the deploy orchestrator and records the deploy in the console's
90
95
  history); `SPECULOS_API` overrides it.
91
96
 
97
+ Use `--backend-env-file` for backend configuration. The legacy `--env-file` alias
98
+ still works when invoking Node with a delimiter, such as
99
+ `node -- /path/to/speculos-toolkit.js deploy --env-file .env.backend`.
100
+ Node 24/25 otherwise consume that flag before the CLI starts and can load backend
101
+ configuration into the CLI process itself.
102
+
92
103
  Docs: https://unified.speculos.ai · Source: https://github.com/speculosai/unified_platform
93
104
 
94
105
  MIT
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "speculos-toolkit",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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"
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
@@ -156,7 +156,8 @@ The last stdout line is `{ ok, brokerUrl, connectors: [{ alias, name, kind, acco
156
156
  - **A frontend-only deploy keeps the app's backend.** `--no-backend` (or a repo where the
157
157
  backend did not change) redeploys the frontend against the SAME backend URL and says so
158
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.
159
+ its own API. Pass `--unset-backend` to actually detach it — that sticks: the app
160
+ stays detached until a deploy actually ships a backend again.
160
161
  - `ok:true` with empty `connectors` → nothing linked (or nothing granted to this user).
161
162
  If the app clearly wants external data, tell the user to link a source (or ask their org
162
163
  admin for access) at **https://unified.speculos.ai/?tab=deploys**, then **re-run the list** —
@@ -303,7 +304,11 @@ Builds run locally (this machine already has the toolchain); only static output
303
304
  (Admins/CI can instead pass `--override <password>` to bypass account auth.)
304
305
  - If `detect` got the folders wrong, add `--frontend ./web` and/or `--backend ./api`.
305
306
  - Useful flags: `--slug <name>`, `--env KEY=VAL` (repeatable, backend env), `--build`
306
- (force the frontend through its build step), `--env-file <file>`.
307
+ (force the frontend through its build step), `--backend-env-file <file>`.
308
+ Prefer `--backend-env-file` over the legacy `--env-file` alias: recent Node
309
+ versions consume the legacy flag before the CLI starts and can load backend
310
+ configuration into the CLI process. Direct legacy invocations require
311
+ `node -- /path/to/speculos-toolkit.js deploy --env-file <file>`.
307
312
 
308
313
  ### Who can see the deployed app
309
314
 
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.
package/src/creds.js CHANGED
@@ -6,6 +6,11 @@
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
 
@@ -15,25 +20,33 @@ function identityFile() { return path.join(identityDir(), "identity.json"); }
15
20
  function loadIdentity() {
16
21
  const f = identityFile();
17
22
  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;
23
+ try { raw = fs.readFileSync(f, "utf8"); }
24
+ catch (e) {
25
+ if (e.code === "ENOENT") return null;
26
+ throw credentialError(`could not read ${f}; restore access before continuing`, e);
27
+ }
28
+ let value;
29
+ try { value = JSON.parse(raw); } catch (e) { throw credentialError(`${f} contains invalid JSON; restore the identity file before continuing`, e); }
30
+ const hasMachine = value && (value.userId !== undefined || value.userKey !== undefined);
31
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
32
+ (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))) ||
33
+ (value.accountToken !== undefined && (typeof value.accountToken !== "string" || !value.accountToken.startsWith("spec_tok_")))) {
34
+ throw credentialError(`${f} contains an invalid identity; restore the identity file before continuing`);
26
35
  }
36
+ return value;
27
37
  }
28
38
  // Merge-write so we never drop a field the other writer set (e.g. accountToken).
29
39
  // Atomic (tmp + rename) so a crash/full-disk mid-write can't truncate the file.
30
40
  function writeIdentity(obj) {
41
+ const tmp = identityFile() + `.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
31
42
  try {
32
- fs.mkdirSync(identityDir(), { recursive: true });
33
- const tmp = identityFile() + ".tmp";
34
- fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
43
+ fs.mkdirSync(identityDir(), { recursive: true, mode: 0o700 });
44
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600, flag: "wx" });
35
45
  fs.renameSync(tmp, identityFile());
36
- } catch { /* best effort */ }
46
+ } catch (e) {
47
+ try { fs.unlinkSync(tmp); } catch { /* no temporary file */ }
48
+ throw credentialError(`could not save ${identityFile()}; deployment ownership was not saved`, e);
49
+ }
37
50
  }
38
51
  function saveIdentity({ userId, userKey }) {
39
52
  const cur = loadIdentity() || {};
@@ -57,10 +70,29 @@ function clearAccountToken() {
57
70
  function file(root) { return path.join(root, ".speculos.json"); }
58
71
 
59
72
  function load(root) {
60
- try { return JSON.parse(fs.readFileSync(file(root), "utf8")); } catch { return null; }
73
+ let raw;
74
+ try { raw = fs.readFileSync(file(root), "utf8"); }
75
+ catch (e) {
76
+ if (e.code === "ENOENT") return null;
77
+ throw Object.assign(new Error(`could not read ${file(root)}; restore access before continuing`), { code: "PROJECT_STATE", cause: e });
78
+ }
79
+ let value;
80
+ try { value = JSON.parse(raw); } catch { /* rejected below */ }
81
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
82
+ typeof value.slug !== "string" || !/^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(value.slug)) {
83
+ throw Object.assign(new Error(`${file(root)} contains an invalid project record; restore it before continuing`), { code: "PROJECT_STATE" });
84
+ }
85
+ return value;
61
86
  }
62
87
  function save(root, data) {
63
- fs.writeFileSync(file(root), JSON.stringify(data, null, 2) + "\n");
88
+ const tmp = file(root) + `.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
89
+ try {
90
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { flag: "wx", mode: 0o600 });
91
+ fs.renameSync(tmp, file(root));
92
+ } catch (e) {
93
+ try { fs.unlinkSync(tmp); } catch { /* no temporary file */ }
94
+ throw Object.assign(new Error(`could not save ${file(root)}; project ownership was not saved`), { code: "PROJECT_STATE", cause: e });
95
+ }
64
96
  ensureGitignored(root);
65
97
  }
66
98
  function remove(root) {
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
  }
@@ -121,7 +138,8 @@ OPTIONS
121
138
  --no-backend frontend-only: skip backend even if one is detected (the app
122
139
  keeps pointing at the backend it already has)
123
140
  --unset-backend clear this app's backend URL: the frontend stops calling a
124
- backend (the sandbox itself stays up — use teardown for that)
141
+ backend (the sandbox itself stays up — use teardown for that).
142
+ It stays cleared until you deploy a backend again
125
143
  --private only you can open the deployed app (needs an account;
126
144
  the DEFAULT for a new app once this machine is linked)
127
145
  --org anyone in your org can open it (needs an account)
@@ -131,7 +149,10 @@ OPTIONS
131
149
  --override <pw> admin override password to deploy a backend without an account
132
150
  (also via SPECULOS_OVERRIDE env)
133
151
  --env KEY=VAL backend env var (repeatable)
134
- --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
135
156
  --connector <a> connectors exec: the source alias from \`connectors list\`
136
157
  --tool <TOOL> connectors exec: the tool slug to run
137
158
  --args <json> connectors exec: tool arguments as inline JSON
@@ -184,7 +205,15 @@ async function pollBackend(jobId, creds, opts) {
184
205
  }
185
206
 
186
207
  async function cmdDeploy(root, opts) {
208
+ if (opts.envFileError) { emit({ ok: false, error: opts.envFileError, code: "ENV_FILE" }); log(opts, `✗ ${opts.envFileError}`); return 1; }
187
209
  const d = detect(root, opts);
210
+ // Keep the source directory even if an anonymous/frontend-only deploy skips
211
+ // running it. Skipping its sandbox must never publish its source as frontend.
212
+ 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
+ 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
+ return 2;
216
+ }
188
217
  // Backend hosting needs either an admin --override OR a signed-in account
189
218
  // (speculos-toolkit login) — one backend app included free. Frontend is always free.
190
219
  const override = opts.override || process.env.SPECULOS_OVERRIDE || null;
@@ -235,7 +264,6 @@ async function cmdDeploy(root, opts) {
235
264
  // ---- backend first (so its URL can be baked into the frontend) ----
236
265
  let backendUrl = null, jobId = null, historyIds = [];
237
266
  if (d.backend) {
238
- if (opts.envFileError) { emit({ ok: false, error: opts.envFileError, code: "ENV_FILE" }); log(opts, `✗ ${opts.envFileError}`); return 1; }
239
267
  log(opts, `→ packing backend (${path.relative(root, d.backend.dir) || "."}, ${d.backend.runtime})`);
240
268
  const backendTar = packDir(d.backend.dir, { dropBuildOutput: false });
241
269
  log(opts, `→ deploying backend (${Math.round(backendTar.bytes / 1024)} KB) to an isolated sandbox`);
@@ -246,10 +274,19 @@ async function cmdDeploy(root, opts) {
246
274
  backend: { tarB64: backendTar.base64, runtime: d.backend.runtime, startCmd: d.backend.startCmd, env: opts.env },
247
275
  }, opts);
248
276
  } catch (e) {
249
- // Backends not enabled (or the account is at its limit) — don't fail the
250
- // deploy; ship the frontend so the user still gets a live URL, and surface
251
- // how to proceed. Only genuine backend build/start errors fail the deploy.
252
- if (e.code === "BACKEND_DISABLED" || e.code === "TOO_MANY") { log(opts, `↪ backend skipped — ${e.message}`); d.backend = null; backendNote = e.message; }
277
+ // A refused backend can still ship its frontend. With no frontend there
278
+ // is no deployment to report as successful; keep the actionable refusal
279
+ // code and identify an existing backend without claiming it was deployed.
280
+ if (e.code === "BACKEND_DISABLED" || e.code === "TOO_MANY") {
281
+ log(opts, `↪ backend skipped — ${e.message}`);
282
+ if (!d.frontend) {
283
+ emit({ ok: false, slug: d.slug, status: "error", error: e.message, code: e.code,
284
+ urls: alloc.backendUrl ? { backend: alloc.backendUrl } : {},
285
+ ...(alloc.backendUrl ? { backendKept: true } : {}) });
286
+ return 1;
287
+ }
288
+ d.backend = null; backendNote = e.message;
289
+ }
253
290
  // Another deploy of THIS app is still installing. Both would overwrite
254
291
  // each other's files inside the sandbox, so stop and say so plainly —
255
292
  // with the job to follow — rather than shipping a half-deploy.
@@ -280,11 +317,13 @@ async function cmdDeploy(root, opts) {
280
317
  // at the backend it already has, which allocate reports. Without this, the
281
318
  // rebuilt bundle and the regenerated speculos-env.js both said "no backend"
282
319
  // 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).
320
+ // way to clear it, and the orchestrator remembers that: later frontend-only
321
+ // deploys stay detached (the backend sandbox itself stays up; `teardown`
322
+ // removes it).
284
323
  let keptBackendUrl = null;
285
324
  const liveBackendUrl = (alloc && alloc.backendUrl) || null;
286
325
  if (!backendUrl && liveBackendUrl) {
287
- if (opts.unsetBackend) log(opts, `↪ clearing this app's backend URL (--unset-backend) — the backend itself stays up`);
326
+ if (opts.unsetBackend) log(opts, `↪ clearing this app's backend URL (--unset-backend) — it stays cleared until you deploy a backend again; the backend itself stays up`);
288
327
  else { keptBackendUrl = liveBackendUrl; log(opts, `↪ keeping this app's existing backend: ${keptBackendUrl} (--unset-backend to clear it)`); }
289
328
  }
290
329
  const feBackendUrl = backendUrl || keptBackendUrl;
@@ -314,15 +353,15 @@ async function cmdDeploy(root, opts) {
314
353
  let excludeDirs = [];
315
354
  if (rootStatic) {
316
355
  const set = new Set();
317
- if (d.backend && d.backend.dir) { const rel = path.relative(root, d.backend.dir); if (rel && rel !== "" && !rel.startsWith("..")) set.add(rel); }
356
+ if (backendSourceDir) { const rel = path.relative(root, backendSourceDir); if (rel && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel)) set.add(rel); }
318
357
  const looksBackend = (p) => fs.existsSync(path.join(p, "package.json")) || fs.existsSync(path.join(p, "requirements.txt")) || fs.existsSync(path.join(p, "pyproject.toml"));
319
358
  for (const name of BACKEND_DIRS) { const p = path.join(root, name); if (fs.existsSync(p) && looksBackend(p)) set.add(name); }
320
359
  excludeDirs = [...set];
321
360
  log(opts, ` note: serving the repo root — excluding secrets${excludeDirs.length ? " + " + excludeDirs.join(", ") : ""} from the public bundle`);
322
361
  }
323
- // SECRET_EXCLUDE (frontend:true) only for the root-served case — a scoped build
324
- // output shouldn't have its assets stripped just because one is named like a key.
325
- const feTar = packDir(outDir, { dropBuildOutput: false, frontend: rootStatic, excludeDirs });
362
+ // Every frontend bundle is downloadable, including explicitly selected
363
+ // directories and build output that copied a public/ credential file.
364
+ const feTar = packDir(outDir, { dropBuildOutput: false, frontend: true, excludeDirs });
326
365
  let fe;
327
366
  try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, tarB64: feTar.base64, backendUrl: feBackendUrl, unsetBackend: !!opts.unsetBackend }, opts); }
328
367
  catch (e) { emit({ ok: false, error: e.message, code: e.code || "FRONTEND" }); return 1; }
@@ -392,11 +431,27 @@ async function cmdTeardown(root, opts) {
392
431
  async function cmdLogin(opts) {
393
432
  // Already linked? Don't run a redundant device flow (pass --relink to switch
394
433
  // to a different account, incl. moving a personal-linked device onto an org).
395
- const existing = creds.loadIdentity();
434
+ let existing = creds.loadIdentity();
396
435
  if (existing && existing.accountToken && !opts.relink && !opts.pasteToken) {
397
- 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.`);
398
- emit({ ok: true, alreadyLinked: true });
399
- return 0;
436
+ try {
437
+ const who = await client.whoami(existing.accountToken, opts);
438
+ let reparented = false, previousAccount = null;
439
+ // The dashboard may have removed the machine since the last login, or a
440
+ // previous link request may have failed after the token was saved.
441
+ if (existing.userId && existing.userKey) {
442
+ const linked = await client.linkMachine(existing.accountToken, { userId: existing.userId, userKey: existing.userKey }, opts);
443
+ reparented = !!linked.reparented; previousAccount = linked.previousEmail || null;
444
+ }
445
+ if (reparented) process.stderr.write(`\n⚠ This device's deployments moved from ${previousAccount || "another account"} to ${who.email || "this account"}.\n`);
446
+ log(opts, `✓ this device is linked${who.email ? ` to ${who.email}` : ""}. Manage deployments at https://unified.speculos.ai/?tab=deploys.`);
447
+ emit({ ok: true, alreadyLinked: true, account: who.email || null, org: who.org || null, reparented, previousAccount });
448
+ return 0;
449
+ } catch (e) {
450
+ if (e.status !== 401) { emit({ ok: false, linked: false, error: e.message, code: e.code || "LOGIN" }); return 1; }
451
+ creds.clearAccountToken();
452
+ existing = creds.loadIdentity();
453
+ log(opts, "Your saved account token expired or was revoked; starting a new login.");
454
+ }
400
455
  }
401
456
 
402
457
  // Paste-a-token path: a user who already has an account (and a token from the
@@ -417,7 +472,10 @@ async function cmdLogin(opts) {
417
472
  try {
418
473
  const r = await client.linkMachine(token, { userId: identity.userId, userKey: identity.userKey }, opts);
419
474
  reparented = !!r.reparented; previousEmail = r.previousEmail || null;
420
- } catch { /* links on next deploy via the token */ }
475
+ } catch (e) {
476
+ 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" });
477
+ return 1;
478
+ }
421
479
  }
422
480
  if (reparented) process.stderr.write(`\n⚠ This device's deployments moved from ${previousEmail || "another account"} to ${who.email || "this account"}.\n`);
423
481
  log(opts, `✓ this device is linked${who.email ? ` to ${who.email}` : ""}${who.org ? ` (org: ${who.org})` : ""} — manage at https://unified.speculos.ai/?tab=deploys.`);
@@ -457,7 +515,10 @@ async function cmdLogin(opts) {
457
515
  try {
458
516
  const r = await client.linkMachine(token, { userId: identity.userId, userKey: identity.userKey }, opts);
459
517
  linked = true; reparented = !!r.reparented; previousEmail = r.previousEmail || null;
460
- } catch { /* will link on next deploy via the token */ }
518
+ } catch (e) {
519
+ 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" });
520
+ return 1;
521
+ }
461
522
  }
462
523
  if (reparented) {
463
524
  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`);
@@ -476,7 +537,12 @@ async function cmdLogout(opts) {
476
537
  try {
477
538
  const r = await client.logout(token, { userId: identity.userId, userKey: identity.userKey }, opts);
478
539
  revoked = !!r.revoked; unlinked = !!r.unlinked;
479
- } catch { /* revoke best-effort; still clear locally */ }
540
+ } catch (e) {
541
+ // Keep the token so a retry can revoke it and unlink the machine. Clearing
542
+ // it during an outage makes an active server credential unrecoverable.
543
+ emit({ ok: false, loggedOut: false, error: `could not sign out: ${e.message}; retry logout when the service is reachable`, code: e.code || "LOGOUT" });
544
+ return 1;
545
+ }
480
546
  creds.clearAccountToken();
481
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" : ""}.`);
482
548
  emit({ ok: true, loggedOut: true, revoked, unlinked });
@@ -541,6 +607,10 @@ async function cmdConnectors(opts) {
541
607
  emit({ ok: false, error: `couldn't read tool arguments: ${e.message}`, code: "BAD_ARGS" });
542
608
  return 2;
543
609
  }
610
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
611
+ emit({ ok: false, error: "tool arguments must be a JSON object", code: "BAD_ARGS" });
612
+ return 2;
613
+ }
544
614
  let r;
545
615
  try { r = await client.connectorsExec(token, { connector: opts.connector, tool: opts.tool, arguments: args }, opts); }
546
616
  catch (e) { emit({ ok: false, error: e.message, code: e.code || "EXEC" }); return 1; }
@@ -623,6 +693,7 @@ async function main(argv) {
623
693
  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)
624
694
  if (opts.version) { emit({ version: VERSION }); return 0; }
625
695
  if (opts.help) { process.stderr.write(HELP + "\n"); return 0; }
696
+ if (opts.argError) { emit({ ok: false, error: opts.argError, code: "BAD_ARGS" }); return 2; }
626
697
  const cmd = opts._[0] || "deploy";
627
698
  const root = process.cwd();
628
699
  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,23 @@ 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 || [])) args.push("--exclude=./" + String(e).replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, ""));
39
43
  args.push("-C", dir, ".");
40
- execFileSync("tar", args, { stdio: ["ignore", "ignore", "pipe"] });
41
- const buf = fs.readFileSync(tmp);
42
- fs.unlinkSync(tmp);
43
- return { base64: buf.toString("base64"), bytes: buf.length };
44
+ try {
45
+ execFileSync("tar", args, { stdio: ["ignore", "ignore", "pipe"] });
46
+ const buf = fs.readFileSync(tmp);
47
+ return { base64: buf.toString("base64"), bytes: buf.length };
48
+ } finally {
49
+ fs.rmSync(tmpDir, { recursive: true, force: true });
50
+ }
44
51
  }
45
52
 
46
53
  module.exports = { packDir, BACKEND_DIRS };