speculos-toolkit 1.2.5 → 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 +13 -2
- package/package.json +4 -1
- package/skill/SKILL.md +5 -1
- package/src/build.js +60 -14
- package/src/client.js +17 -6
- package/src/creds.js +46 -14
- package/src/detect.js +3 -0
- package/src/index.js +94 -26
- package/src/pack.js +14 -7
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
|
|
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.
|
|
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
|
@@ -304,7 +304,11 @@ Builds run locally (this machine already has the toolchain); only static output
|
|
|
304
304
|
(Admins/CI can instead pass `--override <password>` to bypass account auth.)
|
|
305
305
|
- If `detect` got the folders wrong, add `--frontend ./web` and/or `--backend ./api`.
|
|
306
306
|
- Useful flags: `--slug <name>`, `--env KEY=VAL` (repeatable, backend env), `--build`
|
|
307
|
-
(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>`.
|
|
308
312
|
|
|
309
313
|
### Who can see the deployed app
|
|
310
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
151
|
-
try {
|
|
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
|
-
|
|
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
|
-
|
|
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"); }
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
33
|
-
|
|
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 (
|
|
41
|
-
|
|
42
|
-
|
|
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)
|
|
75
|
+
const j = t.indexOf("="); if (j > 0) fromFile[t.slice(0, j).trim()] = t.slice(j + 1).trim();
|
|
60
76
|
}
|
|
61
|
-
|
|
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
|
}
|
|
@@ -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>
|
|
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
|
|
@@ -185,7 +205,15 @@ async function pollBackend(jobId, creds, opts) {
|
|
|
185
205
|
}
|
|
186
206
|
|
|
187
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; }
|
|
188
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
|
+
}
|
|
189
217
|
// Backend hosting needs either an admin --override OR a signed-in account
|
|
190
218
|
// (speculos-toolkit login) — one backend app included free. Frontend is always free.
|
|
191
219
|
const override = opts.override || process.env.SPECULOS_OVERRIDE || null;
|
|
@@ -236,7 +264,6 @@ async function cmdDeploy(root, opts) {
|
|
|
236
264
|
// ---- backend first (so its URL can be baked into the frontend) ----
|
|
237
265
|
let backendUrl = null, jobId = null, historyIds = [];
|
|
238
266
|
if (d.backend) {
|
|
239
|
-
if (opts.envFileError) { emit({ ok: false, error: opts.envFileError, code: "ENV_FILE" }); log(opts, `✗ ${opts.envFileError}`); return 1; }
|
|
240
267
|
log(opts, `→ packing backend (${path.relative(root, d.backend.dir) || "."}, ${d.backend.runtime})`);
|
|
241
268
|
const backendTar = packDir(d.backend.dir, { dropBuildOutput: false });
|
|
242
269
|
log(opts, `→ deploying backend (${Math.round(backendTar.bytes / 1024)} KB) to an isolated sandbox`);
|
|
@@ -247,10 +274,19 @@ async function cmdDeploy(root, opts) {
|
|
|
247
274
|
backend: { tarB64: backendTar.base64, runtime: d.backend.runtime, startCmd: d.backend.startCmd, env: opts.env },
|
|
248
275
|
}, opts);
|
|
249
276
|
} catch (e) {
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
if (e.code === "BACKEND_DISABLED" || e.code === "TOO_MANY") {
|
|
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
|
+
}
|
|
254
290
|
// Another deploy of THIS app is still installing. Both would overwrite
|
|
255
291
|
// each other's files inside the sandbox, so stop and say so plainly —
|
|
256
292
|
// with the job to follow — rather than shipping a half-deploy.
|
|
@@ -317,15 +353,15 @@ async function cmdDeploy(root, opts) {
|
|
|
317
353
|
let excludeDirs = [];
|
|
318
354
|
if (rootStatic) {
|
|
319
355
|
const set = new Set();
|
|
320
|
-
if (
|
|
356
|
+
if (backendSourceDir) { const rel = path.relative(root, backendSourceDir); if (rel && rel !== ".." && !rel.startsWith(".." + path.sep) && !path.isAbsolute(rel)) set.add(rel); }
|
|
321
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"));
|
|
322
358
|
for (const name of BACKEND_DIRS) { const p = path.join(root, name); if (fs.existsSync(p) && looksBackend(p)) set.add(name); }
|
|
323
359
|
excludeDirs = [...set];
|
|
324
360
|
log(opts, ` note: serving the repo root — excluding secrets${excludeDirs.length ? " + " + excludeDirs.join(", ") : ""} from the public bundle`);
|
|
325
361
|
}
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
const feTar = packDir(outDir, { dropBuildOutput: false, frontend:
|
|
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 });
|
|
329
365
|
let fe;
|
|
330
366
|
try { fe = await client.putFrontend({ userId, userKey, slug: d.slug, slugUuid, tarB64: feTar.base64, backendUrl: feBackendUrl, unsetBackend: !!opts.unsetBackend }, opts); }
|
|
331
367
|
catch (e) { emit({ ok: false, error: e.message, code: e.code || "FRONTEND" }); return 1; }
|
|
@@ -395,11 +431,27 @@ async function cmdTeardown(root, opts) {
|
|
|
395
431
|
async function cmdLogin(opts) {
|
|
396
432
|
// Already linked? Don't run a redundant device flow (pass --relink to switch
|
|
397
433
|
// to a different account, incl. moving a personal-linked device onto an org).
|
|
398
|
-
|
|
434
|
+
let existing = creds.loadIdentity();
|
|
399
435
|
if (existing && existing.accountToken && !opts.relink && !opts.pasteToken) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
}
|
|
403
455
|
}
|
|
404
456
|
|
|
405
457
|
// Paste-a-token path: a user who already has an account (and a token from the
|
|
@@ -420,7 +472,10 @@ async function cmdLogin(opts) {
|
|
|
420
472
|
try {
|
|
421
473
|
const r = await client.linkMachine(token, { userId: identity.userId, userKey: identity.userKey }, opts);
|
|
422
474
|
reparented = !!r.reparented; previousEmail = r.previousEmail || null;
|
|
423
|
-
} catch {
|
|
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
|
+
}
|
|
424
479
|
}
|
|
425
480
|
if (reparented) process.stderr.write(`\n⚠ This device's deployments moved from ${previousEmail || "another account"} to ${who.email || "this account"}.\n`);
|
|
426
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.`);
|
|
@@ -460,7 +515,10 @@ async function cmdLogin(opts) {
|
|
|
460
515
|
try {
|
|
461
516
|
const r = await client.linkMachine(token, { userId: identity.userId, userKey: identity.userKey }, opts);
|
|
462
517
|
linked = true; reparented = !!r.reparented; previousEmail = r.previousEmail || null;
|
|
463
|
-
} catch {
|
|
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
|
+
}
|
|
464
522
|
}
|
|
465
523
|
if (reparented) {
|
|
466
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`);
|
|
@@ -479,7 +537,12 @@ async function cmdLogout(opts) {
|
|
|
479
537
|
try {
|
|
480
538
|
const r = await client.logout(token, { userId: identity.userId, userKey: identity.userKey }, opts);
|
|
481
539
|
revoked = !!r.revoked; unlinked = !!r.unlinked;
|
|
482
|
-
} catch {
|
|
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
|
+
}
|
|
483
546
|
creds.clearAccountToken();
|
|
484
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" : ""}.`);
|
|
485
548
|
emit({ ok: true, loggedOut: true, revoked, unlinked });
|
|
@@ -544,6 +607,10 @@ async function cmdConnectors(opts) {
|
|
|
544
607
|
emit({ ok: false, error: `couldn't read tool arguments: ${e.message}`, code: "BAD_ARGS" });
|
|
545
608
|
return 2;
|
|
546
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
|
+
}
|
|
547
614
|
let r;
|
|
548
615
|
try { r = await client.connectorsExec(token, { connector: opts.connector, tool: opts.tool, arguments: args }, opts); }
|
|
549
616
|
catch (e) { emit({ ok: false, error: e.message, code: e.code || "EXEC" }); return 1; }
|
|
@@ -626,6 +693,7 @@ async function main(argv) {
|
|
|
626
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)
|
|
627
694
|
if (opts.version) { emit({ version: VERSION }); return 0; }
|
|
628
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; }
|
|
629
697
|
const cmd = opts._[0] || "deploy";
|
|
630
698
|
const root = process.cwd();
|
|
631
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
|
-
|
|
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(
|
|
42
|
+
for (const e of (excludeDirs || [])) args.push("--exclude=./" + String(e).replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, ""));
|
|
39
43
|
args.push("-C", dir, ".");
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 };
|