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 +472 -85
- package/package.json +3 -1
- package/scripts/download-tsnet.js +77 -0
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
|
|
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
|
-
|
|
118
|
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
735
|
+
child2.stdout.on("data", (d) => {
|
|
629
736
|
out += d.toString();
|
|
630
737
|
onData?.(d.toString());
|
|
631
738
|
});
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
882
|
+
child2.stdout.on("data", (d) => {
|
|
776
883
|
out += d.toString();
|
|
777
884
|
onData?.(d.toString());
|
|
778
885
|
});
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
|
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
|
|
1406
|
+
const child2 = spawn6(tsBin(), args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1228
1407
|
let out = "";
|
|
1229
1408
|
let err = "";
|
|
1230
1409
|
const timer = setTimeout(() => {
|
|
1231
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
-
|
|
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,
|
|
1706
|
+
function add(method, path10, handler) {
|
|
1526
1707
|
const keys = [];
|
|
1527
1708
|
const pattern = new RegExp(
|
|
1528
|
-
"^" +
|
|
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/
|
|
2133
|
+
// src/daemon/tsnet.ts
|
|
1942
2134
|
import fs11 from "fs";
|
|
1943
|
-
import
|
|
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(
|
|
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
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
1988
|
-
|
|
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
|
-
|
|
1997
|
-
|
|
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 =
|
|
2009
|
-
const err =
|
|
2010
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
2032
|
-
|
|
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 =
|
|
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
|
|
2066
|
-
const
|
|
2067
|
-
const
|
|
2068
|
-
|
|
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.
|
|
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
|
|
2150
|
-
|
|
2151
|
-
auth: opts.auth,
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|