sakuraai 0.0.8 → 0.0.10

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 CHANGED
@@ -14,6 +14,13 @@ import os from "os";
14
14
  import path from "path";
15
15
  import fs from "fs";
16
16
  import { randomUUID } from "crypto";
17
+ function readTsnetState() {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(TSNET_PATH, "utf8"));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
17
24
  function ensureDir(dir) {
18
25
  fs.mkdirSync(dir, { recursive: true });
19
26
  }
@@ -74,7 +81,7 @@ function ensureSakuraDirs() {
74
81
  ensureDir(SAKURA_DIR);
75
82
  ensureDir(LOG_DIR);
76
83
  }
77
- var SAKURA_DIR, CONFIG_PATH, AUTH_PATH, DAEMON_PATH, PID_PATH, LOG_DIR, LOG_PATH, DEFAULT_PORT, DEFAULT_HOST, _config;
84
+ var SAKURA_DIR, CONFIG_PATH, AUTH_PATH, DAEMON_PATH, PID_PATH, LOG_DIR, LOG_PATH, DEFAULT_PORT, DEFAULT_HOST, TSNET_PATH, _config;
78
85
  var init_config = __esm({
79
86
  "src/config.ts"() {
80
87
  "use strict";
@@ -87,38 +94,134 @@ var init_config = __esm({
87
94
  LOG_PATH = path.join(LOG_DIR, "daemon.log");
88
95
  DEFAULT_PORT = Number(process.env.SAKURA_PORT ?? 4787);
89
96
  DEFAULT_HOST = process.env.SAKURA_HOST ?? "127.0.0.1";
97
+ TSNET_PATH = path.join(SAKURA_DIR, "tsnet.json");
90
98
  _config = null;
91
99
  }
92
100
  });
93
101
 
102
+ // src/cloud.ts
103
+ function apiBase() {
104
+ return (loadAuth().serverUrl || DEFAULT_API_URL).replace(/\/+$/, "");
105
+ }
106
+ async function postJson(base, path10, body, token) {
107
+ const res = await fetch(`${base}${path10}`, {
108
+ method: "POST",
109
+ headers: {
110
+ "Content-Type": "application/json",
111
+ ...token ? { Authorization: `Bearer ${token}` } : {}
112
+ },
113
+ body: JSON.stringify(body ?? {})
114
+ });
115
+ if (!res.ok) {
116
+ const text = await res.text().catch(() => "");
117
+ throw new Error(`${path10} \u2192 ${res.status} ${res.statusText}${text ? `: ${text}` : ""}`);
118
+ }
119
+ return await res.json();
120
+ }
121
+ function deviceStart(base, machineName) {
122
+ return postJson(base, "/cli/device/start", { machineName });
123
+ }
124
+ function devicePoll(base, deviceCode) {
125
+ return postJson(base, "/cli/device/poll", { deviceCode });
126
+ }
127
+ function provisionTailnet(base, serverToken, hostname2) {
128
+ return postJson(base, "/tailnet/provision", { hostname: hostname2 }, serverToken);
129
+ }
130
+ var DEFAULT_API_URL;
131
+ var init_cloud = __esm({
132
+ "src/cloud.ts"() {
133
+ "use strict";
134
+ init_config();
135
+ DEFAULT_API_URL = process.env.SAKURA_SERVER_URL || "https://api.sakura.mom";
136
+ }
137
+ });
138
+
94
139
  // src/auth.ts
95
140
  var auth_exports = {};
96
141
  __export(auth_exports, {
142
+ getServerToken: () => getServerToken,
97
143
  getToken: () => getToken,
98
144
  isLoggedIn: () => isLoggedIn,
99
145
  login: () => login,
146
+ loginInteractive: () => loginInteractive,
100
147
  logout: () => logout,
101
148
  requireToken: () => requireToken
102
149
  });
103
150
  import { randomBytes } from "crypto";
151
+ import { spawn } from "child_process";
104
152
  import os2 from "os";
105
153
  function getToken() {
106
154
  return process.env.SAKURA_AUTH || loadAuth().token;
107
155
  }
156
+ function getServerToken() {
157
+ return process.env.SAKURA_SERVER_TOKEN || loadAuth().serverToken;
158
+ }
108
159
  function isLoggedIn() {
109
160
  return !!getToken();
110
161
  }
111
162
  function generateToken() {
112
163
  return "sk_" + randomBytes(24).toString("hex");
113
164
  }
165
+ function openBrowser(url) {
166
+ try {
167
+ if (process.platform === "darwin") {
168
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
169
+ } else if (process.platform === "win32") {
170
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
171
+ } else {
172
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
173
+ }
174
+ } catch {
175
+ }
176
+ }
114
177
  function login(opts = {}) {
115
- const token = opts.auth?.trim() || generateToken();
178
+ const existing = loadAuth();
116
179
  const machineName = opts.machineName?.trim() || os2.hostname();
117
180
  const state = {
118
- token,
119
- serverUrl: opts.serverUrl,
181
+ ...existing,
182
+ token: opts.auth?.trim() || existing.token || generateToken(),
183
+ serverUrl: opts.serverUrl || existing.serverUrl,
184
+ machineName,
185
+ login: existing.login || process.env.USER || machineName,
186
+ loggedInAt: Date.now()
187
+ };
188
+ saveAuth(state);
189
+ updateConfig((cfg) => {
190
+ cfg.machineName = machineName;
191
+ });
192
+ return state;
193
+ }
194
+ async function loginInteractive(opts = {}, cb = {}) {
195
+ const base = (opts.serverUrl || DEFAULT_API_URL).replace(/\/+$/, "");
196
+ const machineName = opts.machineName?.trim() || os2.hostname();
197
+ const start2 = await deviceStart(base, machineName);
198
+ cb.onPrompt?.({ verifyUrl: start2.verifyUrl, userCode: start2.userCode });
199
+ openBrowser(start2.verifyUrl);
200
+ const deadline = Date.now() + start2.expiresIn * 1e3;
201
+ const intervalMs = Math.max(1, start2.interval) * 1e3;
202
+ let approved = null;
203
+ while (Date.now() < deadline) {
204
+ await sleep(intervalMs);
205
+ const poll = await devicePoll(base, start2.deviceCode);
206
+ if (poll.status === "approved") {
207
+ approved = poll;
208
+ break;
209
+ }
210
+ if (poll.status === "expired" || poll.status === "unknown") {
211
+ throw new Error("login code expired \u2014 run `sakura login` again.");
212
+ }
213
+ }
214
+ if (!approved?.token) throw new Error("login timed out \u2014 run `sakura login` again.");
215
+ const existing = loadAuth();
216
+ const state = {
217
+ ...existing,
218
+ token: existing.token || generateToken(),
219
+ // keep the phone↔daemon secret
220
+ serverToken: approved.token,
221
+ serverUrl: base,
222
+ userId: approved.user?._id,
223
+ login: approved.user?.login || approved.user?.email || machineName,
120
224
  machineName,
121
- login: process.env.USER || machineName,
122
225
  loggedInAt: Date.now()
123
226
  };
124
227
  saveAuth(state);
@@ -134,15 +237,18 @@ function requireToken() {
134
237
  const t = getToken();
135
238
  if (!t) {
136
239
  throw new Error(
137
- "Not signed in. Run `sakuraai login` (or `sakuraai login --auth <token>`) first."
240
+ "Not signed in. Run `sakura login` first."
138
241
  );
139
242
  }
140
243
  return t;
141
244
  }
245
+ var sleep;
142
246
  var init_auth = __esm({
143
247
  "src/auth.ts"() {
144
248
  "use strict";
145
249
  init_config();
250
+ init_cloud();
251
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms));
146
252
  }
147
253
  });
148
254
 
@@ -296,7 +402,7 @@ var init_util = __esm({
296
402
  // src/runtime/claude.ts
297
403
  import fs3 from "fs";
298
404
  import path2 from "path";
299
- import { spawn } from "child_process";
405
+ import { spawn as spawn2 } from "child_process";
300
406
  function* jsonlEntries(file) {
301
407
  let content;
302
408
  try {
@@ -440,7 +546,7 @@ function send(sessionId, message, onData, images) {
440
546
 
441
547
  ${refs}` : refs;
442
548
  }
443
- const child = spawn(
549
+ const child2 = spawn2(
444
550
  bin,
445
551
  [
446
552
  "--resume",
@@ -460,7 +566,7 @@ ${refs}` : refs;
460
566
  let buf = "";
461
567
  let finalText = "";
462
568
  let err = "";
463
- child.stdout.on("data", (d) => {
569
+ child2.stdout.on("data", (d) => {
464
570
  buf += d.toString();
465
571
  let nl;
466
572
  while ((nl = buf.indexOf("\n")) >= 0) {
@@ -477,9 +583,9 @@ ${refs}` : refs;
477
583
  }
478
584
  }
479
585
  });
480
- child.stderr.on("data", (d) => err += d.toString());
481
- child.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
482
- child.on(
586
+ child2.stderr.on("data", (d) => err += d.toString());
587
+ child2.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
588
+ child2.on(
483
589
  "close",
484
590
  (code) => resolve({
485
591
  ok: code === 0,
@@ -508,7 +614,7 @@ var init_claude = __esm({
508
614
  // src/runtime/codex.ts
509
615
  import fs4 from "fs";
510
616
  import path3 from "path";
511
- import { spawn as spawn2 } from "child_process";
617
+ import { spawn as spawn3 } from "child_process";
512
618
  function codexBin() {
513
619
  if (process.env.CODEX_BIN) return process.env.CODEX_BIN;
514
620
  if (fs4.existsSync(MAC_APP_BIN)) return MAC_APP_BIN;
@@ -620,19 +726,19 @@ function messages2(sessionId) {
620
726
  function send2(sessionId, message, onData, images) {
621
727
  return new Promise((resolve) => {
622
728
  const imageArgs = (images ?? []).flatMap((p) => ["-i", p]);
623
- const child = spawn2(codexBin(), ["exec", "resume", sessionId, ...imageArgs, message], {
729
+ const child2 = spawn3(codexBin(), ["exec", "resume", sessionId, ...imageArgs, message], {
624
730
  stdio: ["ignore", "pipe", "pipe"],
625
731
  env: process.env
626
732
  });
627
733
  let out = "";
628
734
  let err = "";
629
- child.stdout.on("data", (d) => {
735
+ child2.stdout.on("data", (d) => {
630
736
  out += d.toString();
631
737
  onData?.(d.toString());
632
738
  });
633
- child.stderr.on("data", (d) => err += d.toString());
634
- child.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
635
- child.on(
739
+ child2.stderr.on("data", (d) => err += d.toString());
740
+ child2.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
741
+ child2.on(
636
742
  "close",
637
743
  (code) => resolve({
638
744
  ok: code === 0,
@@ -657,7 +763,7 @@ var init_codex = __esm({
657
763
  // src/runtime/opencode.ts
658
764
  import path4 from "path";
659
765
  import fs5 from "fs";
660
- import { spawn as spawn3 } from "child_process";
766
+ import { spawn as spawn4 } from "child_process";
661
767
  function opencodeBin() {
662
768
  if (process.env.OPENCODE_BIN) return process.env.OPENCODE_BIN;
663
769
  if (fs5.existsSync(MAC_APP_BIN2)) return MAC_APP_BIN2;
@@ -767,19 +873,19 @@ async function messages3(sessionId) {
767
873
  function send3(sessionId, message, onData, images) {
768
874
  return new Promise((resolve) => {
769
875
  const fileArgs = (images ?? []).flatMap((p) => ["-f", p]);
770
- const child = spawn3(opencodeBin(), ["run", "-s", sessionId, ...fileArgs, message], {
876
+ const child2 = spawn4(opencodeBin(), ["run", "-s", sessionId, ...fileArgs, message], {
771
877
  stdio: ["ignore", "pipe", "pipe"],
772
878
  env: process.env
773
879
  });
774
880
  let out = "";
775
881
  let err = "";
776
- child.stdout.on("data", (d) => {
882
+ child2.stdout.on("data", (d) => {
777
883
  out += d.toString();
778
884
  onData?.(d.toString());
779
885
  });
780
- child.stderr.on("data", (d) => err += d.toString());
781
- child.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
782
- child.on(
886
+ child2.stderr.on("data", (d) => err += d.toString());
887
+ child2.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
888
+ child2.on(
783
889
  "close",
784
890
  (code) => resolve({
785
891
  ok: code === 0,
@@ -837,7 +943,7 @@ var init_meta = __esm({
837
943
  });
838
944
 
839
945
  // src/runtime/sessions.ts
840
- import { spawn as spawn4 } from "child_process";
946
+ import { spawn as spawn5 } from "child_process";
841
947
  async function list(opts = {}) {
842
948
  let sessions = [];
843
949
  if (!opts.agent || opts.agent === "claude") sessions.push(...listSessions());
@@ -918,16 +1024,16 @@ function create(prompt, opts = {}) {
918
1024
  }
919
1025
  return new Promise((resolve) => {
920
1026
  const bin = process.env.CLAUDE_BIN || "claude";
921
- const child = spawn4(bin, ["-p", prompt], {
1027
+ const child2 = spawn5(bin, ["-p", prompt], {
922
1028
  cwd: opts.cwd || process.cwd(),
923
1029
  stdio: ["ignore", "pipe", "pipe"]
924
1030
  });
925
1031
  let out = "";
926
1032
  let err = "";
927
- child.stdout.on("data", (d) => out += d.toString());
928
- child.stderr.on("data", (d) => err += d.toString());
929
- child.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
930
- child.on(
1033
+ child2.stdout.on("data", (d) => out += d.toString());
1034
+ child2.stderr.on("data", (d) => err += d.toString());
1035
+ child2.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
1036
+ child2.on(
931
1037
  "close",
932
1038
  (code) => resolve({
933
1039
  ok: code === 0,
@@ -1289,7 +1395,7 @@ var init_fsops = __esm({
1289
1395
 
1290
1396
  // src/tailscale.ts
1291
1397
  import fs9 from "fs";
1292
- import { spawn as spawn5 } from "child_process";
1398
+ import { spawn as spawn6 } from "child_process";
1293
1399
  function tsBin() {
1294
1400
  const macPath = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
1295
1401
  if (fs9.existsSync(macPath)) return macPath;
@@ -1297,11 +1403,11 @@ function tsBin() {
1297
1403
  }
1298
1404
  function runTs(args, timeoutMs = 6e4) {
1299
1405
  return new Promise((resolve) => {
1300
- const child = spawn5(tsBin(), args, { stdio: ["ignore", "pipe", "pipe"] });
1406
+ const child2 = spawn6(tsBin(), args, { stdio: ["ignore", "pipe", "pipe"] });
1301
1407
  let out = "";
1302
1408
  let err = "";
1303
1409
  const timer = setTimeout(() => {
1304
- child.kill("SIGKILL");
1410
+ child2.kill("SIGKILL");
1305
1411
  resolve({
1306
1412
  ok: false,
1307
1413
  data: null,
@@ -1310,9 +1416,9 @@ function runTs(args, timeoutMs = 6e4) {
1310
1416
  code: "timeout"
1311
1417
  });
1312
1418
  }, timeoutMs);
1313
- child.stdout.on("data", (d) => out += d.toString());
1314
- child.stderr.on("data", (d) => err += d.toString());
1315
- child.on("error", (e) => {
1419
+ child2.stdout.on("data", (d) => out += d.toString());
1420
+ child2.stderr.on("data", (d) => err += d.toString());
1421
+ child2.on("error", (e) => {
1316
1422
  clearTimeout(timer);
1317
1423
  if (e.code === "ENOENT") {
1318
1424
  resolve({
@@ -1332,7 +1438,7 @@ function runTs(args, timeoutMs = 6e4) {
1332
1438
  });
1333
1439
  }
1334
1440
  });
1335
- child.on("close", (code) => {
1441
+ child2.on("close", (code) => {
1336
1442
  clearTimeout(timer);
1337
1443
  const stdout = out.trim();
1338
1444
  const stderr = err.trim();
@@ -1506,7 +1612,7 @@ var init_stream = __esm({
1506
1612
 
1507
1613
  // src/daemon/terminal.ts
1508
1614
  import os6 from "os";
1509
- import { spawn as spawn6 } from "child_process";
1615
+ import { spawn as spawn7 } from "child_process";
1510
1616
  function cleanOutput(t, chunk) {
1511
1617
  let s = t.buf + chunk;
1512
1618
  s = s.replace(OSC, "").replace(CSI, "").replace(ESC2, "");
@@ -1524,7 +1630,7 @@ function defaultShell() {
1524
1630
  }
1525
1631
  function openTerminal(ws, opts) {
1526
1632
  const shell = defaultShell();
1527
- const child = spawn6(shell, ["-i"], {
1633
+ const child2 = spawn7(shell, ["-i"], {
1528
1634
  cwd: opts?.cwd && opts.cwd.trim() ? opts.cwd : os6.homedir(),
1529
1635
  env: {
1530
1636
  ...process.env,
@@ -1534,7 +1640,7 @@ function openTerminal(ws, opts) {
1534
1640
  ITERM_SHELL_INTEGRATION_INSTALLED: ""
1535
1641
  }
1536
1642
  });
1537
- const term = { child, buf: "" };
1643
+ const term = { child: child2, buf: "" };
1538
1644
  terminals.set(ws, term);
1539
1645
  const sendOut = (raw) => {
1540
1646
  const data = cleanOutput(term, raw);
@@ -1544,15 +1650,15 @@ function openTerminal(ws, opts) {
1544
1650
  } catch {
1545
1651
  }
1546
1652
  };
1547
- child.stdout.on("data", (d) => sendOut(d.toString()));
1548
- child.stderr.on("data", (d) => sendOut(d.toString()));
1549
- child.on("exit", (code) => {
1653
+ child2.stdout.on("data", (d) => sendOut(d.toString()));
1654
+ child2.stderr.on("data", (d) => sendOut(d.toString()));
1655
+ child2.on("exit", (code) => {
1550
1656
  try {
1551
1657
  ws.send(JSON.stringify({ type: "exit", code }));
1552
1658
  } catch {
1553
1659
  }
1554
1660
  });
1555
- child.on("error", (e) => sendOut(`
1661
+ child2.on("error", (e) => sendOut(`
1556
1662
  [shell error: ${e.message}]
1557
1663
  `));
1558
1664
  try {
@@ -1597,10 +1703,10 @@ import os7 from "os";
1597
1703
  import path8 from "path";
1598
1704
  import { URL } from "url";
1599
1705
  import { WebSocketServer } from "ws";
1600
- function add(method, path9, handler) {
1706
+ function add(method, path10, handler) {
1601
1707
  const keys = [];
1602
1708
  const pattern = new RegExp(
1603
- "^" + path9.replace(/:[^/]+/g, (m) => {
1709
+ "^" + path10.replace(/:[^/]+/g, (m) => {
1604
1710
  keys.push(m.slice(1));
1605
1711
  return "([^/]+)";
1606
1712
  }) + "/?$"
@@ -2024,12 +2130,121 @@ var init_server = __esm({
2024
2130
  }
2025
2131
  });
2026
2132
 
2027
- // src/daemon/manager.ts
2133
+ // src/daemon/tsnet.ts
2028
2134
  import fs11 from "fs";
2029
- import { spawn as spawn7 } from "child_process";
2135
+ import path9 from "path";
2136
+ import { fileURLToPath } from "url";
2137
+ import { spawn as spawn8 } from "child_process";
2138
+ function resolveBinary() {
2139
+ const envBin = process.env.SAKURA_TSNET_BIN;
2140
+ if (envBin && fs11.existsSync(envBin)) return envBin;
2141
+ const here = path9.dirname(fileURLToPath(import.meta.url));
2142
+ const name = process.platform === "win32" ? "sakura-tsnet.exe" : "sakura-tsnet";
2143
+ const candidates = [
2144
+ path9.join(here, "..", "bin", name),
2145
+ // package/bin (postinstall download / build-sidecar.sh)
2146
+ path9.join(here, "bin", name),
2147
+ path9.join(process.cwd(), "bin", name),
2148
+ path9.join(SAKURA_DIR, name)
2149
+ // user-dropped
2150
+ ];
2151
+ return candidates.find((p) => fs11.existsSync(p)) ?? null;
2152
+ }
2153
+ function startTsnet(port2) {
2154
+ const cfg = loadConfig();
2155
+ const ts = cfg.tailscale;
2156
+ if (!ts?.controlUrl || !ts?.authKey) return;
2157
+ if (child) return;
2158
+ const bin = resolveBinary();
2159
+ if (!bin) {
2160
+ log.warn(
2161
+ "embedded Tailscale is configured but the sakura-tsnet binary was not found \u2014 build it with modules/sakura-tailscale/scripts/build-sidecar.sh (LAN access still works)."
2162
+ );
2163
+ return;
2164
+ }
2165
+ stopping = false;
2166
+ spawnSidecar(bin, ts.controlUrl, ts.authKey, ts.hostname || cfg.machineName, port2);
2167
+ }
2168
+ function spawnSidecar(bin, controlUrl, authKey, hostname2, port2) {
2169
+ const env = {
2170
+ ...process.env,
2171
+ SAKURA_TS_CONTROL_URL: controlUrl,
2172
+ SAKURA_TS_AUTH_KEY: authKey,
2173
+ SAKURA_TS_HOSTNAME: hostname2,
2174
+ SAKURA_TS_STATE_DIR: path9.join(SAKURA_DIR, "tsnet-state"),
2175
+ SAKURA_TS_STATE_FILE: TSNET_PATH,
2176
+ SAKURA_TS_PORT: String(port2)
2177
+ };
2178
+ log.info(`starting embedded Tailscale sidecar (${path9.basename(bin)})`);
2179
+ const c = spawn8(bin, [], { stdio: ["ignore", "pipe", "pipe"], env });
2180
+ child = c;
2181
+ const pipe = (buf) => buf.toString().split("\n").filter(Boolean).forEach((line) => log.info(`[tsnet] ${line}`));
2182
+ c.stdout?.on("data", pipe);
2183
+ c.stderr?.on("data", pipe);
2184
+ c.on("exit", (code) => {
2185
+ child = null;
2186
+ if (stopping) return;
2187
+ log.warn(`tsnet sidecar exited (code ${code}); restarting in 5s`);
2188
+ restartTimer = setTimeout(() => {
2189
+ if (!stopping) spawnSidecar(bin, controlUrl, authKey, hostname2, port2);
2190
+ }, 5e3);
2191
+ });
2192
+ }
2193
+ function stopTsnet() {
2194
+ stopping = true;
2195
+ if (restartTimer) {
2196
+ clearTimeout(restartTimer);
2197
+ restartTimer = null;
2198
+ }
2199
+ if (child) {
2200
+ try {
2201
+ child.kill("SIGTERM");
2202
+ } catch {
2203
+ }
2204
+ child = null;
2205
+ }
2206
+ try {
2207
+ fs11.rmSync(TSNET_PATH, { force: true });
2208
+ } catch {
2209
+ }
2210
+ }
2211
+ var child, stopping, restartTimer;
2212
+ var init_tsnet = __esm({
2213
+ "src/daemon/tsnet.ts"() {
2214
+ "use strict";
2215
+ init_config();
2216
+ init_logger();
2217
+ child = null;
2218
+ stopping = false;
2219
+ restartTimer = null;
2220
+ }
2221
+ });
2222
+
2223
+ // src/daemon/manager.ts
2224
+ import fs12 from "fs";
2225
+ import { spawn as spawn9 } from "child_process";
2226
+ async function ensureTailnetProvisioned() {
2227
+ const serverToken = getServerToken();
2228
+ if (!serverToken) return;
2229
+ try {
2230
+ const cfg = loadConfig();
2231
+ const prov = await provisionTailnet(apiBase(), serverToken, cfg.machineName);
2232
+ if (!prov?.controlUrl || !prov?.authKey) return;
2233
+ updateConfig((c) => {
2234
+ c.tailscale = {
2235
+ controlUrl: prov.controlUrl,
2236
+ authKey: prov.authKey,
2237
+ hostname: prov.hostname || c.machineName
2238
+ };
2239
+ });
2240
+ log.info("provisioned embedded-Tailscale credentials for this account");
2241
+ } catch (e) {
2242
+ log.warn(`tailnet provisioning skipped (${e.message}) \u2014 serving over LAN`);
2243
+ }
2244
+ }
2030
2245
  function readDaemonInfo() {
2031
2246
  try {
2032
- return JSON.parse(fs11.readFileSync(DAEMON_PATH, "utf8"));
2247
+ return JSON.parse(fs12.readFileSync(DAEMON_PATH, "utf8"));
2033
2248
  } catch {
2034
2249
  return null;
2035
2250
  }
@@ -2046,8 +2261,8 @@ function running() {
2046
2261
  const info2 = readDaemonInfo();
2047
2262
  if (info2 && pidAlive(info2.pid)) return info2;
2048
2263
  if (info2) {
2049
- fs11.rmSync(DAEMON_PATH, { force: true });
2050
- fs11.rmSync(PID_PATH, { force: true });
2264
+ fs12.rmSync(DAEMON_PATH, { force: true });
2265
+ fs12.rmSync(PID_PATH, { force: true });
2051
2266
  }
2052
2267
  return null;
2053
2268
  }
@@ -2070,17 +2285,20 @@ async function runServer(version) {
2070
2285
  url: `http://${host}:${port2}`,
2071
2286
  startedAt: Date.now()
2072
2287
  };
2073
- fs11.writeFileSync(DAEMON_PATH, JSON.stringify(info2, null, 2));
2074
- fs11.writeFileSync(PID_PATH, String(process.pid));
2288
+ fs12.writeFileSync(DAEMON_PATH, JSON.stringify(info2, null, 2));
2289
+ fs12.writeFileSync(PID_PATH, String(process.pid));
2290
+ await ensureTailnetProvisioned();
2291
+ startTsnet(port2);
2075
2292
  const tailnet = await discoverBaseUrl(port2).catch(() => null);
2076
2293
  log.info(`sakuraai runtime listening on http://0.0.0.0:${port2}`);
2077
2294
  if (tailnet) log.info(`reachable over Tailscale at ${tailnet}`);
2078
2295
  log.info("point the Sakura app at the tailnet URL above (Settings \u2192 Connectivity)");
2079
2296
  const shutdown = () => {
2080
2297
  log.info("shutting down");
2298
+ stopTsnet();
2081
2299
  server.close();
2082
- fs11.rmSync(DAEMON_PATH, { force: true });
2083
- fs11.rmSync(PID_PATH, { force: true });
2300
+ fs12.rmSync(DAEMON_PATH, { force: true });
2301
+ fs12.rmSync(PID_PATH, { force: true });
2084
2302
  process.exit(0);
2085
2303
  };
2086
2304
  process.on("SIGINT", shutdown);
@@ -2091,14 +2309,14 @@ function start() {
2091
2309
  if (existing) return existing;
2092
2310
  ensureSakuraDirs();
2093
2311
  const entry = process.argv[1] ?? "";
2094
- const out = fs11.openSync(LOG_PATH, "a");
2095
- const err = fs11.openSync(LOG_PATH, "a");
2096
- const child = spawn7(process.execPath, [entry, "__run-daemon"], {
2312
+ const out = fs12.openSync(LOG_PATH, "a");
2313
+ const err = fs12.openSync(LOG_PATH, "a");
2314
+ const child2 = spawn9(process.execPath, [entry, "__run-daemon"], {
2097
2315
  detached: true,
2098
2316
  stdio: ["ignore", out, err],
2099
2317
  env: process.env
2100
2318
  });
2101
- child.unref();
2319
+ child2.unref();
2102
2320
  const deadline = Date.now() + 5e3;
2103
2321
  while (Date.now() < deadline) {
2104
2322
  const info2 = readDaemonInfo();
@@ -2114,8 +2332,8 @@ function stop() {
2114
2332
  process.kill(info2.pid, "SIGTERM");
2115
2333
  } catch {
2116
2334
  }
2117
- fs11.rmSync(DAEMON_PATH, { force: true });
2118
- fs11.rmSync(PID_PATH, { force: true });
2335
+ fs12.rmSync(DAEMON_PATH, { force: true });
2336
+ fs12.rmSync(PID_PATH, { force: true });
2119
2337
  return true;
2120
2338
  }
2121
2339
  function restart() {
@@ -2125,7 +2343,7 @@ function restart() {
2125
2343
  }
2126
2344
  function logs(lines = 50) {
2127
2345
  try {
2128
- const all2 = fs11.readFileSync(LOG_PATH, "utf8").split("\n");
2346
+ const all2 = fs12.readFileSync(LOG_PATH, "utf8").split("\n");
2129
2347
  return all2.slice(-lines).join("\n");
2130
2348
  } catch {
2131
2349
  return "";
@@ -2138,6 +2356,9 @@ var init_manager = __esm({
2138
2356
  init_logger();
2139
2357
  init_server();
2140
2358
  init_tailscale();
2359
+ init_tsnet();
2360
+ init_auth();
2361
+ init_cloud();
2141
2362
  }
2142
2363
  });
2143
2364
 
@@ -2148,10 +2369,23 @@ async function buildPairing() {
2148
2369
  const token = requireToken();
2149
2370
  const cfg = loadConfig();
2150
2371
  const port2 = Number(process.env.SAKURA_PORT || cfg.daemon.port);
2151
- const tailnet = await discoverBaseUrl(port2).catch(() => null);
2152
- const url = tailnet ?? `http://${localIp()}:${port2}`;
2153
- const deepLink = `sakura://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
2154
- return { url, token, deepLink };
2372
+ const tsnet = readTsnetState();
2373
+ const tsnetUrl = tsnet?.tailscaleIp && tsnet.backendState === "Running" ? `http://${tsnet.tailscaleIp}:${port2}` : null;
2374
+ const cliTailnet = tsnetUrl ? null : await discoverBaseUrl(port2).catch(() => null);
2375
+ const url = tsnetUrl ?? cliTailnet ?? `http://${localIp()}:${port2}`;
2376
+ const params = /* @__PURE__ */ new Map([
2377
+ ["url", url],
2378
+ ["token", token]
2379
+ ]);
2380
+ const ts = cfg.tailscale;
2381
+ const embeddedTailscale = !!(ts?.controlUrl && ts?.authKey);
2382
+ if (embeddedTailscale) {
2383
+ params.set("cn", ts.controlUrl);
2384
+ params.set("tskey", ts.authKey);
2385
+ }
2386
+ const query = [...params].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
2387
+ const deepLink = `sakura://pair?${query}`;
2388
+ return { url, token, deepLink, embeddedTailscale };
2155
2389
  }
2156
2390
  function renderQr(deepLink) {
2157
2391
  return new Promise((resolve) => {
@@ -2193,6 +2427,11 @@ async function showPairing(opts = {}) {
2193
2427
  info(` URL: ${pairing.url}`);
2194
2428
  info(` Token: ${pairing.token}`);
2195
2429
  info("");
2430
+ if (pairing.embeddedTailscale) {
2431
+ info("Embedded Tailscale: the QR also carries the tailnet join key, so the");
2432
+ info("phone connects privately over the tunnel with no Tailscale app needed.");
2433
+ info("");
2434
+ }
2196
2435
  info("Or paste the token into the app (Settings \u2192 Connect).");
2197
2436
  }
2198
2437
  function registerPair(program) {
@@ -2222,7 +2461,7 @@ var init_pair = __esm({
2222
2461
  import { Command } from "commander";
2223
2462
 
2224
2463
  // src/version.ts
2225
- var VERSION = "0.0.6";
2464
+ var VERSION = "0.0.10";
2226
2465
 
2227
2466
  // src/index.ts
2228
2467
  init_config();
@@ -2232,21 +2471,39 @@ init_auth();
2232
2471
  init_config();
2233
2472
  init_output();
2234
2473
  function registerAuth(program) {
2235
- program.command("login").description("Sign in and register this machine (generates a CLI token to pair the app)").option("--auth <token>", "use a pre-created CLI token instead of generating one").option("--server-url <url>", "Sakura server / Convex URL to associate").option("--machine-name <name>", "override the machine name (defaults to hostname)").option("--json", "output as JSON").action((opts) => {
2236
- const state = login({
2237
- auth: opts.auth,
2238
- serverUrl: opts.serverUrl,
2239
- machineName: opts.machineName
2240
- });
2241
- if (opts.json) return printJson(state);
2242
- success(`Signed in as ${state.login} on "${state.machineName}".`);
2243
- info("");
2244
- info("Pair the Sakura mobile app with this token (Settings \u2192 Connectivity):");
2245
- info("");
2246
- info(` ${state.token}`);
2247
- info("");
2248
- info("Then start the runtime so the app can reach this machine:");
2249
- info(" sakuraai daemon start");
2474
+ program.command("login").description("Sign in with your Sakura account (opens a browser) so this machine can join your tailnet").option("--auth <token>", "headless: set a pre-created daemon token instead of signing in").option("--server-url <url>", "override the Sakura cloud API URL").option("--machine-name <name>", "override the machine name (defaults to hostname)").option("--json", "output as JSON").action(async (opts) => {
2475
+ if (opts.auth) {
2476
+ const state = login({ auth: opts.auth, serverUrl: opts.serverUrl, machineName: opts.machineName });
2477
+ if (opts.json) return printJson(state);
2478
+ success(`Daemon token set for "${state.machineName}".`);
2479
+ info("Start the runtime: sakura daemon start");
2480
+ return;
2481
+ }
2482
+ try {
2483
+ const state = await loginInteractive(
2484
+ { serverUrl: opts.serverUrl, machineName: opts.machineName },
2485
+ {
2486
+ onPrompt: ({ verifyUrl, userCode }) => {
2487
+ info("Opening your browser to finish sign-in\u2026");
2488
+ info("");
2489
+ info(` ${verifyUrl}`);
2490
+ info("");
2491
+ info(`If it didn't open, visit the link above and confirm the code: ${userCode}`);
2492
+ info("");
2493
+ info("Waiting for approval\u2026");
2494
+ }
2495
+ }
2496
+ );
2497
+ if (opts.json) return printJson(state);
2498
+ success(`Signed in as ${state.login} on "${state.machineName}".`);
2499
+ info("");
2500
+ info("Start the runtime \u2014 it auto-joins your tailnet, then show the QR:");
2501
+ info(" sakura daemon start");
2502
+ info(" sakura pair");
2503
+ } catch (e) {
2504
+ warn(e?.message || "login failed");
2505
+ process.exitCode = 1;
2506
+ }
2250
2507
  });
2251
2508
  program.command("logout").description("Clear local credentials").action(() => {
2252
2509
  logout();
@@ -2265,7 +2522,25 @@ function registerAuth(program) {
2265
2522
  init_manager();
2266
2523
  init_tailscale();
2267
2524
  init_auth();
2525
+ init_config();
2268
2526
  init_output();
2527
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
2528
+ async function tunnelUrl(port2) {
2529
+ const ts = readTsnetState();
2530
+ if (ts?.tailscaleIp && ts.backendState === "Running") return `http://${ts.tailscaleIp}:${port2}`;
2531
+ return await discoverBaseUrl(port2).catch(() => null);
2532
+ }
2533
+ async function waitForTunnel(timeoutMs) {
2534
+ if (!getServerToken()) return false;
2535
+ const deadline = Date.now() + timeoutMs;
2536
+ while (Date.now() < deadline) {
2537
+ const cfg = loadConfig();
2538
+ const ts = readTsnetState();
2539
+ if (cfg.tailscale?.controlUrl && ts?.tailscaleIp && ts.backendState === "Running") return true;
2540
+ await sleep2(500);
2541
+ }
2542
+ return false;
2543
+ }
2269
2544
  function registerRuntime(program, version) {
2270
2545
  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) => {
2271
2546
  if (opts.auth) {
@@ -2282,9 +2557,11 @@ function registerRuntime(program, version) {
2282
2557
  if (!isLoggedIn()) die("Not signed in. Run `sakuraai login` first.");
2283
2558
  const info0 = start();
2284
2559
  success(`Daemon running (pid ${info0.pid}) on ${info0.url}`);
2285
- const tailnet = await discoverBaseUrl(info0.port).catch(() => null);
2286
- if (!tailnet) {
2287
- warn("Tailscale not detected. Run `sakuraai serve` after `tailscale up` for a private URL.");
2560
+ const ready = await waitForTunnel(2e4);
2561
+ if (ready) {
2562
+ info("Embedded Tailscale tunnel up \u2014 the phone can connect from any network, no Tailscale app needed.");
2563
+ } else if (getServerToken()) {
2564
+ warn("Tunnel not up yet \u2014 pairing over LAN for now. Re-run `sakuraai pair` in a moment for the private URL.");
2288
2565
  }
2289
2566
  const { showPairing: showPairing2 } = await Promise.resolve().then(() => (init_pair(), pair_exports));
2290
2567
  await showPairing2();
@@ -2298,7 +2575,7 @@ function registerRuntime(program, version) {
2298
2575
  });
2299
2576
  d.command("status").description("Show runtime + connection status").option("--json", "output as JSON").action(async (opts) => {
2300
2577
  const i = running();
2301
- const tailnet = i ? await discoverBaseUrl(i.port).catch(() => null) : null;
2578
+ const tailnet = i ? await tunnelUrl(i.port) : null;
2302
2579
  if (opts.json) return printJson({ running: !!i, daemon: i, tailnetBaseUrl: tailnet });
2303
2580
  if (!i) return warn("Daemon is not running. Start it with `sakuraai daemon start`.");
2304
2581
  success(`Daemon running (pid ${i.pid}) on ${i.url}`);
@@ -2313,7 +2590,7 @@ function registerRuntime(program, version) {
2313
2590
  // src/commands/session.ts
2314
2591
  init_sessions();
2315
2592
  init_output();
2316
- import fs12 from "fs";
2593
+ import fs13 from "fs";
2317
2594
  function resolveSessionId(arg) {
2318
2595
  const id = arg || process.env.SAKURA_SESSION_ID;
2319
2596
  if (!id) die("Provide a sessionId (positional or via SAKURA_SESSION_ID).");
@@ -2322,7 +2599,7 @@ function resolveSessionId(arg) {
2322
2599
  async function resolvePrompt(positional, opts) {
2323
2600
  if (positional) return positional;
2324
2601
  if (opts.prompt) return opts.prompt;
2325
- if (opts.promptFile) return fs12.readFileSync(opts.promptFile, "utf8");
2602
+ if (opts.promptFile) return fs13.readFileSync(opts.promptFile, "utf8");
2326
2603
  if (!process.stdin.isTTY) {
2327
2604
  const chunks = [];
2328
2605
  for await (const c of process.stdin) chunks.push(c);
@@ -2550,8 +2827,52 @@ init_output();
2550
2827
  function port() {
2551
2828
  return Number(process.env.SAKURA_PORT || loadConfig().daemon.port);
2552
2829
  }
2830
+ function maskKey(key) {
2831
+ if (key.length <= 14) return "\u2022\u2022\u2022\u2022";
2832
+ return `${key.slice(0, 12)}\u2026${key.slice(-4)}`;
2833
+ }
2834
+ function redact(cfg) {
2835
+ if (!cfg) return cfg;
2836
+ return { ...cfg, authKey: maskKey(cfg.authKey) };
2837
+ }
2553
2838
  function registerConnectivity(program) {
2554
2839
  const ts = program.command("tailscale").alias("ts").description("Tailscale connectivity for private PC \u2194 mobile access");
2840
+ ts.command("init").description("Configure embedded Tailscale (Headscale control URL + reusable key)").requiredOption("--control-url <url>", "Headscale control-plane URL (https://headscale.\u2026)").requiredOption("--auth-key <key>", "reusable pre-auth key (tskey-auth-\u2026)").option("--hostname <name>", "tailnet hostname for this machine").action((opts) => {
2841
+ const controlUrl = String(opts.controlUrl).trim();
2842
+ const authKey = String(opts.authKey).trim();
2843
+ if (!/^https?:\/\//i.test(controlUrl)) die("control URL must start with http(s)://");
2844
+ if (!authKey) die("auth key is required");
2845
+ updateConfig((cfg) => {
2846
+ cfg.tailscale = {
2847
+ controlUrl: controlUrl.replace(/\/+$/, ""),
2848
+ authKey,
2849
+ ...opts.hostname ? { hostname: String(opts.hostname).trim() } : {}
2850
+ };
2851
+ });
2852
+ success("Embedded Tailscale configured.");
2853
+ info(`Control URL: ${controlUrl}`);
2854
+ info("Run `sakuraai daemon restart`, then `sakuraai pair` to show the QR.");
2855
+ });
2856
+ ts.command("config").description("Show the embedded-Tailscale config + live tsnet node state").option("--json", "output as JSON").action((opts) => {
2857
+ const cfg = loadConfig().tailscale ?? null;
2858
+ const node = readTsnetState();
2859
+ if (opts.json) return printJson({ config: redact(cfg), node });
2860
+ if (!cfg) {
2861
+ warn("Embedded Tailscale is not configured. Run `sakuraai tailscale init`.");
2862
+ return;
2863
+ }
2864
+ success("Embedded Tailscale configured.");
2865
+ info(`Control URL: ${cfg.controlUrl}`);
2866
+ info(`Auth key: ${maskKey(cfg.authKey)}`);
2867
+ if (cfg.hostname) info(`Hostname: ${cfg.hostname}`);
2868
+ if (node) {
2869
+ info(`Node state: ${node.backendState}`);
2870
+ if (node.tailscaleIp) info(`Tailnet IP: ${node.tailscaleIp}`);
2871
+ if (node.dnsName) info(`DNS name: ${node.dnsName}`);
2872
+ } else {
2873
+ warn("tsnet sidecar not running \u2014 start it with `sakuraai daemon start`.");
2874
+ }
2875
+ });
2555
2876
  ts.command("status").description("Show tailnet status + the base URL the app should use").option("--json", "output as JSON").action(async (opts) => {
2556
2877
  const r = await status(port());
2557
2878
  if (opts.json) return printJson(r);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sakuraai",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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",
@@ -27,6 +27,7 @@
27
27
  "files": [
28
28
  "dist",
29
29
  "!dist/**/*.map",
30
+ "scripts/download-tsnet.js",
30
31
  "README.md"
31
32
  ],
32
33
  "scripts": {
@@ -35,6 +36,7 @@
35
36
  "dev": "tsx src/index.ts",
36
37
  "clean": "rimraf dist",
37
38
  "typecheck": "tsc --noEmit",
39
+ "postinstall": "node scripts/download-tsnet.js",
38
40
  "prepublishOnly": "npm run clean && npm run build"
39
41
  },
40
42
  "dependencies": {
@@ -0,0 +1,77 @@
1
+ // postinstall: fetch the prebuilt `sakura-tsnet` sidecar for this OS/arch from
2
+ // the GitHub Release matching this package version. Best-effort by design — if
3
+ // anything fails (offline, unsupported platform, missing asset) we print a hint
4
+ // and exit 0, so `npm install` never breaks. The daemon then serves over LAN and
5
+ // `sakura daemon start` logs how to build the binary locally.
6
+ //
7
+ // Skips: SAKURA_SKIP_TSNET_DOWNLOAD=1, SAKURA_TSNET_BIN set, or already present.
8
+ // Override source: SAKURA_TSNET_TAG, SAKURA_TSNET_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-tsnet.exe" : "sakura-tsnet");
24
+
25
+ function skip(msg) {
26
+ if (msg) console.warn(`[sakura] ${msg}`);
27
+ console.warn(
28
+ "[sakura] embedded-Tailscale sidecar not installed — the daemon will still " +
29
+ "work over your LAN. To enable cross-network access, build it with " +
30
+ "modules/sakura-tailscale/scripts/build-sidecar.sh, or set SAKURA_TSNET_BIN.",
31
+ );
32
+ process.exit(0);
33
+ }
34
+
35
+ if (process.env.SAKURA_SKIP_TSNET_DOWNLOAD === "1" || process.env.SAKURA_TSNET_BIN) process.exit(0);
36
+ if (fs.existsSync(outPath)) process.exit(0);
37
+ if (!PLAT || !ARCH) skip(`unsupported platform ${process.platform}/${process.arch}.`);
38
+
39
+ const asset = `sakura-tsnet-${PLAT}-${ARCH}${isWin ? ".exe" : ""}`;
40
+ const tag = process.env.SAKURA_TSNET_TAG || `v${pkg.version}`;
41
+ const base = process.env.SAKURA_TSNET_BASE_URL || `https://github.com/Nishu0/sakura-tsnet/releases/download/${tag}`;
42
+ const url = `${base}/${asset}`;
43
+
44
+ function download(u, redirectsLeft = 5) {
45
+ return new Promise((resolve, reject) => {
46
+ https
47
+ .get(u, { headers: { "User-Agent": "sakuraai-postinstall" } }, (res) => {
48
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
49
+ if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
50
+ res.resume();
51
+ return resolve(download(res.headers.location, redirectsLeft - 1));
52
+ }
53
+ if (res.statusCode !== 200) {
54
+ res.resume();
55
+ return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
56
+ }
57
+ fs.mkdirSync(binDir, { recursive: true });
58
+ const tmp = `${outPath}.download`;
59
+ const file = fs.createWriteStream(tmp);
60
+ res.pipe(file);
61
+ file.on("finish", () => file.close(() => {
62
+ fs.renameSync(tmp, outPath);
63
+ if (!isWin) fs.chmodSync(outPath, 0o755);
64
+ resolve();
65
+ }));
66
+ file.on("error", reject);
67
+ })
68
+ .on("error", reject);
69
+ });
70
+ }
71
+
72
+ try {
73
+ await download(url);
74
+ console.log(`[sakura] installed embedded-Tailscale sidecar (${asset}).`);
75
+ } catch (e) {
76
+ skip(`could not download ${asset} (${e.message}).`);
77
+ }