sakuraai 0.0.14 → 0.0.16

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.
Files changed (2) hide show
  1. package/dist/index.js +135 -44
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4651,6 +4651,7 @@ var init_server = __esm({
4651
4651
  });
4652
4652
 
4653
4653
  // src/daemon/tsnet.ts
4654
+ import crypto from "crypto";
4654
4655
  import fs21 from "fs";
4655
4656
  import path19 from "path";
4656
4657
  import { fileURLToPath } from "url";
@@ -4678,35 +4679,63 @@ function startTsnet(port2) {
4678
4679
  const bin = resolveBinary();
4679
4680
  if (!bin) {
4680
4681
  log.warn(
4681
- "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)."
4682
+ "embedded Tailscale is configured but the sakura-tsnet binary was not found \u2014 reinstall with `npm i -g sakuraai@latest --foreground-scripts` (LAN access still works)."
4682
4683
  );
4683
4684
  return;
4684
4685
  }
4685
4686
  stopping = false;
4686
- spawnSidecar(bin, ts.controlUrl, ts.authKey, ts.hostname || cfg.machineName, port2);
4687
+ resetStateIfKeyChanged(ts.authKey);
4688
+ const fresh = !fs21.existsSync(path19.join(STATE_DIR, "tailscaled.state")) && !fs21.existsSync(JOINED_MARK);
4689
+ spawnSidecar(bin, ts.controlUrl, ts.authKey, ts.hostname || cfg.machineName, port2, fresh);
4687
4690
  }
4688
- function spawnSidecar(bin, controlUrl, authKey, hostname2, port2) {
4691
+ function resetStateIfKeyChanged(authKey) {
4692
+ const hash = crypto.createHash("sha256").update(authKey).digest("hex");
4693
+ let prev = null;
4694
+ try {
4695
+ prev = fs21.readFileSync(KEY_MARK, "utf8").trim();
4696
+ } catch {
4697
+ }
4698
+ if (prev && prev !== hash) {
4699
+ log.info("tailnet key changed \u2014 re-registering this Mac on the new key");
4700
+ fs21.rmSync(STATE_DIR, { recursive: true, force: true });
4701
+ }
4702
+ fs21.mkdirSync(STATE_DIR, { recursive: true });
4703
+ fs21.writeFileSync(KEY_MARK, hash);
4704
+ }
4705
+ function spawnSidecar(bin, controlUrl, authKey, hostname2, port2, forceLogin) {
4689
4706
  const env = {
4690
4707
  ...process.env,
4691
4708
  SAKURA_TS_CONTROL_URL: controlUrl,
4692
4709
  SAKURA_TS_AUTH_KEY: authKey,
4693
4710
  SAKURA_TS_HOSTNAME: hostname2,
4694
- SAKURA_TS_STATE_DIR: path19.join(SAKURA_DIR, "tsnet-state"),
4711
+ SAKURA_TS_STATE_DIR: STATE_DIR,
4695
4712
  SAKURA_TS_STATE_FILE: TSNET_PATH,
4696
4713
  SAKURA_TS_PORT: String(port2)
4697
4714
  };
4698
- log.info(`starting embedded Tailscale sidecar (${path19.basename(bin)})`);
4715
+ if (forceLogin) env.TSNET_FORCE_LOGIN = "1";
4716
+ log.info(`starting embedded Tailscale sidecar (${path19.basename(bin)})${forceLogin ? " with a forced login" : ""}`);
4699
4717
  const c = spawn11(bin, [], { stdio: ["ignore", "pipe", "pipe"], env });
4700
4718
  child = c;
4701
- const pipe = (buf) => buf.toString().split("\n").filter(Boolean).forEach((line) => log.info(`[tsnet] ${line}`));
4719
+ let joinFailed = false;
4720
+ const pipe = (buf) => buf.toString().split("\n").filter(Boolean).forEach((line) => {
4721
+ log.info(`[tsnet] ${line}`);
4722
+ if (line.includes("failed to join tailnet")) joinFailed = true;
4723
+ if (line.includes("on tailnet as")) {
4724
+ try {
4725
+ fs21.writeFileSync(JOINED_MARK, (/* @__PURE__ */ new Date()).toISOString());
4726
+ } catch {
4727
+ }
4728
+ }
4729
+ });
4702
4730
  c.stdout?.on("data", pipe);
4703
4731
  c.stderr?.on("data", pipe);
4704
4732
  c.on("exit", (code) => {
4705
4733
  child = null;
4706
4734
  if (stopping) return;
4735
+ const force = forceLogin || joinFailed && !fs21.existsSync(JOINED_MARK);
4707
4736
  log.warn(`tsnet sidecar exited (code ${code}); restarting in 5s`);
4708
4737
  restartTimer = setTimeout(() => {
4709
- if (!stopping) spawnSidecar(bin, controlUrl, authKey, hostname2, port2);
4738
+ if (!stopping) spawnSidecar(bin, controlUrl, authKey, hostname2, port2, force);
4710
4739
  }, 5e3);
4711
4740
  });
4712
4741
  }
@@ -4728,7 +4757,7 @@ function stopTsnet() {
4728
4757
  } catch {
4729
4758
  }
4730
4759
  }
