speculos-toolkit 1.0.0
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 +72 -0
- package/bin/speculos-toolkit.js +7 -0
- package/package.json +36 -0
- package/skill/SKILL.md +347 -0
- package/src/build.js +197 -0
- package/src/client.js +55 -0
- package/src/creds.js +83 -0
- package/src/detect.js +129 -0
- package/src/index.js +547 -0
- package/src/pack.js +46 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// HTTP client to the Speculos orchestrator (management API). Holds no platform
|
|
2
|
+
// secrets. The orchestrator runs self-hosted behind the Cloudflare forwarding
|
|
3
|
+
// deploy-orch-38hd4.speculos.ai; it drives Daytona backends and pushes frontends
|
|
4
|
+
// to the EC2 host that serves them at user-deployed.speculos.ai.
|
|
5
|
+
const DEFAULT_API = process.env.SPECULOS_API || "https://deploy-orch-38hd4.speculos.ai";
|
|
6
|
+
|
|
7
|
+
function base(opts) { return (opts.api || DEFAULT_API).replace(/\/$/, ""); }
|
|
8
|
+
|
|
9
|
+
async function post(url, body, token) {
|
|
10
|
+
const headers = { "Content-Type": "application/json" };
|
|
11
|
+
if (token) headers["Authorization"] = "Bearer " + token;
|
|
12
|
+
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
13
|
+
const data = await res.json().catch(() => ({}));
|
|
14
|
+
if (!res.ok) { const e = new Error(data.error || `HTTP ${res.status}`); e.code = data.code; e.status = res.status; throw e; }
|
|
15
|
+
return data;
|
|
16
|
+
}
|
|
17
|
+
async function get(url, token) {
|
|
18
|
+
const headers = {};
|
|
19
|
+
if (token) headers["Authorization"] = "Bearer " + token;
|
|
20
|
+
const res = await fetch(url, { headers });
|
|
21
|
+
const data = await res.json().catch(() => ({}));
|
|
22
|
+
if (!res.ok) { const e = new Error(data.error || `HTTP ${res.status}`); e.code = data.code; e.status = res.status; throw e; }
|
|
23
|
+
return data;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// establish identity (mint on first call) + allocate the slug's stable uuid.
|
|
27
|
+
// opts.token (CLI account token) links this machine to the account.
|
|
28
|
+
async function allocate(payload, opts = {}) { return post(base(opts) + "/api/allocate", payload, opts.token); }
|
|
29
|
+
// start a per-app backend sandbox (account token authorizes via entitlement)
|
|
30
|
+
async function startBackend(payload, opts = {}) { return post(base(opts) + "/api/backend", payload, opts.token); }
|
|
31
|
+
// ---- account device-link ----
|
|
32
|
+
async function linkStart(opts = {}) { return post(base(opts) + "/api/cli/link/start", {}); }
|
|
33
|
+
async function linkPoll(code, opts = {}) { return post(base(opts) + "/api/cli/link/poll", { code }); }
|
|
34
|
+
// verify a pasted account token (login --token) and get its account/org
|
|
35
|
+
async function whoami(token, opts = {}) { return get(base(opts) + "/api/account/whoami", token); }
|
|
36
|
+
async function linkMachine(token, payload, opts = {}) { return post(base(opts) + "/api/account/link", payload, token); }
|
|
37
|
+
async function logout(token, opts = {}) { return post(base(opts) + "/api/account/logout", {}, token); }
|
|
38
|
+
// poll a backend job (owner-authenticated)
|
|
39
|
+
async function backendStatus(jobId, creds = {}, opts = {}) {
|
|
40
|
+
const qs = new URLSearchParams({ userId: creds.userId || "", userKey: creds.userKey || "" });
|
|
41
|
+
return get(base(opts) + "/api/backend/" + encodeURIComponent(jobId) + "?" + qs.toString());
|
|
42
|
+
}
|
|
43
|
+
// upload built/static frontend
|
|
44
|
+
async function putFrontend(payload, opts = {}) { return post(base(opts) + "/api/frontend", payload); }
|
|
45
|
+
// remove a deployment
|
|
46
|
+
async function teardown(payload, opts = {}) { return post(base(opts) + "/api/teardown", payload); }
|
|
47
|
+
// ---- connectors (data sources linked in the dashboard) ----
|
|
48
|
+
async function connectorsList(token, opts = {}) {
|
|
49
|
+
return get(base(opts) + "/api/connectors" + (opts.noTools ? "?tools=0" : ""), token);
|
|
50
|
+
}
|
|
51
|
+
async function connectorsExec(token, payload, opts = {}) {
|
|
52
|
+
return post(base(opts) + "/api/connectors/execute", payload, token);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { allocate, startBackend, backendStatus, putFrontend, teardown, linkStart, linkPoll, linkMachine, whoami, logout, connectorsList, connectorsExec, base, DEFAULT_API };
|
package/src/creds.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Local credentials.
|
|
2
|
+
// ~/.speculos/identity.json -> { userId, userKey } (machine-global; groups
|
|
3
|
+
// all of this machine's deploys under one userId)
|
|
4
|
+
// <project>/.speculos.json -> { slug, slugUuid } (per-project, gitignored)
|
|
5
|
+
// userKey is a secret; both files are kept out of git.
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
// ---- machine-global identity --------------------------------------------
|
|
11
|
+
|
|
12
|
+
function identityDir() { return path.join(os.homedir(), ".speculos"); }
|
|
13
|
+
function identityFile() { return path.join(identityDir(), "identity.json"); }
|
|
14
|
+
|
|
15
|
+
function loadIdentity() {
|
|
16
|
+
const f = identityFile();
|
|
17
|
+
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;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Merge-write so we never drop a field the other writer set (e.g. accountToken).
|
|
29
|
+
// Atomic (tmp + rename) so a crash/full-disk mid-write can't truncate the file.
|
|
30
|
+
function writeIdentity(obj) {
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(identityDir(), { recursive: true });
|
|
33
|
+
const tmp = identityFile() + ".tmp";
|
|
34
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
|
|
35
|
+
fs.renameSync(tmp, identityFile());
|
|
36
|
+
} catch { /* best effort */ }
|
|
37
|
+
}
|
|
38
|
+
function saveIdentity({ userId, userKey }) {
|
|
39
|
+
const cur = loadIdentity() || {};
|
|
40
|
+
writeIdentity({ ...cur, userId, userKey });
|
|
41
|
+
}
|
|
42
|
+
// The CLI account token from `speculos-toolkit login` (links this machine to an
|
|
43
|
+
// account; unlocks backend hosting). Stored alongside the machine identity.
|
|
44
|
+
function saveAccountToken(accountToken) {
|
|
45
|
+
const cur = loadIdentity() || {};
|
|
46
|
+
writeIdentity({ ...cur, accountToken });
|
|
47
|
+
}
|
|
48
|
+
function clearAccountToken() {
|
|
49
|
+
const cur = loadIdentity();
|
|
50
|
+
if (!cur) return;
|
|
51
|
+
delete cur.accountToken;
|
|
52
|
+
writeIdentity(cur);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- per-project slug record --------------------------------------------
|
|
56
|
+
|
|
57
|
+
function file(root) { return path.join(root, ".speculos.json"); }
|
|
58
|
+
|
|
59
|
+
function load(root) {
|
|
60
|
+
try { return JSON.parse(fs.readFileSync(file(root), "utf8")); } catch { return null; }
|
|
61
|
+
}
|
|
62
|
+
function save(root, data) {
|
|
63
|
+
fs.writeFileSync(file(root), JSON.stringify(data, null, 2) + "\n");
|
|
64
|
+
ensureGitignored(root);
|
|
65
|
+
}
|
|
66
|
+
function remove(root) {
|
|
67
|
+
try { fs.unlinkSync(file(root)); } catch { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureGitignored(root) {
|
|
71
|
+
const gi = path.join(root, ".gitignore");
|
|
72
|
+
try {
|
|
73
|
+
let txt = "";
|
|
74
|
+
try { txt = fs.readFileSync(gi, "utf8"); } catch { /* no .gitignore yet */ }
|
|
75
|
+
const has = txt.split(/\r?\n/).some((l) => l.trim() === ".speculos.json");
|
|
76
|
+
if (!has) {
|
|
77
|
+
const prefix = txt && !txt.endsWith("\n") ? txt + "\n" : txt;
|
|
78
|
+
fs.writeFileSync(gi, prefix + ".speculos.json\n");
|
|
79
|
+
}
|
|
80
|
+
} catch { /* best effort */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { loadIdentity, saveIdentity, saveAccountToken, clearAccountToken, load, save, remove, file };
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Auto-detect frontend + backend dirs and how to build/run them.
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
|
|
6
|
+
const FRONTEND_DIRS = ["frontend", "web", "client", "site", "www", "ui", "app"];
|
|
7
|
+
const BACKEND_DIRS = ["backend", "api", "server", "svc", "service"];
|
|
8
|
+
|
|
9
|
+
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
|
|
10
|
+
function readJSON(p) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; } }
|
|
11
|
+
|
|
12
|
+
function slugify(s) {
|
|
13
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 34) || "app";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function defaultSlug(root) {
|
|
17
|
+
const base = slugify(path.basename(path.resolve(root)));
|
|
18
|
+
const h = crypto.createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 4);
|
|
19
|
+
return `${base}-${h}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---- frontend -----------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function classifyFrontend(dir, opts = {}) {
|
|
25
|
+
const pkg = readJSON(path.join(dir, "package.json"));
|
|
26
|
+
// --static / --no-build: serve the dir as-is even if it has a build script
|
|
27
|
+
// (e.g. a plain static site whose package.json only runs Tailwind/a linter).
|
|
28
|
+
const hasBuild = !opts.noBuild && pkg && pkg.scripts && pkg.scripts.build;
|
|
29
|
+
if (hasBuild) {
|
|
30
|
+
const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
|
|
31
|
+
let outputDir = null, framework = "other";
|
|
32
|
+
if (deps.next) { outputDir = "out"; framework = "next"; } // next export; SSR not covered by a static host
|
|
33
|
+
else if (deps.vite || deps["@vitejs/plugin-react"]) { outputDir = "dist"; framework = "vite"; }
|
|
34
|
+
else if (deps["react-scripts"]) { outputDir = "build"; framework = "cra"; }
|
|
35
|
+
else if (deps["@angular/core"]) { outputDir = "dist"; framework = "angular"; }
|
|
36
|
+
else if (deps.svelte || deps["@sveltejs/kit"]) { outputDir = "build"; framework = "svelte"; }
|
|
37
|
+
return { dir, kind: "build", buildCmd: "npm run build", outputDir, framework };
|
|
38
|
+
}
|
|
39
|
+
// static: a directory of files we serve as-is (plain HTML or a prebuilt dist)
|
|
40
|
+
return { dir, kind: "static", buildCmd: null, outputDir: null, framework: "static" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findFrontend(root, explicit, opts = {}) {
|
|
44
|
+
if (explicit) return classifyFrontend(explicit, opts);
|
|
45
|
+
for (const name of FRONTEND_DIRS) {
|
|
46
|
+
const p = path.join(root, name);
|
|
47
|
+
if (exists(p) && fs.statSync(p).isDirectory()) {
|
|
48
|
+
const c = classifyFrontend(p, opts);
|
|
49
|
+
// a named dir counts as the frontend if it builds with a known framework
|
|
50
|
+
// or is a plain static dir (has an index.html)
|
|
51
|
+
if (c.framework !== "static" || exists(path.join(p, "index.html"))) return c;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// root itself: a plain static site (index.html), or a RECOGNIZED build framework
|
|
55
|
+
// at the root (Next/Vite/CRA/Angular/Svelte). An unknown-framework root build
|
|
56
|
+
// ("other" — e.g. a bare `tsc` project) is NOT auto-taken as a frontend: that
|
|
57
|
+
// would misdetect a backend-only repo and publish its compiled source. Such a
|
|
58
|
+
// root app can still be deployed explicitly with `--frontend .`.
|
|
59
|
+
if (exists(path.join(root, "index.html"))) return classifyFrontend(root, opts);
|
|
60
|
+
const rootC = classifyFrontend(root, opts);
|
|
61
|
+
if (rootC.kind === "build" && rootC.framework !== "other") return rootC;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- backend ------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function classifyBackend(dir) {
|
|
68
|
+
const pkg = readJSON(path.join(dir, "package.json"));
|
|
69
|
+
if (pkg) {
|
|
70
|
+
// bun signal: a bun lockfile, bunfig, or packageManager/engines naming bun.
|
|
71
|
+
const isBun = exists(path.join(dir, "bun.lockb")) || exists(path.join(dir, "bun.lock")) ||
|
|
72
|
+
exists(path.join(dir, "bunfig.toml")) ||
|
|
73
|
+
(typeof pkg.packageManager === "string" && pkg.packageManager.startsWith("bun")) ||
|
|
74
|
+
!!(pkg.engines && pkg.engines.bun);
|
|
75
|
+
const runtime = isBun ? "bun" : "node";
|
|
76
|
+
let startCmd;
|
|
77
|
+
if (pkg.scripts && pkg.scripts.start) startCmd = isBun ? "bun run start" : "npm start";
|
|
78
|
+
else if (isBun) {
|
|
79
|
+
const entry = ["index.ts", "server.ts", "src/index.ts", "src/server.ts", "index.js", "server.js", "app.ts", "main.ts"]
|
|
80
|
+
.find((f) => exists(path.join(dir, f)));
|
|
81
|
+
startCmd = entry ? `bun ${entry}` : "bun index.ts";
|
|
82
|
+
} else {
|
|
83
|
+
const entry = ["index.js", "server.js", "app.js", "main.js", "src/index.js", "src/server.js"]
|
|
84
|
+
.find((f) => exists(path.join(dir, f)));
|
|
85
|
+
startCmd = entry ? `node ${entry}` : "node index.js";
|
|
86
|
+
}
|
|
87
|
+
return { dir, runtime, startCmd };
|
|
88
|
+
}
|
|
89
|
+
if (exists(path.join(dir, "requirements.txt")) || exists(path.join(dir, "pyproject.toml")) ||
|
|
90
|
+
exists(path.join(dir, "app.py")) || exists(path.join(dir, "main.py"))) {
|
|
91
|
+
const procfile = readProcfile(dir);
|
|
92
|
+
let startCmd = procfile;
|
|
93
|
+
if (!startCmd) {
|
|
94
|
+
const entry = ["app.py", "main.py", "server.py", "wsgi.py"].find((f) => exists(path.join(dir, f)));
|
|
95
|
+
startCmd = entry ? `python3 ${entry}` : "python3 app.py";
|
|
96
|
+
}
|
|
97
|
+
return { dir, runtime: "python", startCmd };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readProcfile(dir) {
|
|
103
|
+
try {
|
|
104
|
+
const txt = fs.readFileSync(path.join(dir, "Procfile"), "utf8");
|
|
105
|
+
const m = txt.match(/^web:\s*(.+)$/m);
|
|
106
|
+
return m ? m[1].trim() : null;
|
|
107
|
+
} catch { return null; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findBackend(root, explicit) {
|
|
111
|
+
if (explicit) return classifyBackend(explicit);
|
|
112
|
+
for (const name of BACKEND_DIRS) {
|
|
113
|
+
const p = path.join(root, name);
|
|
114
|
+
if (exists(p) && fs.statSync(p).isDirectory()) { const c = classifyBackend(p); if (c) return c; }
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function detect(root, opts = {}) {
|
|
120
|
+
const frontend = opts.noFrontend ? null : findFrontend(root, opts.frontend, { noBuild: opts.static });
|
|
121
|
+
const backend = opts.noBackend ? null : findBackend(root, opts.backend);
|
|
122
|
+
if (frontend && opts.build) frontend.kind = "build";
|
|
123
|
+
if (frontend && opts.output) frontend.outputDir = opts.output;
|
|
124
|
+
if (backend && opts.start) backend.startCmd = opts.start;
|
|
125
|
+
if (backend && opts.runtime) backend.runtime = opts.runtime;
|
|
126
|
+
return { slug: opts.slug || defaultSlug(root), frontend, backend };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { detect, slugify, defaultSlug, classifyFrontend, classifyBackend };
|