sakuraai 0.0.7 → 0.0.9

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
  }
@@ -37,7 +44,8 @@ function defaultConfig() {
37
44
  workspaces: [{ id: "local", name: "Local", slug: "local" }],
38
45
  projects: [],
39
46
  agentConfigs: [],
40
- daemon: { host: DEFAULT_HOST, port: DEFAULT_PORT }
47
+ daemon: { host: DEFAULT_HOST, port: DEFAULT_PORT },
48
+ pushTokens: []
41
49
  };
42
50
  }
43
51
  function loadConfig() {
@@ -73,7 +81,7 @@ function ensureSakuraDirs() {
73
81
  ensureDir(SAKURA_DIR);
74
82
  ensureDir(LOG_DIR);
75
83
  }
76
- 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;
77
85
  var init_config = __esm({
78
86
  "src/config.ts"() {
79
87
  "use strict";
@@ -86,38 +94,134 @@ var init_config = __esm({
86
94
  LOG_PATH = path.join(LOG_DIR, "daemon.log");
87
95
  DEFAULT_PORT = Number(process.env.SAKURA_PORT ?? 4787);
88
96
  DEFAULT_HOST = process.env.SAKURA_HOST ?? "127.0.0.1";
97
+ TSNET_PATH = path.join(SAKURA_DIR, "tsnet.json");
89
98
  _config = null;
90
99
  }
91
100
  });
92
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
+
93
139
  // src/auth.ts
94
140
  var auth_exports = {};
95
141
  __export(auth_exports, {
142
+ getServerToken: () => getServerToken,
96
143
  getToken: () => getToken,
97
144
  isLoggedIn: () => isLoggedIn,
98
145
  login: () => login,
146
+ loginInteractive: () => loginInteractive,
99
147
  logout: () => logout,
100
148
  requireToken: () => requireToken
101
149
  });
102
150
  import { randomBytes } from "crypto";
151
+ import { spawn } from "child_process";
103
152
  import os2 from "os";
104
153
  function getToken() {
105
154
  return process.env.SAKURA_AUTH || loadAuth().token;
106
155
  }
156
+ function getServerToken() {
157
+ return process.env.SAKURA_SERVER_TOKEN || loadAuth().serverToken;
158
+ }
107
159
  function isLoggedIn() {
108
160
  return !!getToken();
109
161
  }
110
162
  function generateToken() {
111
163
  return "sk_" + randomBytes(24).toString("hex");
112
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
+ }
113
177
  function login(opts = {}) {
114
- const token = opts.auth?.trim() || generateToken();
178
+ const existing = loadAuth();
179
+ const machineName = opts.machineName?.trim() || os2.hostname();
180
+ const state = {
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(/\/+$/, "");
115
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();
116
216
  const state = {
117
- token,
118
- serverUrl: opts.serverUrl,
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,
119
224
  machineName,
120
- login: process.env.USER || machineName,
121
225
  loggedInAt: Date.now()
122
226
  };
123
227
  saveAuth(state);
@@ -133,15 +237,18 @@ function requireToken() {
133
237
  const t = getToken();
134
238
  if (!t) {
135
239
  throw new Error(
136
- "Not signed in. Run `sakuraai login` (or `sakuraai login --auth <token>`) first."
240
+ "Not signed in. Run `sakura login` first."
137
241
  );
138
242
  }
139
243
  return t;
140
244
  }
245
+ var sleep;
141
246
  var init_auth = __esm({
142
247
  "src/auth.ts"() {
143
248
  "use strict";
144
249
  init_config();
250
+ init_cloud();
251
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms));
145
252
  }
146
253
  });
147
254
 
@@ -295,7 +402,7 @@ var init_util = __esm({
295
402
  // src/runtime/claude.ts
296
403
  import fs3 from "fs";
297
404
  import path2 from "path";
298
- import { spawn } from "child_process";
405
+ import { spawn as spawn2 } from "child_process";
299
406
  function* jsonlEntries(file) {
300
407
  let content;
301
408
  try {
@@ -439,7 +546,7 @@ function send(sessionId, message, onData, images) {
439
546
 
440
547
  ${refs}` : refs;
441
548
  }
442
- const child = spawn(
549
+ const child2 = spawn2(
443
550
  bin,
444
551
  [
445
552
  "--resume",
@@ -459,7 +566,7 @@ ${refs}` : refs;
459
566
  let buf = "";
460
567
  let finalText = "";
461
568
  let err = "";
462
- child.stdout.on("data", (d) => {
569
+ child2.stdout.on("data", (d) => {
463
570
  buf += d.toString();
464
571
  let nl;
465
572
  while ((nl = buf.indexOf("\n")) >= 0) {
@@ -476,9 +583,9 @@ ${refs}` : refs;
476
583
  }
477
584
  }
478
585
  });
479
- child.stderr.on("data", (d) => err += d.toString());
480
- child.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
481
- 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(
482
589
  "close",
483
590
  (code) => resolve({
484
591
  ok: code === 0,
@@ -507,7 +614,7 @@ var init_claude = __esm({
507
614
  // src/runtime/codex.ts
508
615
  import fs4 from "fs";
509
616
  import path3 from "path";
510
- import { spawn as spawn2 } from "child_process";
617
+ import { spawn as spawn3 } from "child_process";
511
618
  function codexBin() {
512
619
  if (process.env.CODEX_BIN) return process.env.CODEX_BIN;
513
620
  if (fs4.existsSync(MAC_APP_BIN)) return MAC_APP_BIN;
@@ -619,19 +726,19 @@ function messages2(sessionId) {
619
726
  function send2(sessionId, message, onData, images) {
620
727
  return new Promise((resolve) => {
621
728
  const imageArgs = (images ?? []).flatMap((p) => ["-i", p]);
622
- const child = spawn2(codexBin(), ["exec", "resume", sessionId, ...imageArgs, message], {
729
+ const child2 = spawn3(codexBin(), ["exec", "resume", sessionId, ...imageArgs, message], {
623
730
  stdio: ["ignore", "pipe", "pipe"],
624
731
  env: process.env
625
732
  });
626
733
  let out = "";
627
734
  let err = "";
628
- child.stdout.on("data", (d) => {
735
+ child2.stdout.on("data", (d) => {
629
736
  out += d.toString();
630
737
  onData?.(d.toString());
631
738
  });
632
- child.stderr.on("data", (d) => err += d.toString());
633
- child.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
634
- 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(
635
742
  "close",
636
743
  (code) => resolve({
637
744
  ok: code === 0,
@@ -656,7 +763,7 @@ var init_codex = __esm({
656
763
  // src/runtime/opencode.ts
657
764
  import path4 from "path";
658
765
  import fs5 from "fs";
659
- import { spawn as spawn3 } from "child_process";
766
+ import { spawn as spawn4 } from "child_process";
660
767
  function opencodeBin() {
661
768
  if (process.env.OPENCODE_BIN) return process.env.OPENCODE_BIN;
662
769
  if (fs5.existsSync(MAC_APP_BIN2)) return MAC_APP_BIN2;
@@ -766,19 +873,19 @@ async function messages3(sessionId) {
766
873
  function send3(sessionId, message, onData, images) {
767
874
  return new Promise((resolve) => {
768
875
  const fileArgs = (images ?? []).flatMap((p) => ["-f", p]);
769
- const child = spawn3(opencodeBin(), ["run", "-s", sessionId, ...fileArgs, message], {
876
+ const child2 = spawn4(opencodeBin(), ["run", "-s", sessionId, ...fileArgs, message], {
770
877
  stdio: ["ignore", "pipe", "pipe"],
771
878
  env: process.env
772
879
  });
773
880
  let out = "";
774
881
  let err = "";
775
- child.stdout.on("data", (d) => {
882
+ child2.stdout.on("data", (d) => {
776
883
  out += d.toString();
777
884
  onData?.(d.toString());
778
885
  });
779
- child.stderr.on("data", (d) => err += d.toString());
780
- child.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
781
- 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(
782
889
  "close",
783
890
  (code) => resolve({
784
891
  ok: code === 0,
@@ -836,7 +943,7 @@ var init_meta = __esm({
836
943
  });
837
944
 
838
945
  // src/runtime/sessions.ts
839
- import { spawn as spawn4 } from "child_process";
946
+ import { spawn as spawn5 } from "child_process";
840
947
  async function list(opts = {}) {
841
948
  let sessions = [];
842
949
  if (!opts.agent || opts.agent === "claude") sessions.push(...listSessions());
@@ -917,16 +1024,16 @@ function create(prompt, opts = {}) {
917
1024
  }
918
1025
  return new Promise((resolve) => {
919
1026
  const bin = process.env.CLAUDE_BIN || "claude";
920
- const child = spawn4(bin, ["-p", prompt], {
1027
+ const child2 = spawn5(bin, ["-p", prompt], {
921
1028
  cwd: opts.cwd || process.cwd(),
922
1029
  stdio: ["ignore", "pipe", "pipe"]
923
1030
  });
924
1031
  let out = "";
925
1032
  let err = "";
926
- child.stdout.on("data", (d) => out += d.toString());
927
- child.stderr.on("data", (d) => err += d.toString());
928
- child.on("error", (e) => resolve({ ok: false, output: "", error: e.message }));
929
- 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(
930
1037
  "close",
931
1038
  (code) => resolve({
932
1039
  ok: code === 0,
@@ -946,6 +1053,78 @@ var init_sessions = __esm({
946
1053
  }
947
1054
  });
948
1055
 
1056
+ // src/push.ts
1057
+ function addPushToken(token) {
1058
+ updateConfig((cfg) => {
1059
+ if (!cfg.pushTokens) cfg.pushTokens = [];
1060
+ if (!cfg.pushTokens.includes(token)) cfg.pushTokens.push(token);
1061
+ });
1062
+ }
1063
+ async function sendPush(title, body, data = {}) {
1064
+ const tokens = loadConfig().pushTokens ?? [];
1065
+ if (tokens.length === 0) return;
1066
+ const messages5 = tokens.map((to) => ({ to, title, body, sound: "default", data }));
1067
+ try {
1068
+ const res = await fetch(EXPO_PUSH_URL, {
1069
+ method: "POST",
1070
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
1071
+ body: JSON.stringify(messages5)
1072
+ });
1073
+ if (!res.ok) console.error(`[push] send failed: HTTP ${res.status}`);
1074
+ } catch (e) {
1075
+ console.error("[push] send error:", e);
1076
+ }
1077
+ }
1078
+ function notifyTurnEnd(agent, sessionId, ok2, error, output) {
1079
+ const label = AGENT_LABEL[agent] ?? agent;
1080
+ const data = { sessionId, agent };
1081
+ if (!ok2) {
1082
+ const m2 = pick(ERROR)(label, error);
1083
+ void sendPush(m2.title, m2.body, { ...data, kind: "error" });
1084
+ return;
1085
+ }
1086
+ if (PERMISSION_RE.test(output)) {
1087
+ const m2 = pick(PERMISSION)(label);
1088
+ void sendPush(m2.title, m2.body, { ...data, kind: "permission" });
1089
+ return;
1090
+ }
1091
+ const m = pick(DONE)(label);
1092
+ void sendPush(m.title, m.body, { ...data, kind: "done" });
1093
+ }
1094
+ var EXPO_PUSH_URL, AGENT_LABEL, PERMISSION_RE, pick, DONE, PERMISSION, ERROR;
1095
+ var init_push = __esm({
1096
+ "src/push.ts"() {
1097
+ "use strict";
1098
+ init_config();
1099
+ EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
1100
+ AGENT_LABEL = {
1101
+ claude: "Claude Code",
1102
+ codex: "Codex",
1103
+ opencode: "OpenCode"
1104
+ };
1105
+ PERMISSION_RE = /\b(need|needs|require[sd]?|asking for|waiting for|grant|allow|approve|approval|permission|authoriz)\b.{0,40}\b(permission|approval|access|to run|to proceed|to continue|your ok|confirm)\b/i;
1106
+ pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
1107
+ DONE = [
1108
+ (a) => ({ title: "\u{1F389} Done & dusted", body: `${a} shipped your task. Go peek \u{1F440}` }),
1109
+ (a) => ({ title: "\u2728 Nailed it", body: `${a} just wrapped up \u2014 it's all yours.` }),
1110
+ (a) => ({ title: "\u{1F680} Mission complete", body: `${a} crushed it. Tap to review.` }),
1111
+ (a) => ({ title: "\u{1F3C1} Finished", body: `${a} finished the job \u{1F525}` }),
1112
+ (a) => ({ title: "\u{1F485} Ta-da", body: `${a} is done flexing on your codebase.` })
1113
+ ];
1114
+ PERMISSION = [
1115
+ (a) => ({ title: "\u{1F510} Permission, please", body: `${a} needs your go-ahead to continue.` }),
1116
+ (a) => ({ title: "\u{1F64B} Tap to approve", body: `${a} is standing by for your OK.` }),
1117
+ (a) => ({ title: "\u23F8\uFE0F Hold up", body: `${a} paused \u2014 it wants your blessing first.` }),
1118
+ (a) => ({ title: "\u{1F6A6} Waiting on you", body: `${a} can't proceed without a green light.` })
1119
+ ];
1120
+ ERROR = [
1121
+ (a, e) => ({ title: "\u{1F4A5} Uh oh", body: e ? `${a}: ${e}` : `${a} hit a snag. Tap to see what happened.` }),
1122
+ (a, e) => ({ title: "\u{1FAE0} Something broke", body: e ? `${a} stumbled: ${e}` : `${a} stopped unexpectedly.` }),
1123
+ (a) => ({ title: "\u26A0\uFE0F Agent tripped", body: `${a} ran into an error. Better check on it.` })
1124
+ ];
1125
+ }
1126
+ });
1127
+
949
1128
  // src/runtime/registry.ts
950
1129
  var registry_exports = {};
951
1130
  __export(registry_exports, {
@@ -1216,7 +1395,7 @@ var init_fsops = __esm({
1216
1395
 
1217
1396
  // src/tailscale.ts
1218
1397
  import fs9 from "fs";
1219
- import { spawn as spawn5 } from "child_process";
1398
+ import { spawn as spawn6 } from "child_process";
1220
1399
  function tsBin() {
1221
1400
  const macPath = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
1222
1401
  if (fs9.existsSync(macPath)) return macPath;
@@ -1224,11 +1403,11 @@ function tsBin() {
1224
1403
  }
1225
1404
  function runTs(args, timeoutMs = 6e4) {
1226
1405
  return new Promise((resolve) => {
1227
- const child = spawn5(tsBin(), args, { stdio: ["ignore", "pipe", "pipe"] });
1406
+ const child2 = spawn6(tsBin(), args, { stdio: ["ignore", "pipe", "pipe"] });
1228
1407
  let out = "";
1229
1408
  let err = "";
1230
1409
  const timer = setTimeout(() => {
1231
- child.kill("SIGKILL");
1410
+ child2.kill("SIGKILL");
1232
1411
  resolve({
1233
1412
  ok: false,
1234
1413
  data: null,
@@ -1237,9 +1416,9 @@ function runTs(args, timeoutMs = 6e4) {
1237
1416
  code: "timeout"
1238
1417
  });
1239
1418
  }, timeoutMs);
1240
- child.stdout.on("data", (d) => out += d.toString());
1241
- child.stderr.on("data", (d) => err += d.toString());
1242
- 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) => {
1243
1422
  clearTimeout(timer);
1244
1423
  if (e.code === "ENOENT") {
1245
1424
  resolve({
@@ -1259,7 +1438,7 @@ function runTs(args, timeoutMs = 6e4) {
1259
1438
  });
1260
1439
  }
1261
1440
  });
1262
- child.on("close", (code) => {
1441
+ child2.on("close", (code) => {
1263
1442
  clearTimeout(timer);
1264
1443
  const stdout = out.trim();
1265
1444
  const stderr = err.trim();
@@ -1414,6 +1593,7 @@ async function runTurn(sessionId, agent, prompt, images) {
1414
1593
  const result = await chat(sessionId, prompt, agent, onData, images);
1415
1594
  clearInterval(timer);
1416
1595
  emit({ type: "done", sessionId, agent, ok: result.ok, error: result.error });
1596
+ notifyTurnEnd(agent, sessionId, result.ok, result.error, stdoutAcc);
1417
1597
  return { ok: result.ok, error: result.error };
1418
1598
  }
1419
1599
  var ANSI, stripAnsi, LIVE_ID, POLL_MS;
@@ -1422,6 +1602,7 @@ var init_stream = __esm({
1422
1602
  "use strict";
1423
1603
  init_sessions();
1424
1604
  init_hub();
1605
+ init_push();
1425
1606
  ANSI = /\[[0-9;]*[a-zA-Z]/g;
1426
1607
  stripAnsi = (s) => s.replace(ANSI, "");
1427
1608
  LIVE_ID = "__live__";
@@ -1431,7 +1612,7 @@ var init_stream = __esm({
1431
1612
 
1432
1613
  // src/daemon/terminal.ts
1433
1614
  import os6 from "os";
1434
- import { spawn as spawn6 } from "child_process";
1615
+ import { spawn as spawn7 } from "child_process";
1435
1616
  function cleanOutput(t, chunk) {
1436
1617
  let s = t.buf + chunk;
1437
1618
  s = s.replace(OSC, "").replace(CSI, "").replace(ESC2, "");
@@ -1449,7 +1630,7 @@ function defaultShell() {
1449
1630
  }
1450
1631
  function openTerminal(ws, opts) {
1451
1632
  const shell = defaultShell();
1452
- const child = spawn6(shell, ["-i"], {
1633
+ const child2 = spawn7(shell, ["-i"], {
1453
1634
  cwd: opts?.cwd && opts.cwd.trim() ? opts.cwd : os6.homedir(),
1454
1635
  env: {
1455
1636
  ...process.env,
@@ -1459,7 +1640,7 @@ function openTerminal(ws, opts) {
1459
1640
  ITERM_SHELL_INTEGRATION_INSTALLED: ""
1460
1641
  }
1461
1642
  });
1462
- const term = { child, buf: "" };
1643
+ const term = { child: child2, buf: "" };
1463
1644
  terminals.set(ws, term);
1464
1645
  const sendOut = (raw) => {
1465
1646
  const data = cleanOutput(term, raw);
@@ -1469,15 +1650,15 @@ function openTerminal(ws, opts) {
1469
1650
  } catch {
1470
1651
  }
1471
1652
  };
1472
- child.stdout.on("data", (d) => sendOut(d.toString()));
1473
- child.stderr.on("data", (d) => sendOut(d.toString()));
1474
- 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) => {
1475
1656
  try {
1476
1657
  ws.send(JSON.stringify({ type: "exit", code }));
1477
1658
  } catch {
1478
1659
  }
1479
1660
  });
1480
- child.on("error", (e) => sendOut(`
1661
+ child2.on("error", (e) => sendOut(`
1481
1662
  [shell error: ${e.message}]
1482
1663
  `));
1483
1664
  try {
@@ -1522,10 +1703,10 @@ import os7 from "os";
1522
1703
  import path8 from "path";
1523
1704
  import { URL } from "url";
1524
1705
  import { WebSocketServer } from "ws";
1525
- function add(method, path9, handler) {
1706
+ function add(method, path10, handler) {
1526
1707
  const keys = [];
1527
1708
  const pattern = new RegExp(
1528
- "^" + path9.replace(/:[^/]+/g, (m) => {
1709
+ "^" + path10.replace(/:[^/]+/g, (m) => {
1529
1710
  keys.push(m.slice(1));
1530
1711
  return "([^/]+)";
1531
1712
  }) + "/?$"
@@ -1688,6 +1869,7 @@ var init_server = __esm({
1688
1869
  init_config();
1689
1870
  init_logger();
1690
1871
  init_sessions();
1872
+ init_push();
1691
1873
  init_registry();
1692
1874
  init_fsops();
1693
1875
  init_tailscale();
@@ -1795,6 +1977,16 @@ var init_server = __esm({
1795
1977
  ({ url }) => ok(listMachines(url.searchParams.get("onlineOnly") === "true"))
1796
1978
  );
1797
1979
  add("GET", "/sakura/projects", () => ok(listProjects()));
1980
+ add("POST", "/sakura/push-token", ({ body }) => {
1981
+ const token = String(body?.token ?? "");
1982
+ if (!token) return fail("bad_request", "token required");
1983
+ addPushToken(token);
1984
+ return ok({ registered: true });
1985
+ });
1986
+ add("POST", "/sakura/push-test", async () => {
1987
+ await sendPush("\u{1F338} Sakura says hi", "Push is locked in. Your agents will ping you here.", { kind: "test" });
1988
+ return ok({ sent: true });
1989
+ });
1798
1990
  add("GET", "/sakura/sessions", async ({ url }) => {
1799
1991
  const limit = Number(url.searchParams.get("limit")) || void 0;
1800
1992
  const agent = url.searchParams.get("agent") || void 0;
@@ -1938,12 +2130,121 @@ var init_server = __esm({
1938
2130
  }
1939
2131
  });
1940
2132
 
1941
- // src/daemon/manager.ts
2133
+ // src/daemon/tsnet.ts
1942
2134
  import fs11 from "fs";
1943
- 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
+ }
1944
2245
  function readDaemonInfo() {
1945
2246
  try {
1946
- return JSON.parse(fs11.readFileSync(DAEMON_PATH, "utf8"));
2247
+ return JSON.parse(fs12.readFileSync(DAEMON_PATH, "utf8"));
1947
2248
  } catch {
1948
2249
  return null;
1949
2250
  }
@@ -1960,8 +2261,8 @@ function running() {
1960
2261
  const info2 = readDaemonInfo();
1961
2262
  if (info2 && pidAlive(info2.pid)) return info2;
1962
2263
  if (info2) {
1963
- fs11.rmSync(DAEMON_PATH, { force: true });
1964
- fs11.rmSync(PID_PATH, { force: true });
2264
+ fs12.rmSync(DAEMON_PATH, { force: true });
2265
+ fs12.rmSync(PID_PATH, { force: true });
1965
2266
  }
1966
2267
  return null;
1967
2268
  }
@@ -1984,17 +2285,20 @@ async function runServer(version) {
1984
2285
  url: `http://${host}:${port2}`,
1985
2286
  startedAt: Date.now()
1986
2287
  };
1987
- fs11.writeFileSync(DAEMON_PATH, JSON.stringify(info2, null, 2));
1988
- 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);
1989
2292
  const tailnet = await discoverBaseUrl(port2).catch(() => null);
1990
2293
  log.info(`sakuraai runtime listening on http://0.0.0.0:${port2}`);
1991
2294
  if (tailnet) log.info(`reachable over Tailscale at ${tailnet}`);
1992
2295
  log.info("point the Sakura app at the tailnet URL above (Settings \u2192 Connectivity)");
1993
2296
  const shutdown = () => {
1994
2297
  log.info("shutting down");
2298
+ stopTsnet();
1995
2299
  server.close();
1996
- fs11.rmSync(DAEMON_PATH, { force: true });
1997
- fs11.rmSync(PID_PATH, { force: true });
2300
+ fs12.rmSync(DAEMON_PATH, { force: true });
2301
+ fs12.rmSync(PID_PATH, { force: true });
1998
2302
  process.exit(0);
1999
2303
  };
2000
2304
  process.on("SIGINT", shutdown);
@@ -2005,14 +2309,14 @@ function start() {
2005
2309
  if (existing) return existing;
2006
2310
  ensureSakuraDirs();
2007
2311
  const entry = process.argv[1] ?? "";
2008
- const out = fs11.openSync(LOG_PATH, "a");
2009
- const err = fs11.openSync(LOG_PATH, "a");
2010
- 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"], {
2011
2315
  detached: true,
2012
2316
  stdio: ["ignore", out, err],
2013
2317
  env: process.env
2014
2318
  });
2015
- child.unref();
2319
+ child2.unref();
2016
2320
  const deadline = Date.now() + 5e3;
2017
2321
  while (Date.now() < deadline) {
2018
2322
  const info2 = readDaemonInfo();
@@ -2028,8 +2332,8 @@ function stop() {
2028
2332
  process.kill(info2.pid, "SIGTERM");
2029
2333
  } catch {
2030
2334
  }
2031
- fs11.rmSync(DAEMON_PATH, { force: true });
2032
- fs11.rmSync(PID_PATH, { force: true });
2335
+ fs12.rmSync(DAEMON_PATH, { force: true });
2336
+ fs12.rmSync(PID_PATH, { force: true });
2033
2337
  return true;
2034
2338
  }
2035
2339
  function restart() {
@@ -2039,7 +2343,7 @@ function restart() {
2039
2343
  }
2040
2344
  function logs(lines = 50) {
2041
2345
  try {
2042
- const all2 = fs11.readFileSync(LOG_PATH, "utf8").split("\n");
2346
+ const all2 = fs12.readFileSync(LOG_PATH, "utf8").split("\n");
2043
2347
  return all2.slice(-lines).join("\n");
2044
2348
  } catch {
2045
2349
  return "";
@@ -2052,6 +2356,9 @@ var init_manager = __esm({
2052
2356
  init_logger();
2053
2357
  init_server();
2054
2358
  init_tailscale();
2359
+ init_tsnet();
2360
+ init_auth();
2361
+ init_cloud();
2055
2362
  }
2056
2363
  });
2057
2364
 
@@ -2062,10 +2369,23 @@ async function buildPairing() {
2062
2369
  const token = requireToken();
2063
2370
  const cfg = loadConfig();
2064
2371
  const port2 = Number(process.env.SAKURA_PORT || cfg.daemon.port);
2065
- const tailnet = await discoverBaseUrl(port2).catch(() => null);
2066
- const url = tailnet ?? `http://${localIp()}:${port2}`;
2067
- const deepLink = `sakura://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
2068
- 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 };
2069
2389
  }
2070
2390
  function renderQr(deepLink) {
2071
2391
  return new Promise((resolve) => {
@@ -2107,6 +2427,11 @@ async function showPairing(opts = {}) {
2107
2427
  info(` URL: ${pairing.url}`);
2108
2428
  info(` Token: ${pairing.token}`);
2109
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
+ }
2110
2435
  info("Or paste the token into the app (Settings \u2192 Connect).");
2111
2436
  }
2112
2437
  function registerPair(program) {
@@ -2136,7 +2461,7 @@ var init_pair = __esm({
2136
2461
  import { Command } from "commander";
2137
2462
 
2138
2463
  // src/version.ts
2139
- var VERSION = "0.0.6";
2464
+ var VERSION = "0.0.9";
2140
2465
 
2141
2466
  // src/index.ts
2142
2467
  init_config();
@@ -2146,21 +2471,39 @@ init_auth();
2146
2471
  init_config();
2147
2472
  init_output();
2148
2473
  function registerAuth(program) {
2149
- 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) => {
2150
- const state = login({
2151
- auth: opts.auth,
2152
- serverUrl: opts.serverUrl,
2153
- machineName: opts.machineName
2154
- });
2155
- if (opts.json) return printJson(state);
2156
- success(`Signed in as ${state.login} on "${state.machineName}".`);
2157
- info("");
2158
- info("Pair the Sakura mobile app with this token (Settings \u2192 Connectivity):");
2159
- info("");
2160
- info(` ${state.token}`);
2161
- info("");
2162
- info("Then start the runtime so the app can reach this machine:");
2163
- 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
+ }
2164
2507
  });
2165
2508
  program.command("logout").description("Clear local credentials").action(() => {
2166
2509
  logout();
@@ -2227,7 +2570,7 @@ function registerRuntime(program, version) {
2227
2570
  // src/commands/session.ts
2228
2571
  init_sessions();
2229
2572
  init_output();
2230
- import fs12 from "fs";
2573
+ import fs13 from "fs";
2231
2574
  function resolveSessionId(arg) {
2232
2575
  const id = arg || process.env.SAKURA_SESSION_ID;
2233
2576
  if (!id) die("Provide a sessionId (positional or via SAKURA_SESSION_ID).");
@@ -2236,7 +2579,7 @@ function resolveSessionId(arg) {
2236
2579
  async function resolvePrompt(positional, opts) {
2237
2580
  if (positional) return positional;
2238
2581
  if (opts.prompt) return opts.prompt;
2239
- if (opts.promptFile) return fs12.readFileSync(opts.promptFile, "utf8");
2582
+ if (opts.promptFile) return fs13.readFileSync(opts.promptFile, "utf8");
2240
2583
  if (!process.stdin.isTTY) {
2241
2584
  const chunks = [];
2242
2585
  for await (const c of process.stdin) chunks.push(c);
@@ -2464,8 +2807,52 @@ init_output();
2464
2807
  function port() {
2465
2808
  return Number(process.env.SAKURA_PORT || loadConfig().daemon.port);
2466
2809
  }
2810
+ function maskKey(key) {
2811
+ if (key.length <= 14) return "\u2022\u2022\u2022\u2022";
2812
+ return `${key.slice(0, 12)}\u2026${key.slice(-4)}`;
2813
+ }
2814
+ function redact(cfg) {
2815
+ if (!cfg) return cfg;
2816
+ return { ...cfg, authKey: maskKey(cfg.authKey) };
2817
+ }
2467
2818
  function registerConnectivity(program) {
2468
2819
  const ts = program.command("tailscale").alias("ts").description("Tailscale connectivity for private PC \u2194 mobile access");
2820
+ 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) => {
2821
+ const controlUrl = String(opts.controlUrl).trim();
2822
+ const authKey = String(opts.authKey).trim();
2823
+ if (!/^https?:\/\//i.test(controlUrl)) die("control URL must start with http(s)://");
2824
+ if (!authKey) die("auth key is required");
2825
+ updateConfig((cfg) => {
2826
+ cfg.tailscale = {
2827
+ controlUrl: controlUrl.replace(/\/+$/, ""),
2828
+ authKey,
2829
+ ...opts.hostname ? { hostname: String(opts.hostname).trim() } : {}
2830
+ };
2831
+ });
2832
+ success("Embedded Tailscale configured.");
2833
+ info(`Control URL: ${controlUrl}`);
2834
+ info("Run `sakuraai daemon restart`, then `sakuraai pair` to show the QR.");
2835
+ });
2836
+ ts.command("config").description("Show the embedded-Tailscale config + live tsnet node state").option("--json", "output as JSON").action((opts) => {
2837
+ const cfg = loadConfig().tailscale ?? null;
2838
+ const node = readTsnetState();
2839
+ if (opts.json) return printJson({ config: redact(cfg), node });
2840
+ if (!cfg) {
2841
+ warn("Embedded Tailscale is not configured. Run `sakuraai tailscale init`.");
2842
+ return;
2843
+ }
2844
+ success("Embedded Tailscale configured.");
2845
+ info(`Control URL: ${cfg.controlUrl}`);
2846
+ info(`Auth key: ${maskKey(cfg.authKey)}`);
2847
+ if (cfg.hostname) info(`Hostname: ${cfg.hostname}`);
2848
+ if (node) {
2849
+ info(`Node state: ${node.backendState}`);
2850
+ if (node.tailscaleIp) info(`Tailnet IP: ${node.tailscaleIp}`);
2851
+ if (node.dnsName) info(`DNS name: ${node.dnsName}`);
2852
+ } else {
2853
+ warn("tsnet sidecar not running \u2014 start it with `sakuraai daemon start`.");
2854
+ }
2855
+ });
2469
2856
  ts.command("status").description("Show tailnet status + the base URL the app should use").option("--json", "output as JSON").action(async (opts) => {
2470
2857
  const r = await status(port());
2471
2858
  if (opts.json) return printJson(r);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sakuraai",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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
+ }