4731
- var child, stopping, restartTimer;
4760
+ var child, stopping, restartTimer, STATE_DIR, KEY_MARK, JOINED_MARK;
4732
4761
  var init_tsnet = __esm({
4733
4762
  "src/daemon/tsnet.ts"() {
4734
4763
  "use strict";
@@ -4737,6 +4766,9 @@ var init_tsnet = __esm({
4737
4766
  child = null;
4738
4767
  stopping = false;
4739
4768
  restartTimer = null;
4769
+ STATE_DIR = path19.join(SAKURA_DIR, "tsnet-state");
4770
+ KEY_MARK = path19.join(STATE_DIR, "joined-with");
4771
+ JOINED_MARK = path19.join(STATE_DIR, "joined");
4740
4772
  }
4741
4773
  });
4742
4774
 
@@ -4885,27 +4917,80 @@ var init_manager = __esm({
4885
4917
  // src/pairing.ts
4886
4918
  import os10 from "os";
4887
4919
  import qrcode from "qrcode-terminal";
4920
+ import fs23 from "fs";
4921
+ function recentTsnetLog(max = 6) {
4922
+ let text = "";
4923
+ try {
4924
+ const fd = fs23.openSync(LOG_PATH, "r");
4925
+ const size = fs23.fstatSync(fd).size;
4926
+ const len = Math.min(size, 64 * 1024);
4927
+ const buf = Buffer.alloc(len);
4928
+ fs23.readSync(fd, buf, 0, len, size - len);
4929
+ fs23.closeSync(fd);
4930
+ text = buf.toString("utf8");
4931
+ } catch {
4932
+ return [];
4933
+ }
4934
+ const lines2 = text.split("\n").filter((l) => /tsnet|sidecar|tailnet/i.test(l));
4935
+ const distinct = lines2.filter((l, i) => l !== lines2[i - 1]);
4936
+ return distinct.slice(-max);
4937
+ }
4938
+ function diagnoseTailnet(tsnet) {
4939
+ if (!resolveBinary()) {
4940
+ return {
4941
+ reason: "the sakura-tsnet binary is missing. It is downloaded when sakuraai installs, and that download failed or was skipped (bun/pnpm, or --ignore-scripts).",
4942
+ fix: "npm i -g sakuraai@latest --foreground-scripts && sakuraai daemon restart",
4943
+ log: []
4944
+ };
4945
+ }
4946
+ if (!tsnet) {
4947
+ return {
4948
+ reason: "the tailnet sidecar has not reported in. It may still be starting, or it failed to start.",
4949
+ fix: "sakuraai daemon restart, wait ~15s, then run sakuraai pair again",
4950
+ log: recentTsnetLog()
4951
+ };
4952
+ }
4953
+ return {
4954
+ reason: `the tailnet sidecar is stuck in state "${tsnet.backendState}" instead of Running.`,
4955
+ fix: "sakuraai daemon restart, wait ~15s, then run sakuraai pair again. If it stays stuck, sakuraai logout && sakuraai login for a fresh key",
4956
+ log: recentTsnetLog()
4957
+ };
4958
+ }
4888
4959
  async function buildPairing() {
4889
4960
  const token = requireToken();
4890
4961
  const cfg = loadConfig();
4891
4962
  const port2 = Number(process.env.SAKURA_PORT || cfg.daemon.port);
4892
4963
  const tsnet = readTsnetState();
4893
4964
  const tsnetUrl = tsnet?.tailscaleIp && tsnet.backendState === "Running" ? `http://${tsnet.tailscaleIp}:${port2}` : null;
4894
- const cliTailnet = tsnetUrl ? null : await discoverBaseUrl(port2).catch(() => null);
4965
+ const ts = cfg.tailscale;
4966
+ const configured = !!(ts?.controlUrl && ts?.authKey);
4967
+ const cliTailnet = tsnetUrl || configured ? null : await discoverBaseUrl(port2).catch(() => null);
4895
4968
  const url = tsnetUrl ?? cliTailnet ?? `http://${localIp()}:${port2}`;
4896
4969
  const params = /* @__PURE__ */ new Map([
4897
4970
  ["url", url],
4898
4971
  ["token", token]
4899
4972
  ]);
4900
- const ts = cfg.tailscale;
4901
- const embeddedTailscale = !!(ts?.controlUrl && ts?.authKey);
4973
+ const embeddedTailscale = configured && !!tsnetUrl;
4902
4974
  if (embeddedTailscale) {
4903
4975
  params.set("cn", ts.controlUrl);
4904
4976
  params.set("tskey", ts.authKey);
4905
4977
  }
4906
4978
  const query = [...params].map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
4907
4979
  const deepLink = `sakura://pair?${query}`;
4908
- return { url, token, deepLink, embeddedTailscale };
4980
+ const tailnetProblem = configured && !tsnetUrl ? diagnoseTailnet(tsnet) : void 0;
4981
+ return { url, token, deepLink, embeddedTailscale, tailnetProblem };
4982
+ }
4983
+ function tunnelUp() {
4984
+ const ts = readTsnetState();
4985
+ return !!(ts?.tailscaleIp && ts.backendState === "Running");
4986
+ }
4987
+ async function waitForTunnel(timeoutMs) {
4988
+ const deadline = Date.now() + timeoutMs;
4989
+ while (Date.now() < deadline) {
4990
+ if (tunnelUp()) return true;
4991
+ await new Promise((r) => setTimeout(r, 500));
4992
+ }
4993
+ return tunnelUp();
4909
4994
  }
4910
4995
  function renderQr(deepLink) {
4911
4996
  return new Promise((resolve) => {
@@ -4927,6 +5012,7 @@ var init_pairing = __esm({
4927
5012
  init_config();
4928
5013
  init_auth();
4929
5014
  init_tailscale();
5015
+ init_tsnet();
4930
5016
  }
4931
5017
  });
4932
5018
 
@@ -4936,10 +5022,22 @@ __export(pair_exports, {
4936
5022
  registerPair: () => registerPair,
4937
5023
  showPairing: () => showPairing
4938
5024
  });
5025
+ import chalk2 from "chalk";
4939
5026
  async function showPairing(opts = {}) {
4940
5027
  const pairing = await buildPairing();
4941
5028
  if (opts.json) return printJson(pairing);
4942
5029
  const qr = await renderQr(pairing.deepLink);
5030
+ const problem = pairing.tailnetProblem;
5031
+ if (problem) {
5032
+ info("");
5033
+ warn(chalk2.bold("This Mac is not on Sakura\u2019s private tailnet, so this QR only works on the same Wi\u2011Fi."));
5034
+ info(` Why: ${problem.reason}`);
5035
+ info(` Fix: ${chalk2.cyan(problem.fix)}`);
5036
+ if (problem.log.length) {
5037
+ info(" Recent tailnet log:");
5038
+ problem.log.forEach((l) => info(chalk2.dim(` ${l}`)));
5039
+ }
5040
+ }
4943
5041
  info("");
4944
5042
  info("Scan this with your phone to pair the Sakura app:");
4945
5043
  info("");
@@ -4966,6 +5064,9 @@ function registerPair(program) {
4966
5064
  }
4967
5065
  if (!running()) {
4968
5066
  warn("The daemon is not running \u2014 start it with `sakuraai daemon start` so the app can connect.");
5067
+ } else if (loadConfig().tailscale?.authKey && !tunnelUp()) {
5068
+ info("Waiting for Sakura\u2019s private tunnel (up to 90s)\u2026");
5069
+ await waitForTunnel(9e4);
4969
5070
  }
4970
5071
  await showPairing(opts);
4971
5072
  success("Waiting for the app to connect\u2026");
@@ -4975,6 +5076,7 @@ var init_pair = __esm({
4975
5076
  "src/commands/pair.ts"() {
4976
5077
  "use strict";
4977
5078
  init_pairing();
5079
+ init_config();
4978
5080
  init_auth();
4979
5081
  init_manager();
4980
5082
  init_output();
@@ -4985,7 +5087,7 @@ var init_pair = __esm({
4985
5087
  import { Command } from "commander";
4986
5088
 
4987
5089
  // src/version.ts
4988
- var VERSION = "0.0.14";
5090
+ var VERSION = "0.0.16";
4989
5091
 
4990
5092
  // src/index.ts
4991
5093
  init_config();
@@ -5048,23 +5150,12 @@ init_tailscale();
5048
5150
  init_auth();
5049
5151
  init_config();
5050
5152
  init_output();
5051
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
5153
+ init_pairing();
5052
5154
  async function tunnelUrl(port2) {
5053
5155
  const ts = readTsnetState();
5054
5156
  if (ts?.tailscaleIp && ts.backendState === "Running") return `http://${ts.tailscaleIp}:${port2}`;
5055
5157
  return await discoverBaseUrl(port2).catch(() => null);
5056
5158
  }
5057
- async function waitForTunnel(timeoutMs) {
5058
- if (!getServerToken()) return false;
5059
- const deadline = Date.now() + timeoutMs;
5060
- while (Date.now() < deadline) {
5061
- const cfg = loadConfig();
5062
- const ts = readTsnetState();
5063
- if (cfg.tailscale?.controlUrl && ts?.tailscaleIp && ts.backendState === "Running") return true;
5064
- await sleep2(500);
5065
- }
5066
- return false;
5067
- }
5068
5159
  function registerRuntime(program, version) {
5069
5160
  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) => {
5070
5161
  if (opts.auth) {
@@ -5081,11 +5172,11 @@ function registerRuntime(program, version) {
5081
5172
  if (!isLoggedIn()) die("Not signed in. Run `sakuraai login` first.");
5082
5173
  const info0 = start();
5083
5174
  success(`Daemon running (pid ${info0.pid}) on ${info0.url}`);
5084
- const ready = await waitForTunnel(2e4);
5085
- if (ready) {
5086
- info("Embedded Tailscale tunnel up \u2014 the phone can connect from any network, no Tailscale app needed.");
5087
- } else if (getServerToken()) {
5088
- warn("Tunnel not up yet \u2014 pairing over LAN for now. Re-run `sakuraai pair` in a moment for the private URL.");
5175
+ if (getServerToken() && loadConfig().tailscale?.controlUrl) {
5176
+ info("Waiting for Sakura\u2019s private tunnel (up to 90s)\u2026");
5177
+ if (await waitForTunnel(9e4)) {
5178
+ info("Embedded Tailscale tunnel up \u2014 the phone can connect from any network, no Tailscale app needed.");
5179
+ }
5089
5180
  }
5090
5181
  const { showPairing: showPairing2 } = await Promise.resolve().then(() => (init_pair(), pair_exports));
5091
5182
  await showPairing2();
@@ -5114,7 +5205,7 @@ function registerRuntime(program, version) {
5114
5205
  // src/commands/session.ts
5115
5206
  init_sessions();
5116
5207
  init_output();
5117
- import fs23 from "fs";
5208
+ import fs24 from "fs";
5118
5209
  function resolveSessionId(arg) {
5119
5210
  const id = arg || process.env.SAKURA_SESSION_ID;
5120
5211
  if (!id) die("Provide a sessionId (positional or via SAKURA_SESSION_ID).");
@@ -5123,7 +5214,7 @@ function resolveSessionId(arg) {
5123
5214
  async function resolvePrompt(positional, opts) {
5124
5215
  if (positional) return positional;
5125
5216
  if (opts.prompt) return opts.prompt;
5126
- if (opts.promptFile) return fs23.readFileSync(opts.promptFile, "utf8");
5217
+ if (opts.promptFile) return fs24.readFileSync(opts.promptFile, "utf8");
5127
5218
  if (!process.stdin.isTTY) {
5128
5219
  const chunks = [];
5129
5220
  for await (const c of process.stdin) chunks.push(c);
@@ -5438,14 +5529,14 @@ init_pair();
5438
5529
 
5439
5530
  // src/commands/welcome.ts
5440
5531
  init_config();
5441
- import fs24 from "fs";
5532
+ import fs25 from "fs";
5442
5533
  import path20 from "path";
5443
5534
  import { fileURLToPath as fileURLToPath2 } from "url";
5444
5535
  import { spawn as spawn13 } from "child_process";
5445
5536
  var WELCOMED_MARKER = path20.join(SAKURA_DIR, ".welcomed");
5446
5537
  function resolveBinary2() {
5447
5538
  const envBin = process.env.SAKURA_ONBOARD_BIN;
5448
- if (envBin && fs24.existsSync(envBin)) return envBin;
5539
+ if (envBin && fs25.existsSync(envBin)) return envBin;
5449
5540
  const here = path20.dirname(fileURLToPath2(import.meta.url));
5450
5541
  const name = process.platform === "win32" ? "sakura-onboard.exe" : "sakura-onboard";
5451
5542
  const candidates = [
@@ -5456,7 +5547,7 @@ function resolveBinary2() {
5456
5547
  path20.join(SAKURA_DIR, name)
5457
5548
  // user-dropped
5458
5549
  ];
5459
- return candidates.find((p) => fs24.existsSync(p)) ?? null;
5550
+ return candidates.find((p) => fs25.existsSync(p)) ?? null;
5460
5551
  }
5461
5552
  function showWelcome() {
5462
5553
  return new Promise((resolve) => {
@@ -5474,10 +5565,10 @@ function showWelcome() {
5474
5565
  });
5475
5566
  }
5476
5567
  async function maybeFirstRunWelcome() {
5477
- if (fs24.existsSync(WELCOMED_MARKER)) return;
5568
+ if (fs25.existsSync(WELCOMED_MARKER)) return;
5478
5569
  if (!process.stdout.isTTY || !process.stdin.isTTY) return;
5479
5570
  try {
5480
- fs24.writeFileSync(WELCOMED_MARKER, (/* @__PURE__ */ new Date()).toISOString());
5571
+ fs25.writeFileSync(WELCOMED_MARKER, (/* @__PURE__ */ new Date()).toISOString());
5481
5572
  } catch {
5482
5573
  }
5483
5574
  await showWelcome();
@@ -5515,7 +5606,7 @@ function registerWelcome(program) {
5515
5606
  }
5516
5607
 
5517
5608
  // src/commands/app.ts
5518
- import fs25 from "fs";
5609
+ import fs26 from "fs";
5519
5610
  import os11 from "os";
5520
5611
  import path21 from "path";
5521
5612
  import { execFileSync as execFileSync4 } from "child_process";
@@ -5528,11 +5619,11 @@ function dmgUrl() {
5528
5619
  async function download(url, dest) {
5529
5620
  const res = await fetch(url, { redirect: "follow" });
5530
5621
  if (!res.ok) throw new Error(`download failed: HTTP ${res.status} for ${url}`);
5531
- fs25.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
5622
+ fs26.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
5532
5623
  }
5533
5624
  async function install() {
5534
5625
  if (process.platform !== "darwin") die("The Sakura menu bar app is macOS only.");
5535
- const tmp = fs25.mkdtempSync(path21.join(os11.tmpdir(), "sakura-app-"));
5626
+ const tmp = fs26.mkdtempSync(path21.join(os11.tmpdir(), "sakura-app-"));
5536
5627
  const dmg = path21.join(tmp, "Sakura.dmg");
5537
5628
  const mount = path21.join(tmp, "mnt");
5538
5629
  info(`Downloading Sakura.app ${VERSION}\u2026`);
@@ -5542,18 +5633,18 @@ async function install() {
5542
5633
  die(`${e.message}
5543
5634
  Build it yourself instead: ./mac/scripts/build-dmg.sh in the Sakura repo.`);
5544
5635
  }
5545
- fs25.mkdirSync(mount);
5636
+ fs26.mkdirSync(mount);
5546
5637
  execFileSync4("hdiutil", ["attach", dmg, "-nobrowse", "-quiet", "-mountpoint", mount]);
5547
5638
  try {
5548
5639
  try {
5549
5640
  execFileSync4("osascript", ["-e", 'tell application id "mom.sakura.mac" to quit']);
5550
5641
  } catch {
5551
5642
  }
5552
- fs25.rmSync(APP_PATH, { recursive: true, force: true });
5643
+ fs26.rmSync(APP_PATH, { recursive: true, force: true });
5553
5644
  execFileSync4("ditto", [path21.join(mount, "Sakura.app"), APP_PATH]);
5554
5645
  } finally {
5555
5646
  execFileSync4("hdiutil", ["detach", mount, "-quiet"]);
5556
- fs25.rmSync(tmp, { recursive: true, force: true });
5647
+ fs26.rmSync(tmp, { recursive: true, force: true });
5557
5648
  }
5558
5649
  execFileSync4("open", [APP_PATH]);
5559
5650
  success("Sakura is in your menu bar (look for the blossom).");
@@ -5562,7 +5653,7 @@ function registerApp(program) {
5562
5653
  const app = program.command("app").description("The optional Sakura menu bar app for macOS");
5563
5654
  app.command("install").description("Download Sakura.app into /Applications and open it").action(install);
5564
5655
  app.command("open").description("Open the Sakura menu bar app").action(() => {
5565
- if (!fs25.existsSync(APP_PATH)) die("Sakura.app is not installed. Run `sakuraai app install`.");
5656
+ if (!fs26.existsSync(APP_PATH)) die("Sakura.app is not installed. Run `sakuraai app install`.");
5566
5657
  execFileSync4("open", [APP_PATH]);
5567
5658
  });
5568
5659
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sakuraai",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Sakura Agent CLI + local runtime for managing AI coding sessions (Claude Code, Codex, OpenCode, ZCode, DeepSeek) and reaching them privately from the Sakura mobile app over Tailscale",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",