sakuraai 0.0.9 → 0.0.11
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/dist/index.js +268 -60
- package/package.json +3 -2
- package/scripts/download-onboard.js +68 -0
package/dist/index.js
CHANGED
|
@@ -103,8 +103,8 @@ var init_config = __esm({
|
|
|
103
103
|
function apiBase() {
|
|
104
104
|
return (loadAuth().serverUrl || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
105
105
|
}
|
|
106
|
-
async function postJson(base,
|
|
107
|
-
const res = await fetch(`${base}${
|
|
106
|
+
async function postJson(base, path12, body, token) {
|
|
107
|
+
const res = await fetch(`${base}${path12}`, {
|
|
108
108
|
method: "POST",
|
|
109
109
|
headers: {
|
|
110
110
|
"Content-Type": "application/json",
|
|
@@ -114,7 +114,7 @@ async function postJson(base, path10, body, token) {
|
|
|
114
114
|
});
|
|
115
115
|
if (!res.ok) {
|
|
116
116
|
const text = await res.text().catch(() => "");
|
|
117
|
-
throw new Error(`${
|
|
117
|
+
throw new Error(`${path12} \u2192 ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
|
|
118
118
|
}
|
|
119
119
|
return await res.json();
|
|
120
120
|
}
|
|
@@ -535,7 +535,7 @@ function sessionCwd(sessionId) {
|
|
|
535
535
|
}
|
|
536
536
|
return void 0;
|
|
537
537
|
}
|
|
538
|
-
function send(sessionId, message, onData, images) {
|
|
538
|
+
function send(sessionId, message, onData, images, model) {
|
|
539
539
|
return new Promise((resolve) => {
|
|
540
540
|
const bin = process.env.CLAUDE_BIN || "claude";
|
|
541
541
|
const cwd = sessionCwd(sessionId);
|
|
@@ -546,6 +546,7 @@ function send(sessionId, message, onData, images) {
|
|
|
546
546
|
|
|
547
547
|
${refs}` : refs;
|
|
548
548
|
}
|
|
549
|
+
const modelArgs = model ? ["--model", model] : [];
|
|
549
550
|
const child2 = spawn2(
|
|
550
551
|
bin,
|
|
551
552
|
[
|
|
@@ -553,6 +554,7 @@ ${refs}` : refs;
|
|
|
553
554
|
sessionId,
|
|
554
555
|
"-p",
|
|
555
556
|
prompt,
|
|
557
|
+
...modelArgs,
|
|
556
558
|
"--output-format",
|
|
557
559
|
"stream-json",
|
|
558
560
|
"--include-partial-messages",
|
|
@@ -723,10 +725,11 @@ function messages2(sessionId) {
|
|
|
723
725
|
const file = findFile2(sessionId);
|
|
724
726
|
return file ? readMessages2(file) : [];
|
|
725
727
|
}
|
|
726
|
-
function send2(sessionId, message, onData, images) {
|
|
728
|
+
function send2(sessionId, message, onData, images, model) {
|
|
727
729
|
return new Promise((resolve) => {
|
|
728
730
|
const imageArgs = (images ?? []).flatMap((p) => ["-i", p]);
|
|
729
|
-
const
|
|
731
|
+
const modelArgs = model ? ["--model", model] : [];
|
|
732
|
+
const child2 = spawn3(codexBin(), ["exec", ...modelArgs, "resume", sessionId, ...imageArgs, message], {
|
|
730
733
|
stdio: ["ignore", "pipe", "pipe"],
|
|
731
734
|
env: process.env
|
|
732
735
|
});
|
|
@@ -870,10 +873,11 @@ async function messages3(sessionId) {
|
|
|
870
873
|
db.close();
|
|
871
874
|
}
|
|
872
875
|
}
|
|
873
|
-
function send3(sessionId, message, onData, images) {
|
|
876
|
+
function send3(sessionId, message, onData, images, model) {
|
|
874
877
|
return new Promise((resolve) => {
|
|
875
878
|
const fileArgs = (images ?? []).flatMap((p) => ["-f", p]);
|
|
876
|
-
const
|
|
879
|
+
const modelArgs = model ? ["-m", model] : [];
|
|
880
|
+
const child2 = spawn4(opencodeBin(), ["run", "-s", sessionId, ...modelArgs, ...fileArgs, message], {
|
|
877
881
|
stdio: ["ignore", "pipe", "pipe"],
|
|
878
882
|
env: process.env
|
|
879
883
|
});
|
|
@@ -1002,11 +1006,11 @@ async function history(sessionId, opts = {}, agent) {
|
|
|
1002
1006
|
if (opts.reverse) msgs = [...msgs].reverse();
|
|
1003
1007
|
return msgs;
|
|
1004
1008
|
}
|
|
1005
|
-
async function chat(sessionId, prompt, agent, onData, images) {
|
|
1009
|
+
async function chat(sessionId, prompt, agent, onData, images, model) {
|
|
1006
1010
|
const a = agent ?? await detectAgent(sessionId);
|
|
1007
|
-
if (a === "claude") return send(sessionId, prompt, onData, images);
|
|
1008
|
-
if (a === "codex") return send2(sessionId, prompt, onData, images);
|
|
1009
|
-
if (a === "opencode") return send3(sessionId, prompt, onData, images);
|
|
1011
|
+
if (a === "claude") return send(sessionId, prompt, onData, images, model);
|
|
1012
|
+
if (a === "codex") return send2(sessionId, prompt, onData, images, model);
|
|
1013
|
+
if (a === "opencode") return send3(sessionId, prompt, onData, images, model);
|
|
1010
1014
|
return {
|
|
1011
1015
|
ok: false,
|
|
1012
1016
|
output: "",
|
|
@@ -1512,6 +1516,94 @@ var init_tailscale = __esm({
|
|
|
1512
1516
|
}
|
|
1513
1517
|
});
|
|
1514
1518
|
|
|
1519
|
+
// src/runtime/models.ts
|
|
1520
|
+
import fs10 from "fs";
|
|
1521
|
+
import path8 from "path";
|
|
1522
|
+
import { execFileSync } from "child_process";
|
|
1523
|
+
function codexModels() {
|
|
1524
|
+
const out = [{ id: "", label: "Default" }];
|
|
1525
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1526
|
+
let configured;
|
|
1527
|
+
try {
|
|
1528
|
+
const cfg = fs10.readFileSync(path8.join(HOME, ".codex", "config.toml"), "utf8");
|
|
1529
|
+
configured = /^\s*model\s*=\s*"([^"]+)"/m.exec(cfg)?.[1];
|
|
1530
|
+
for (const m of cfg.matchAll(/model\s*=\s*"([^"]+)"/g)) {
|
|
1531
|
+
const id = m[1];
|
|
1532
|
+
if (id && !seen.has(id)) {
|
|
1533
|
+
seen.add(id);
|
|
1534
|
+
out.push({ id, label: id === configured ? `${id} (config)` : id });
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
} catch {
|
|
1538
|
+
}
|
|
1539
|
+
for (const id of CODEX_CURATED) {
|
|
1540
|
+
if (!seen.has(id)) {
|
|
1541
|
+
seen.add(id);
|
|
1542
|
+
out.push({ id, label: id });
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return out;
|
|
1546
|
+
}
|
|
1547
|
+
function opencodeModels() {
|
|
1548
|
+
const out = [{ id: "", label: "Default" }];
|
|
1549
|
+
const allowed = /* @__PURE__ */ new Set(["opencode"]);
|
|
1550
|
+
try {
|
|
1551
|
+
const auth = JSON.parse(
|
|
1552
|
+
fs10.readFileSync(path8.join(HOME, ".local", "share", "opencode", "auth.json"), "utf8")
|
|
1553
|
+
);
|
|
1554
|
+
for (const k of Object.keys(auth)) allowed.add(k);
|
|
1555
|
+
} catch {
|
|
1556
|
+
}
|
|
1557
|
+
try {
|
|
1558
|
+
const raw = execFileSync(opencodeBin(), ["models"], { encoding: "utf8", timeout: 15e3 });
|
|
1559
|
+
for (const line of raw.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
1560
|
+
const slash = line.indexOf("/");
|
|
1561
|
+
if (slash < 0) continue;
|
|
1562
|
+
const provider = line.slice(0, slash);
|
|
1563
|
+
const model = line.slice(slash + 1);
|
|
1564
|
+
if (!allowed.has(provider)) continue;
|
|
1565
|
+
out.push({
|
|
1566
|
+
id: line,
|
|
1567
|
+
// opencode wants the full provider/model string
|
|
1568
|
+
label: model,
|
|
1569
|
+
group: OC_PROVIDER_LABELS[provider] ?? provider,
|
|
1570
|
+
free: provider === "opencode" || /free/i.test(model)
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
} catch {
|
|
1574
|
+
}
|
|
1575
|
+
return out;
|
|
1576
|
+
}
|
|
1577
|
+
function listModels(agent) {
|
|
1578
|
+
if (agent === "codex") return codexModels();
|
|
1579
|
+
if (agent === "opencode") return opencodeModels();
|
|
1580
|
+
return CLAUDE_MODELS;
|
|
1581
|
+
}
|
|
1582
|
+
var CLAUDE_MODELS, CODEX_CURATED, OC_PROVIDER_LABELS;
|
|
1583
|
+
var init_models = __esm({
|
|
1584
|
+
"src/runtime/models.ts"() {
|
|
1585
|
+
"use strict";
|
|
1586
|
+
init_util();
|
|
1587
|
+
init_opencode();
|
|
1588
|
+
CLAUDE_MODELS = [
|
|
1589
|
+
{ id: "", label: "Default" },
|
|
1590
|
+
{ id: "opus", label: "Opus" },
|
|
1591
|
+
{ id: "sonnet", label: "Sonnet" },
|
|
1592
|
+
{ id: "haiku", label: "Haiku" },
|
|
1593
|
+
{ id: "opus[1m]", label: "Opus (1M context)" }
|
|
1594
|
+
];
|
|
1595
|
+
CODEX_CURATED = ["gpt-5.5-codex", "gpt-5.5", "gpt-5-codex", "gpt-5"];
|
|
1596
|
+
OC_PROVIDER_LABELS = {
|
|
1597
|
+
opencode: "OpenCode Zen",
|
|
1598
|
+
"opencode-go": "OpenCode Go",
|
|
1599
|
+
openrouter: "OpenRouter",
|
|
1600
|
+
zai: "Z.AI",
|
|
1601
|
+
"zai-coding-plan": "Z.AI Coding",
|
|
1602
|
+
"zhipuai-coding-plan": "Zhipu Coding"
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1515
1607
|
// src/daemon/hub.ts
|
|
1516
1608
|
function sessionKey(agent, sessionId) {
|
|
1517
1609
|
return `${agent}:${sessionId}`;
|
|
@@ -1550,7 +1642,7 @@ var init_hub = __esm({
|
|
|
1550
1642
|
});
|
|
1551
1643
|
|
|
1552
1644
|
// src/daemon/stream.ts
|
|
1553
|
-
async function runTurn(sessionId, agent, prompt, images) {
|
|
1645
|
+
async function runTurn(sessionId, agent, prompt, images, model) {
|
|
1554
1646
|
const key = sessionKey(agent, sessionId);
|
|
1555
1647
|
const emit = (e) => hub.broadcast(key, e);
|
|
1556
1648
|
let lastEmitted = "\0";
|
|
@@ -1590,7 +1682,7 @@ async function runTurn(sessionId, agent, prompt, images) {
|
|
|
1590
1682
|
} catch {
|
|
1591
1683
|
}
|
|
1592
1684
|
}, POLL_MS);
|
|
1593
|
-
const result = await chat(sessionId, prompt, agent, onData, images);
|
|
1685
|
+
const result = await chat(sessionId, prompt, agent, onData, images, model);
|
|
1594
1686
|
clearInterval(timer);
|
|
1595
1687
|
emit({ type: "done", sessionId, agent, ok: result.ok, error: result.error });
|
|
1596
1688
|
notifyTurnEnd(agent, sessionId, result.ok, result.error, stdoutAcc);
|
|
@@ -1698,15 +1790,15 @@ var init_terminal = __esm({
|
|
|
1698
1790
|
|
|
1699
1791
|
// src/daemon/server.ts
|
|
1700
1792
|
import http from "http";
|
|
1701
|
-
import
|
|
1793
|
+
import fs11 from "fs";
|
|
1702
1794
|
import os7 from "os";
|
|
1703
|
-
import
|
|
1795
|
+
import path9 from "path";
|
|
1704
1796
|
import { URL } from "url";
|
|
1705
1797
|
import { WebSocketServer } from "ws";
|
|
1706
|
-
function add(method,
|
|
1798
|
+
function add(method, path12, handler) {
|
|
1707
1799
|
const keys = [];
|
|
1708
1800
|
const pattern = new RegExp(
|
|
1709
|
-
"^" +
|
|
1801
|
+
"^" + path12.replace(/:[^/]+/g, (m) => {
|
|
1710
1802
|
keys.push(m.slice(1));
|
|
1711
1803
|
return "([^/]+)";
|
|
1712
1804
|
}) + "/?$"
|
|
@@ -1720,8 +1812,8 @@ function fail(code, stderr, data = null) {
|
|
|
1720
1812
|
return { ok: false, data, stdout: "", stderr, code };
|
|
1721
1813
|
}
|
|
1722
1814
|
function saveImages(sessionId, images) {
|
|
1723
|
-
const dir =
|
|
1724
|
-
|
|
1815
|
+
const dir = path9.join(os7.tmpdir(), "sakura-uploads", sessionId.slice(0, 24));
|
|
1816
|
+
fs11.mkdirSync(dir, { recursive: true });
|
|
1725
1817
|
const paths = [];
|
|
1726
1818
|
images.forEach((img, i) => {
|
|
1727
1819
|
const raw = String(img?.data ?? "");
|
|
@@ -1732,8 +1824,8 @@ function saveImages(sessionId, images) {
|
|
|
1732
1824
|
}
|
|
1733
1825
|
const ext = EXT_BY_MIME[m[1]] ?? "png";
|
|
1734
1826
|
const safe = img.name && /\.[a-z0-9]+$/i.test(img.name) ? img.name.replace(/[^\w.\-]/g, "_") : `image-${Date.now()}-${i + 1}.${ext}`;
|
|
1735
|
-
const file =
|
|
1736
|
-
|
|
1827
|
+
const file = path9.join(dir, safe);
|
|
1828
|
+
fs11.writeFileSync(file, Buffer.from(m[2], "base64"));
|
|
1737
1829
|
log.info(`saveImages: wrote ${file} (${m[2].length} b64 chars)`);
|
|
1738
1830
|
paths.push(file);
|
|
1739
1831
|
});
|
|
@@ -1874,6 +1966,7 @@ var init_server = __esm({
|
|
|
1874
1966
|
init_fsops();
|
|
1875
1967
|
init_tailscale();
|
|
1876
1968
|
init_registry();
|
|
1969
|
+
init_models();
|
|
1877
1970
|
init_hub();
|
|
1878
1971
|
init_stream();
|
|
1879
1972
|
init_terminal();
|
|
@@ -1905,7 +1998,8 @@ var init_server = __esm({
|
|
|
1905
1998
|
log.info(`send: agent=${agent} msgLen=${message.length} bodyKeys=[${Object.keys(body ?? {}).join(",")}] images=${incoming.length}`);
|
|
1906
1999
|
const imagePaths = incoming.length ? saveImages(params.id, incoming) : [];
|
|
1907
2000
|
if (imagePaths.length) log.info(`send: attached ${imagePaths.length} image(s) to ${agent} prompt`);
|
|
1908
|
-
const
|
|
2001
|
+
const model = body?.model ? String(body.model) : void 0;
|
|
2002
|
+
const r = await runTurn(params.id, agent, message, imagePaths, model);
|
|
1909
2003
|
return { ok: r.ok, error: r.error };
|
|
1910
2004
|
});
|
|
1911
2005
|
add("POST", "/sessions/:id/rename", ({ params, body }) => {
|
|
@@ -1944,7 +2038,7 @@ var init_server = __esm({
|
|
|
1944
2038
|
case "logs": {
|
|
1945
2039
|
let logs2 = "";
|
|
1946
2040
|
try {
|
|
1947
|
-
const lines =
|
|
2041
|
+
const lines = fs11.readFileSync(LOG_PATH, "utf8").split("\n");
|
|
1948
2042
|
logs2 = lines.slice(-200).join("\n");
|
|
1949
2043
|
} catch {
|
|
1950
2044
|
}
|
|
@@ -1977,6 +2071,10 @@ var init_server = __esm({
|
|
|
1977
2071
|
({ url }) => ok(listMachines(url.searchParams.get("onlineOnly") === "true"))
|
|
1978
2072
|
);
|
|
1979
2073
|
add("GET", "/sakura/projects", () => ok(listProjects()));
|
|
2074
|
+
add("GET", "/sakura/models", ({ url }) => {
|
|
2075
|
+
const agent = url.searchParams.get("agent") || "claude";
|
|
2076
|
+
return ok(listModels(agent));
|
|
2077
|
+
});
|
|
1980
2078
|
add("POST", "/sakura/push-token", ({ body }) => {
|
|
1981
2079
|
const token = String(body?.token ?? "");
|
|
1982
2080
|
if (!token) return fail("bad_request", "token required");
|
|
@@ -2012,7 +2110,8 @@ var init_server = __esm({
|
|
|
2012
2110
|
add("POST", "/sakura/sessions/:id/chat", async ({ params, body }) => {
|
|
2013
2111
|
const agent = body?.agent || await detectAgent(params.id);
|
|
2014
2112
|
if (!agent) return fail("nonzero", "unknown agent for session");
|
|
2015
|
-
const
|
|
2113
|
+
const model = body?.model ? String(body.model) : void 0;
|
|
2114
|
+
const r = await runTurn(params.id, agent, String(body?.prompt ?? ""), void 0, model);
|
|
2016
2115
|
return r.ok ? ok({ ok: true }) : fail("nonzero", r.error ?? "chat failed");
|
|
2017
2116
|
});
|
|
2018
2117
|
add(
|
|
@@ -2131,24 +2230,24 @@ var init_server = __esm({
|
|
|
2131
2230
|
});
|
|
2132
2231
|
|
|
2133
2232
|
// src/daemon/tsnet.ts
|
|
2134
|
-
import
|
|
2135
|
-
import
|
|
2233
|
+
import fs12 from "fs";
|
|
2234
|
+
import path10 from "path";
|
|
2136
2235
|
import { fileURLToPath } from "url";
|
|
2137
2236
|
import { spawn as spawn8 } from "child_process";
|
|
2138
2237
|
function resolveBinary() {
|
|
2139
2238
|
const envBin = process.env.SAKURA_TSNET_BIN;
|
|
2140
|
-
if (envBin &&
|
|
2141
|
-
const here =
|
|
2239
|
+
if (envBin && fs12.existsSync(envBin)) return envBin;
|
|
2240
|
+
const here = path10.dirname(fileURLToPath(import.meta.url));
|
|
2142
2241
|
const name = process.platform === "win32" ? "sakura-tsnet.exe" : "sakura-tsnet";
|
|
2143
2242
|
const candidates = [
|
|
2144
|
-
|
|
2243
|
+
path10.join(here, "..", "bin", name),
|
|
2145
2244
|
// package/bin (postinstall download / build-sidecar.sh)
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2245
|
+
path10.join(here, "bin", name),
|
|
2246
|
+
path10.join(process.cwd(), "bin", name),
|
|
2247
|
+
path10.join(SAKURA_DIR, name)
|
|
2149
2248
|
// user-dropped
|
|
2150
2249
|
];
|
|
2151
|
-
return candidates.find((p) =>
|
|
2250
|
+
return candidates.find((p) => fs12.existsSync(p)) ?? null;
|
|
2152
2251
|
}
|
|
2153
2252
|
function startTsnet(port2) {
|
|
2154
2253
|
const cfg = loadConfig();
|
|
@@ -2171,11 +2270,11 @@ function spawnSidecar(bin, controlUrl, authKey, hostname2, port2) {
|
|
|
2171
2270
|
SAKURA_TS_CONTROL_URL: controlUrl,
|
|
2172
2271
|
SAKURA_TS_AUTH_KEY: authKey,
|
|
2173
2272
|
SAKURA_TS_HOSTNAME: hostname2,
|
|
2174
|
-
SAKURA_TS_STATE_DIR:
|
|
2273
|
+
SAKURA_TS_STATE_DIR: path10.join(SAKURA_DIR, "tsnet-state"),
|
|
2175
2274
|
SAKURA_TS_STATE_FILE: TSNET_PATH,
|
|
2176
2275
|
SAKURA_TS_PORT: String(port2)
|
|
2177
2276
|
};
|
|
2178
|
-
log.info(`starting embedded Tailscale sidecar (${
|
|
2277
|
+
log.info(`starting embedded Tailscale sidecar (${path10.basename(bin)})`);
|
|
2179
2278
|
const c = spawn8(bin, [], { stdio: ["ignore", "pipe", "pipe"], env });
|
|
2180
2279
|
child = c;
|
|
2181
2280
|
const pipe = (buf) => buf.toString().split("\n").filter(Boolean).forEach((line) => log.info(`[tsnet] ${line}`));
|
|
@@ -2204,7 +2303,7 @@ function stopTsnet() {
|
|
|
2204
2303
|
child = null;
|
|
2205
2304
|
}
|
|
2206
2305
|
try {
|
|
2207
|
-
|
|
2306
|
+
fs12.rmSync(TSNET_PATH, { force: true });
|
|
2208
2307
|
} catch {
|
|
2209
2308
|
}
|
|
2210
2309
|
}
|
|
@@ -2221,7 +2320,7 @@ var init_tsnet = __esm({
|
|
|
2221
2320
|
});
|
|
2222
2321
|
|
|
2223
2322
|
// src/daemon/manager.ts
|
|
2224
|
-
import
|
|
2323
|
+
import fs13 from "fs";
|
|
2225
2324
|
import { spawn as spawn9 } from "child_process";
|
|
2226
2325
|
async function ensureTailnetProvisioned() {
|
|
2227
2326
|
const serverToken = getServerToken();
|
|
@@ -2244,7 +2343,7 @@ async function ensureTailnetProvisioned() {
|
|
|
2244
2343
|
}
|
|
2245
2344
|
function readDaemonInfo() {
|
|
2246
2345
|
try {
|
|
2247
|
-
return JSON.parse(
|
|
2346
|
+
return JSON.parse(fs13.readFileSync(DAEMON_PATH, "utf8"));
|
|
2248
2347
|
} catch {
|
|
2249
2348
|
return null;
|
|
2250
2349
|
}
|
|
@@ -2261,8 +2360,8 @@ function running() {
|
|
|
2261
2360
|
const info2 = readDaemonInfo();
|
|
2262
2361
|
if (info2 && pidAlive(info2.pid)) return info2;
|
|
2263
2362
|
if (info2) {
|
|
2264
|
-
|
|
2265
|
-
|
|
2363
|
+
fs13.rmSync(DAEMON_PATH, { force: true });
|
|
2364
|
+
fs13.rmSync(PID_PATH, { force: true });
|
|
2266
2365
|
}
|
|
2267
2366
|
return null;
|
|
2268
2367
|
}
|
|
@@ -2285,8 +2384,8 @@ async function runServer(version) {
|
|
|
2285
2384
|
url: `http://${host}:${port2}`,
|
|
2286
2385
|
startedAt: Date.now()
|
|
2287
2386
|
};
|
|
2288
|
-
|
|
2289
|
-
|
|
2387
|
+
fs13.writeFileSync(DAEMON_PATH, JSON.stringify(info2, null, 2));
|
|
2388
|
+
fs13.writeFileSync(PID_PATH, String(process.pid));
|
|
2290
2389
|
await ensureTailnetProvisioned();
|
|
2291
2390
|
startTsnet(port2);
|
|
2292
2391
|
const tailnet = await discoverBaseUrl(port2).catch(() => null);
|
|
@@ -2297,8 +2396,8 @@ async function runServer(version) {
|
|
|
2297
2396
|
log.info("shutting down");
|
|
2298
2397
|
stopTsnet();
|
|
2299
2398
|
server.close();
|
|
2300
|
-
|
|
2301
|
-
|
|
2399
|
+
fs13.rmSync(DAEMON_PATH, { force: true });
|
|
2400
|
+
fs13.rmSync(PID_PATH, { force: true });
|
|
2302
2401
|
process.exit(0);
|
|
2303
2402
|
};
|
|
2304
2403
|
process.on("SIGINT", shutdown);
|
|
@@ -2309,8 +2408,8 @@ function start() {
|
|
|
2309
2408
|
if (existing) return existing;
|
|
2310
2409
|
ensureSakuraDirs();
|
|
2311
2410
|
const entry = process.argv[1] ?? "";
|
|
2312
|
-
const out =
|
|
2313
|
-
const err =
|
|
2411
|
+
const out = fs13.openSync(LOG_PATH, "a");
|
|
2412
|
+
const err = fs13.openSync(LOG_PATH, "a");
|
|
2314
2413
|
const child2 = spawn9(process.execPath, [entry, "__run-daemon"], {
|
|
2315
2414
|
detached: true,
|
|
2316
2415
|
stdio: ["ignore", out, err],
|
|
@@ -2332,8 +2431,8 @@ function stop() {
|
|
|
2332
2431
|
process.kill(info2.pid, "SIGTERM");
|
|
2333
2432
|
} catch {
|
|
2334
2433
|
}
|
|
2335
|
-
|
|
2336
|
-
|
|
2434
|
+
fs13.rmSync(DAEMON_PATH, { force: true });
|
|
2435
|
+
fs13.rmSync(PID_PATH, { force: true });
|
|
2337
2436
|
return true;
|
|
2338
2437
|
}
|
|
2339
2438
|
function restart() {
|
|
@@ -2343,7 +2442,7 @@ function restart() {
|
|
|
2343
2442
|
}
|
|
2344
2443
|
function logs(lines = 50) {
|
|
2345
2444
|
try {
|
|
2346
|
-
const all2 =
|
|
2445
|
+
const all2 = fs13.readFileSync(LOG_PATH, "utf8").split("\n");
|
|
2347
2446
|
return all2.slice(-lines).join("\n");
|
|
2348
2447
|
} catch {
|
|
2349
2448
|
return "";
|
|
@@ -2428,11 +2527,15 @@ async function showPairing(opts = {}) {
|
|
|
2428
2527
|
info(` Token: ${pairing.token}`);
|
|
2429
2528
|
info("");
|
|
2430
2529
|
if (pairing.embeddedTailscale) {
|
|
2431
|
-
info("Embedded Tailscale: the QR
|
|
2432
|
-
info("phone connects
|
|
2530
|
+
info("Embedded Tailscale: the QR carries the private tailnet join key, so the");
|
|
2531
|
+
info("phone connects over the tunnel from any network \u2014 no Tailscale app needed.");
|
|
2433
2532
|
info("");
|
|
2533
|
+
info("\u26A0 Scan the QR to connect from another network or mobile data. Pasting the");
|
|
2534
|
+
info(" URL + token by hand does NOT include the join key, so it only works when");
|
|
2535
|
+
info(" the phone is on the same Wi\u2011Fi as this Mac.");
|
|
2536
|
+
} else {
|
|
2537
|
+
info("Or paste the URL + token into the app (Settings \u2192 Connect) \u2014 same Wi\u2011Fi.");
|
|
2434
2538
|
}
|
|
2435
|
-
info("Or paste the token into the app (Settings \u2192 Connect).");
|
|
2436
2539
|
}
|
|
2437
2540
|
function registerPair(program) {
|
|
2438
2541
|
program.command("pair").description("Show a QR code + token to pair the Sakura mobile app").option("--json", "output the pairing payload as JSON").action(async (opts) => {
|
|
@@ -2461,7 +2564,7 @@ var init_pair = __esm({
|
|
|
2461
2564
|
import { Command } from "commander";
|
|
2462
2565
|
|
|
2463
2566
|
// src/version.ts
|
|
2464
|
-
var VERSION = "0.0.
|
|
2567
|
+
var VERSION = "0.0.11";
|
|
2465
2568
|
|
|
2466
2569
|
// src/index.ts
|
|
2467
2570
|
init_config();
|
|
@@ -2522,7 +2625,25 @@ function registerAuth(program) {
|
|
|
2522
2625
|
init_manager();
|
|
2523
2626
|
init_tailscale();
|
|
2524
2627
|
init_auth();
|
|
2628
|
+
init_config();
|
|
2525
2629
|
init_output();
|
|
2630
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2631
|
+
async function tunnelUrl(port2) {
|
|
2632
|
+
const ts = readTsnetState();
|
|
2633
|
+
if (ts?.tailscaleIp && ts.backendState === "Running") return `http://${ts.tailscaleIp}:${port2}`;
|
|
2634
|
+
return await discoverBaseUrl(port2).catch(() => null);
|
|
2635
|
+
}
|
|
2636
|
+
async function waitForTunnel(timeoutMs) {
|
|
2637
|
+
if (!getServerToken()) return false;
|
|
2638
|
+
const deadline = Date.now() + timeoutMs;
|
|
2639
|
+
while (Date.now() < deadline) {
|
|
2640
|
+
const cfg = loadConfig();
|
|
2641
|
+
const ts = readTsnetState();
|
|
2642
|
+
if (cfg.tailscale?.controlUrl && ts?.tailscaleIp && ts.backendState === "Running") return true;
|
|
2643
|
+
await sleep2(500);
|
|
2644
|
+
}
|
|
2645
|
+
return false;
|
|
2646
|
+
}
|
|
2526
2647
|
function registerRuntime(program, version) {
|
|
2527
2648
|
program.command("start").description("Run the Sakura runtime in this terminal (foreground)").option("--auth <token>", "sign in with a CLI token before starting").action(async (opts) => {
|
|
2528
2649
|
if (opts.auth) {
|
|
@@ -2539,9 +2660,11 @@ function registerRuntime(program, version) {
|
|
|
2539
2660
|
if (!isLoggedIn()) die("Not signed in. Run `sakuraai login` first.");
|
|
2540
2661
|
const info0 = start();
|
|
2541
2662
|
success(`Daemon running (pid ${info0.pid}) on ${info0.url}`);
|
|
2542
|
-
const
|
|
2543
|
-
if (
|
|
2544
|
-
|
|
2663
|
+
const ready = await waitForTunnel(2e4);
|
|
2664
|
+
if (ready) {
|
|
2665
|
+
info("Embedded Tailscale tunnel up \u2014 the phone can connect from any network, no Tailscale app needed.");
|
|
2666
|
+
} else if (getServerToken()) {
|
|
2667
|
+
warn("Tunnel not up yet \u2014 pairing over LAN for now. Re-run `sakuraai pair` in a moment for the private URL.");
|
|
2545
2668
|
}
|
|
2546
2669
|
const { showPairing: showPairing2 } = await Promise.resolve().then(() => (init_pair(), pair_exports));
|
|
2547
2670
|
await showPairing2();
|
|
@@ -2555,7 +2678,7 @@ function registerRuntime(program, version) {
|
|
|
2555
2678
|
});
|
|
2556
2679
|
d.command("status").description("Show runtime + connection status").option("--json", "output as JSON").action(async (opts) => {
|
|
2557
2680
|
const i = running();
|
|
2558
|
-
const tailnet = i ? await
|
|
2681
|
+
const tailnet = i ? await tunnelUrl(i.port) : null;
|
|
2559
2682
|
if (opts.json) return printJson({ running: !!i, daemon: i, tailnetBaseUrl: tailnet });
|
|
2560
2683
|
if (!i) return warn("Daemon is not running. Start it with `sakuraai daemon start`.");
|
|
2561
2684
|
success(`Daemon running (pid ${i.pid}) on ${i.url}`);
|
|
@@ -2570,7 +2693,7 @@ function registerRuntime(program, version) {
|
|
|
2570
2693
|
// src/commands/session.ts
|
|
2571
2694
|
init_sessions();
|
|
2572
2695
|
init_output();
|
|
2573
|
-
import
|
|
2696
|
+
import fs14 from "fs";
|
|
2574
2697
|
function resolveSessionId(arg) {
|
|
2575
2698
|
const id = arg || process.env.SAKURA_SESSION_ID;
|
|
2576
2699
|
if (!id) die("Provide a sessionId (positional or via SAKURA_SESSION_ID).");
|
|
@@ -2579,7 +2702,7 @@ function resolveSessionId(arg) {
|
|
|
2579
2702
|
async function resolvePrompt(positional, opts) {
|
|
2580
2703
|
if (positional) return positional;
|
|
2581
2704
|
if (opts.prompt) return opts.prompt;
|
|
2582
|
-
if (opts.promptFile) return
|
|
2705
|
+
if (opts.promptFile) return fs14.readFileSync(opts.promptFile, "utf8");
|
|
2583
2706
|
if (!process.stdin.isTTY) {
|
|
2584
2707
|
const chunks = [];
|
|
2585
2708
|
for await (const c of process.stdin) chunks.push(c);
|
|
@@ -2891,6 +3014,86 @@ function registerConnectivity(program) {
|
|
|
2891
3014
|
|
|
2892
3015
|
// src/index.ts
|
|
2893
3016
|
init_pair();
|
|
3017
|
+
|
|
3018
|
+
// src/commands/welcome.ts
|
|
3019
|
+
init_config();
|
|
3020
|
+
import fs15 from "fs";
|
|
3021
|
+
import path11 from "path";
|
|
3022
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3023
|
+
import { spawn as spawn10 } from "child_process";
|
|
3024
|
+
var WELCOMED_MARKER = path11.join(SAKURA_DIR, ".welcomed");
|
|
3025
|
+
function resolveBinary2() {
|
|
3026
|
+
const envBin = process.env.SAKURA_ONBOARD_BIN;
|
|
3027
|
+
if (envBin && fs15.existsSync(envBin)) return envBin;
|
|
3028
|
+
const here = path11.dirname(fileURLToPath2(import.meta.url));
|
|
3029
|
+
const name = process.platform === "win32" ? "sakura-onboard.exe" : "sakura-onboard";
|
|
3030
|
+
const candidates = [
|
|
3031
|
+
path11.join(here, "..", "bin", name),
|
|
3032
|
+
// package/bin (postinstall download)
|
|
3033
|
+
path11.join(here, "bin", name),
|
|
3034
|
+
path11.join(process.cwd(), "bin", name),
|
|
3035
|
+
path11.join(SAKURA_DIR, name)
|
|
3036
|
+
// user-dropped
|
|
3037
|
+
];
|
|
3038
|
+
return candidates.find((p) => fs15.existsSync(p)) ?? null;
|
|
3039
|
+
}
|
|
3040
|
+
function showWelcome() {
|
|
3041
|
+
return new Promise((resolve) => {
|
|
3042
|
+
const bin = resolveBinary2();
|
|
3043
|
+
if (!bin) {
|
|
3044
|
+
printFallback();
|
|
3045
|
+
return resolve();
|
|
3046
|
+
}
|
|
3047
|
+
const child2 = spawn10(bin, [], { stdio: "inherit" });
|
|
3048
|
+
child2.on("error", () => {
|
|
3049
|
+
printFallback();
|
|
3050
|
+
resolve();
|
|
3051
|
+
});
|
|
3052
|
+
child2.on("close", () => resolve());
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
async function maybeFirstRunWelcome() {
|
|
3056
|
+
if (fs15.existsSync(WELCOMED_MARKER)) return;
|
|
3057
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) return;
|
|
3058
|
+
try {
|
|
3059
|
+
fs15.writeFileSync(WELCOMED_MARKER, (/* @__PURE__ */ new Date()).toISOString());
|
|
3060
|
+
} catch {
|
|
3061
|
+
}
|
|
3062
|
+
await showWelcome();
|
|
3063
|
+
}
|
|
3064
|
+
function printFallback() {
|
|
3065
|
+
const out = `
|
|
3066
|
+
\u{1F338} Sakura \u2014 your coding agents, in your pocket
|
|
3067
|
+
|
|
3068
|
+
Two ways your phone reaches this Mac:
|
|
3069
|
+
|
|
3070
|
+
1 Home Wi-Fi (same network, simple)
|
|
3071
|
+
Phone \u2500\u2500Wi-Fi\u2500\u2500> Mac (192.168.x.x)
|
|
3072
|
+
\u2022 sakura daemon start
|
|
3073
|
+
\u2022 paste the URL + token in the app (or scan the QR)
|
|
3074
|
+
\u2022 works only while both are on the same Wi-Fi
|
|
3075
|
+
|
|
3076
|
+
2 Tailscale (any network / mobile data, private)
|
|
3077
|
+
Phone \u2550tunnel\u2550> private mesh \u2550tunnel\u2550> Mac (100.x)
|
|
3078
|
+
\u2022 sakura login (links this Mac to your account)
|
|
3079
|
+
\u2022 sakura daemon start (joins the tunnel, prints a QR)
|
|
3080
|
+
\u2022 SCAN THE QR in the app (the QR carries the private key)
|
|
3081
|
+
\u2022 no Tailscale app needed \u2014 it's built in
|
|
3082
|
+
|
|
3083
|
+
Pasting the URL by hand can't carry the tunnel key, so paste is
|
|
3084
|
+
same-Wi-Fi only. Scan the QR to connect from anywhere.
|
|
3085
|
+
|
|
3086
|
+
Docs: https://www.sakura.mom \xB7 Show again: sakura welcome
|
|
3087
|
+
`;
|
|
3088
|
+
process.stdout.write(out + "\n");
|
|
3089
|
+
}
|
|
3090
|
+
function registerWelcome(program) {
|
|
3091
|
+
program.command("welcome").aliases(["guide"]).description("Show the Sakura connection guide (Home Wi-Fi vs Tailscale)").action(async () => {
|
|
3092
|
+
await showWelcome();
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
// src/index.ts
|
|
2894
3097
|
init_manager();
|
|
2895
3098
|
async function main() {
|
|
2896
3099
|
ensureSakuraDirs();
|
|
@@ -2898,6 +3101,10 @@ async function main() {
|
|
|
2898
3101
|
await runServer(VERSION);
|
|
2899
3102
|
return;
|
|
2900
3103
|
}
|
|
3104
|
+
const sub = process.argv[2];
|
|
3105
|
+
if (sub !== "welcome" && sub !== "guide") {
|
|
3106
|
+
await maybeFirstRunWelcome();
|
|
3107
|
+
}
|
|
2901
3108
|
const program = new Command();
|
|
2902
3109
|
program.name("sakuraai").description(
|
|
2903
3110
|
"Sakura Agent CLI \u2014 manage local AI coding sessions and reach them from the Sakura app over Tailscale"
|
|
@@ -2908,6 +3115,7 @@ async function main() {
|
|
|
2908
3115
|
registerRegistry(program);
|
|
2909
3116
|
registerConnectivity(program);
|
|
2910
3117
|
registerPair(program);
|
|
3118
|
+
registerWelcome(program);
|
|
2911
3119
|
program.showHelpAfterError("(add --help for usage)");
|
|
2912
3120
|
await program.parseAsync(process.argv);
|
|
2913
3121
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sakuraai",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"description": "Sakura Agent CLI + local runtime for managing AI coding sessions (Claude Code, Codex, OpenCode) and reaching them privately from the Sakura mobile app over Tailscale",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"dist",
|
|
29
29
|
"!dist/**/*.map",
|
|
30
30
|
"scripts/download-tsnet.js",
|
|
31
|
+
"scripts/download-onboard.js",
|
|
31
32
|
"README.md"
|
|
32
33
|
],
|
|
33
34
|
"scripts": {
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
"dev": "tsx src/index.ts",
|
|
37
38
|
"clean": "rimraf dist",
|
|
38
39
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"postinstall": "node scripts/download-tsnet.js",
|
|
40
|
+
"postinstall": "node scripts/download-tsnet.js && node scripts/download-onboard.js",
|
|
40
41
|
"prepublishOnly": "npm run clean && npm run build"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// postinstall: fetch the prebuilt `sakura-onboard` bubbletea welcome UI for this
|
|
2
|
+
// OS/arch from the public Nishu0/sakura-tsnet Release matching this package
|
|
3
|
+
// version. Best-effort by design — if anything fails (offline, unsupported
|
|
4
|
+
// platform, missing asset) we exit 0 so `npm install` never breaks; the CLI just
|
|
5
|
+
// prints a plain-text guide instead of the interactive one.
|
|
6
|
+
//
|
|
7
|
+
// Skips: SAKURA_SKIP_ONBOARD_DOWNLOAD=1, SAKURA_ONBOARD_BIN set, or already present.
|
|
8
|
+
// Override source: SAKURA_ONBOARD_TAG, SAKURA_ONBOARD_BASE_URL.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import https from "node:https";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
|
|
17
|
+
|
|
18
|
+
const PLAT = { darwin: "darwin", linux: "linux", win32: "windows" }[process.platform];
|
|
19
|
+
const ARCH = { x64: "amd64", arm64: "arm64" }[process.arch];
|
|
20
|
+
const isWin = process.platform === "win32";
|
|
21
|
+
|
|
22
|
+
const binDir = path.join(here, "..", "bin");
|
|
23
|
+
const outPath = path.join(binDir, isWin ? "sakura-onboard.exe" : "sakura-onboard");
|
|
24
|
+
|
|
25
|
+
if (process.env.SAKURA_SKIP_ONBOARD_DOWNLOAD === "1" || process.env.SAKURA_ONBOARD_BIN) process.exit(0);
|
|
26
|
+
if (fs.existsSync(outPath)) process.exit(0);
|
|
27
|
+
if (!PLAT || !ARCH) process.exit(0);
|
|
28
|
+
|
|
29
|
+
const asset = `sakura-onboard-${PLAT}-${ARCH}${isWin ? ".exe" : ""}`;
|
|
30
|
+
const tag = process.env.SAKURA_ONBOARD_TAG || `v${pkg.version}`;
|
|
31
|
+
const base = process.env.SAKURA_ONBOARD_BASE_URL || `https://github.com/Nishu0/sakura-tsnet/releases/download/${tag}`;
|
|
32
|
+
const url = `${base}/${asset}`;
|
|
33
|
+
|
|
34
|
+
function download(u, redirectsLeft = 5) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
https
|
|
37
|
+
.get(u, { headers: { "User-Agent": "sakuraai-postinstall" } }, (res) => {
|
|
38
|
+
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
39
|
+
if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
|
|
40
|
+
res.resume();
|
|
41
|
+
return resolve(download(res.headers.location, redirectsLeft - 1));
|
|
42
|
+
}
|
|
43
|
+
if (res.statusCode !== 200) {
|
|
44
|
+
res.resume();
|
|
45
|
+
return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
|
|
46
|
+
}
|
|
47
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
48
|
+
const tmp = `${outPath}.download`;
|
|
49
|
+
const file = fs.createWriteStream(tmp);
|
|
50
|
+
res.pipe(file);
|
|
51
|
+
file.on("finish", () => file.close(() => {
|
|
52
|
+
fs.renameSync(tmp, outPath);
|
|
53
|
+
if (!isWin) fs.chmodSync(outPath, 0o755);
|
|
54
|
+
resolve();
|
|
55
|
+
}));
|
|
56
|
+
file.on("error", reject);
|
|
57
|
+
})
|
|
58
|
+
.on("error", reject);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await download(url);
|
|
64
|
+
console.log(`[sakura] installed welcome guide (${asset}). Run \`sakura welcome\` any time.`);
|
|
65
|
+
} catch {
|
|
66
|
+
// Silent — the CLI falls back to a plain-text guide.
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|