@schuttdev/gigai 0.1.0-beta.9 → 0.2.2

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.
@@ -1,11 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- AuthStore
4
- } from "./chunk-OMGUT7RM.js";
5
- import {
6
- generatePairingCode,
7
- validateAndPair
8
- } from "./chunk-FN4LCKUA.js";
9
2
  import {
10
3
  ErrorCode,
11
4
  GigaiConfigSchema,
@@ -13,7 +6,7 @@ import {
13
6
  decrypt,
14
7
  encrypt,
15
8
  generateEncryptionKey
16
- } from "./chunk-4XUWD3DZ.js";
9
+ } from "./chunk-7C3UYEKE.js";
17
10
 
18
11
  // ../server/dist/index.mjs
19
12
  import { parseArgs } from "util";
@@ -22,7 +15,10 @@ import cors from "@fastify/cors";
22
15
  import rateLimit from "@fastify/rate-limit";
23
16
  import multipart from "@fastify/multipart";
24
17
  import fp from "fastify-plugin";
18
+ import { nanoid } from "nanoid";
25
19
  import { randomBytes } from "crypto";
20
+ import { hostname } from "os";
21
+ import { nanoid as nanoid2 } from "nanoid";
26
22
  import fp2 from "fastify-plugin";
27
23
  import fp3 from "fastify-plugin";
28
24
  import { spawn } from "child_process";
@@ -38,18 +34,91 @@ import { spawn as spawn2 } from "child_process";
38
34
  import { writeFile, readFile as readFile2, unlink, mkdir } from "fs/promises";
39
35
  import { join as join2 } from "path";
40
36
  import { tmpdir } from "os";
41
- import { nanoid } from "nanoid";
37
+ import { nanoid as nanoid3 } from "nanoid";
38
+ import { execFile, spawn as spawn3 } from "child_process";
39
+ import { promisify } from "util";
42
40
  import { readFile as readFile3 } from "fs/promises";
43
41
  import { resolve as resolve3 } from "path";
42
+ import { spawn as spawn4 } from "child_process";
43
+ import { spawn as spawn5 } from "child_process";
44
44
  import { input, select, checkbox, confirm } from "@inquirer/prompts";
45
- import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
46
- import { resolve as resolve4, join as join3 } from "path";
47
- import { execFile } from "child_process";
48
- import { promisify } from "util";
49
- import { randomBytes as randomBytes2 } from "crypto";
45
+ import { writeFile as writeFile2 } from "fs/promises";
46
+ import { resolve as resolve4 } from "path";
47
+ import { execFile as execFile2, spawn as spawn6 } from "child_process";
48
+ import { promisify as promisify2 } from "util";
50
49
  import { input as input2 } from "@inquirer/prompts";
51
50
  import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
52
51
  import { resolve as resolve5 } from "path";
52
+ import { writeFile as writeFile4 } from "fs/promises";
53
+ import { resolve as resolve6, join as join3 } from "path";
54
+ import { homedir, platform } from "os";
55
+ import { execFile as execFile3 } from "child_process";
56
+ import { promisify as promisify3 } from "util";
57
+ var AuthStore = class {
58
+ pairingCodes = /* @__PURE__ */ new Map();
59
+ sessions = /* @__PURE__ */ new Map();
60
+ cleanupInterval;
61
+ constructor() {
62
+ this.cleanupInterval = setInterval(() => this.cleanup(), 6e4);
63
+ }
64
+ // Pairing codes
65
+ addPairingCode(code, ttlSeconds) {
66
+ const entry = {
67
+ code,
68
+ expiresAt: Date.now() + ttlSeconds * 1e3,
69
+ used: false
70
+ };
71
+ this.pairingCodes.set(code, entry);
72
+ return entry;
73
+ }
74
+ getPairingCode(code) {
75
+ return this.pairingCodes.get(code);
76
+ }
77
+ markPairingCodeUsed(code) {
78
+ const entry = this.pairingCodes.get(code);
79
+ if (entry) {
80
+ entry.used = true;
81
+ }
82
+ }
83
+ // Sessions
84
+ createSession(orgUuid, ttlSeconds) {
85
+ const session = {
86
+ id: nanoid(32),
87
+ orgUuid,
88
+ token: nanoid(48),
89
+ expiresAt: Date.now() + ttlSeconds * 1e3,
90
+ lastActivity: Date.now()
91
+ };
92
+ this.sessions.set(session.token, session);
93
+ return session;
94
+ }
95
+ getSession(token) {
96
+ const session = this.sessions.get(token);
97
+ if (session) {
98
+ session.lastActivity = Date.now();
99
+ }
100
+ return session;
101
+ }
102
+ // Cleanup
103
+ cleanup() {
104
+ const now = Date.now();
105
+ for (const [key, code] of this.pairingCodes) {
106
+ if (code.expiresAt < now) {
107
+ this.pairingCodes.delete(key);
108
+ }
109
+ }
110
+ for (const [key, session] of this.sessions) {
111
+ if (session.expiresAt < now) {
112
+ this.sessions.delete(key);
113
+ }
114
+ }
115
+ }
116
+ destroy() {
117
+ clearInterval(this.cleanupInterval);
118
+ this.pairingCodes.clear();
119
+ this.sessions.clear();
120
+ }
121
+ };
53
122
  function connectWithToken(store, encryptedToken, orgUuid, encryptionKey, sessionTtlSeconds) {
54
123
  let payload;
55
124
  try {
@@ -63,16 +132,7 @@ function connectWithToken(store, encryptedToken, orgUuid, encryptionKey, session
63
132
  } catch {
64
133
  throw new GigaiError(ErrorCode.TOKEN_DECRYPT_FAILED, "Failed to decrypt token");
65
134
  }
66
- if (decrypted.orgUuid === "*") {
67
- const bound = store.getBoundOrg(decrypted.serverFingerprint);
68
- if (bound) {
69
- if (bound !== orgUuid) {
70
- throw new GigaiError(ErrorCode.ORG_MISMATCH, "Organization UUID mismatch");
71
- }
72
- } else {
73
- store.bindOrg(decrypted.serverFingerprint, orgUuid);
74
- }
75
- } else if (decrypted.orgUuid !== orgUuid) {
135
+ if (decrypted.orgUuid !== orgUuid) {
76
136
  throw new GigaiError(ErrorCode.ORG_MISMATCH, "Organization UUID mismatch");
77
137
  }
78
138
  return store.createSession(orgUuid, sessionTtlSeconds);
@@ -98,8 +158,37 @@ function createAuthMiddleware(store) {
98
158
  request.session = session;
99
159
  };
100
160
  }
161
+ var PAIRING_CODE_LENGTH = 8;
162
+ var PAIRING_CODE_CHARS = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ";
163
+ function generatePairingCode(store, ttlSeconds) {
164
+ let code = "";
165
+ const bytes = nanoid2(PAIRING_CODE_LENGTH);
166
+ for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
167
+ code += PAIRING_CODE_CHARS[bytes.charCodeAt(i) % PAIRING_CODE_CHARS.length];
168
+ }
169
+ store.addPairingCode(code, ttlSeconds);
170
+ return code;
171
+ }
172
+ function validateAndPair(store, code, orgUuid, encryptionKey, serverFingerprint) {
173
+ const entry = store.getPairingCode(code.toUpperCase());
174
+ if (!entry) {
175
+ throw new GigaiError(ErrorCode.PAIRING_INVALID, "Invalid pairing code");
176
+ }
177
+ if (entry.used) {
178
+ throw new GigaiError(ErrorCode.PAIRING_USED, "Pairing code already used");
179
+ }
180
+ if (entry.expiresAt < Date.now()) {
181
+ throw new GigaiError(ErrorCode.PAIRING_EXPIRED, "Pairing code expired");
182
+ }
183
+ store.markPairingCodeUsed(code.toUpperCase());
184
+ return encrypt(
185
+ { orgUuid, serverFingerprint, createdAt: Date.now() },
186
+ encryptionKey
187
+ );
188
+ }
101
189
  function registerAuthRoutes(server, store, config) {
102
190
  const serverFingerprint = randomBytes(16).toString("hex");
191
+ const serverName = config.serverName ?? hostname();
103
192
  server.post("/auth/pair", {
104
193
  config: {
105
194
  rateLimit: { max: 5, timeWindow: "1 hour" }
@@ -123,7 +212,7 @@ function registerAuthRoutes(server, store, config) {
123
212
  config.auth.encryptionKey,
124
213
  serverFingerprint
125
214
  );
126
- return { encryptedToken: JSON.stringify(encryptedToken) };
215
+ return { encryptedToken: JSON.stringify(encryptedToken), serverName };
127
216
  });
128
217
  server.post("/auth/connect", {
129
218
  config: {
@@ -276,7 +365,7 @@ function executeTool(entry, args, timeout) {
276
365
  `Cannot execute tool of type: ${entry.type}`
277
366
  );
278
367
  }
279
- return new Promise((resolve6, reject) => {
368
+ return new Promise((resolve7, reject) => {
280
369
  const start = Date.now();
281
370
  const stdoutChunks = [];
282
371
  const stderrChunks = [];
@@ -324,7 +413,7 @@ function executeTool(entry, args, timeout) {
324
413
  reject(new GigaiError(ErrorCode.EXEC_TIMEOUT, `Tool execution timed out after ${effectiveTimeout}ms`));
325
414
  return;
326
415
  }
327
- resolve6({
416
+ resolve7({
328
417
  stdout: Buffer.concat(stdoutChunks).toString("utf8"),
329
418
  stderr: Buffer.concat(stderrChunks).toString("utf8"),
330
419
  exitCode: exitCode ?? 1,
@@ -616,7 +705,7 @@ async function execCommandSafe(command, args, config) {
616
705
  throw new GigaiError(ErrorCode.VALIDATION_ERROR, "Null byte in argument");
617
706
  }
618
707
  }
619
- return new Promise((resolve6, reject) => {
708
+ return new Promise((resolve7, reject) => {
620
709
  const child = spawn2(command, args, {
621
710
  shell: false,
622
711
  stdio: ["ignore", "pipe", "pipe"]
@@ -638,7 +727,7 @@ async function execCommandSafe(command, args, config) {
638
727
  reject(new GigaiError(ErrorCode.EXEC_FAILED, `Failed to spawn ${command}: ${err.message}`));
639
728
  });
640
729
  child.on("close", (exitCode) => {
641
- resolve6({
730
+ resolve7({
642
731
  stdout: Buffer.concat(stdoutChunks).toString("utf8"),
643
732
  stderr: Buffer.concat(stderrChunks).toString("utf8"),
644
733
  exitCode: exitCode ?? 1
@@ -756,7 +845,7 @@ async function transferRoutes(server) {
756
845
  if (!data) {
757
846
  throw new GigaiError(ErrorCode.VALIDATION_ERROR, "No file uploaded");
758
847
  }
759
- const id = nanoid(16);
848
+ const id = nanoid3(16);
760
849
  const buffer = await data.toBuffer();
761
850
  const filePath = join2(TRANSFER_DIR, id);
762
851
  await writeFile(filePath, buffer);
@@ -787,6 +876,48 @@ async function transferRoutes(server) {
787
876
  reply.type(entry.mimeType).send(content);
788
877
  });
789
878
  }
879
+ var execFileAsync = promisify(execFile);
880
+ async function adminRoutes(server) {
881
+ server.post("/admin/update", async (_request, reply) => {
882
+ try {
883
+ const { stdout, stderr } = await execFileAsync(
884
+ "npm",
885
+ ["install", "-g", "@schuttdev/gigai@latest"],
886
+ { timeout: 12e4 }
887
+ );
888
+ server.log.info(`Update output: ${stdout}`);
889
+ if (stderr) server.log.warn(`Update stderr: ${stderr}`);
890
+ } catch (e) {
891
+ server.log.error(`Update failed: ${e.message}`);
892
+ reply.status(500);
893
+ return { updated: false, error: e.message };
894
+ }
895
+ setTimeout(async () => {
896
+ server.log.info("Restarting server after update...");
897
+ const args = ["server", "start"];
898
+ const configIdx = process.argv.indexOf("--config");
899
+ if (configIdx !== -1 && process.argv[configIdx + 1]) {
900
+ args.push("--config", process.argv[configIdx + 1]);
901
+ }
902
+ const shortIdx = process.argv.indexOf("-c");
903
+ if (shortIdx !== -1 && process.argv[shortIdx + 1]) {
904
+ args.push("--config", process.argv[shortIdx + 1]);
905
+ }
906
+ if (process.argv.includes("--dev")) {
907
+ args.push("--dev");
908
+ }
909
+ await server.close();
910
+ const child = spawn3("gigai", args, {
911
+ detached: true,
912
+ stdio: "ignore",
913
+ cwd: process.cwd()
914
+ });
915
+ child.unref();
916
+ process.exit(0);
917
+ }, 500);
918
+ return { updated: true, restarting: true };
919
+ });
920
+ }
790
921
  async function createServer(opts) {
791
922
  const { config, dev = false } = opts;
792
923
  const server = Fastify({
@@ -796,7 +927,9 @@ async function createServer(opts) {
796
927
  trustProxy: !dev
797
928
  // Only trust proxy headers in production (behind HTTPS reverse proxy)
798
929
  });
799
- if (!dev) {
930
+ const httpsProvider = config.server.https?.provider;
931
+ const behindTunnel = httpsProvider === "tailscale" || httpsProvider === "cloudflare";
932
+ if (!dev && !behindTunnel) {
800
933
  server.addHook("onRequest", async (request, _reply) => {
801
934
  if (request.protocol !== "https") {
802
935
  throw new GigaiError(ErrorCode.HTTPS_REQUIRED, "HTTPS is required");
@@ -814,6 +947,7 @@ async function createServer(opts) {
814
947
  await server.register(toolRoutes);
815
948
  await server.register(execRoutes);
816
949
  await server.register(transferRoutes);
950
+ await server.register(adminRoutes);
817
951
  server.setErrorHandler((error, _request, reply) => {
818
952
  if (error instanceof GigaiError) {
819
953
  reply.status(error.statusCode).send(error.toJSON());
@@ -839,10 +973,67 @@ async function loadConfig(path) {
839
973
  const json = JSON.parse(raw);
840
974
  return GigaiConfigSchema.parse(json);
841
975
  }
842
- var execFileAsync = promisify(execFile);
976
+ function runCommand(command, args) {
977
+ return new Promise((resolve7, reject) => {
978
+ const child = spawn4(command, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
979
+ const chunks = [];
980
+ child.stdout.on("data", (chunk) => chunks.push(chunk));
981
+ child.on("error", reject);
982
+ child.on("close", (exitCode) => {
983
+ resolve7({ stdout: Buffer.concat(chunks).toString("utf8").trim(), exitCode: exitCode ?? 1 });
984
+ });
985
+ });
986
+ }
987
+ async function getTailscaleStatus() {
988
+ try {
989
+ const { stdout, exitCode } = await runCommand("tailscale", ["status", "--json"]);
990
+ if (exitCode !== 0) return { online: false };
991
+ const status = JSON.parse(stdout);
992
+ return {
993
+ online: status.BackendState === "Running",
994
+ hostname: status.Self?.DNSName?.replace(/\.$/, "")
995
+ };
996
+ } catch {
997
+ return { online: false };
998
+ }
999
+ }
1000
+ async function enableFunnel(port) {
1001
+ const status = await getTailscaleStatus();
1002
+ if (!status.online || !status.hostname) {
1003
+ throw new Error("Tailscale is not running or not connected");
1004
+ }
1005
+ const { exitCode, stdout } = await runCommand("tailscale", [
1006
+ "funnel",
1007
+ "--bg",
1008
+ `${port}`
1009
+ ]);
1010
+ if (exitCode !== 0) {
1011
+ throw new Error(`Failed to enable Tailscale Funnel: ${stdout}`);
1012
+ }
1013
+ return `https://${status.hostname}`;
1014
+ }
1015
+ async function disableFunnel(port) {
1016
+ await runCommand("tailscale", ["funnel", "--bg", "off", `${port}`]);
1017
+ }
1018
+ function runTunnel(tunnelName, localPort) {
1019
+ const child = spawn5("cloudflared", [
1020
+ "tunnel",
1021
+ "--url",
1022
+ `http://localhost:${localPort}`,
1023
+ "run",
1024
+ tunnelName
1025
+ ], {
1026
+ shell: false,
1027
+ stdio: "ignore",
1028
+ detached: true
1029
+ });
1030
+ child.unref();
1031
+ return child;
1032
+ }
1033
+ var execFileAsync2 = promisify2(execFile2);
843
1034
  async function getTailscaleDnsName() {
844
1035
  try {
845
- const { stdout } = await execFileAsync("tailscale", ["status", "--json"]);
1036
+ const { stdout } = await execFileAsync2("tailscale", ["status", "--json"]);
846
1037
  const data = JSON.parse(stdout);
847
1038
  const dnsName = data?.Self?.DNSName;
848
1039
  if (dnsName) return dnsName.replace(/\.$/, "");
@@ -851,6 +1042,41 @@ async function getTailscaleDnsName() {
851
1042
  return null;
852
1043
  }
853
1044
  }
1045
+ async function ensureTailscaleFunnel(port) {
1046
+ const dnsName = await getTailscaleDnsName();
1047
+ if (!dnsName) {
1048
+ throw new Error("Tailscale is not running or not connected. Install/start Tailscale first.");
1049
+ }
1050
+ console.log(" Enabling Tailscale Funnel...");
1051
+ try {
1052
+ const { stdout, stderr } = await execFileAsync2("tailscale", ["funnel", "--bg", `${port}`]);
1053
+ const output = stdout + stderr;
1054
+ if (output.includes("Funnel is not enabled")) {
1055
+ const urlMatch = output.match(/(https:\/\/login\.tailscale\.com\/\S+)/);
1056
+ const enableUrl = urlMatch?.[1] ?? "https://login.tailscale.com/admin/machines";
1057
+ console.log(`
1058
+ Funnel is not enabled on your tailnet.`);
1059
+ console.log(` Enable it here: ${enableUrl}
1060
+ `);
1061
+ await confirm({ message: "I've enabled Funnel in my Tailscale admin. Continue?", default: true });
1062
+ const retry = await execFileAsync2("tailscale", ["funnel", "--bg", `${port}`]);
1063
+ if ((retry.stdout + retry.stderr).includes("Funnel is not enabled")) {
1064
+ throw new Error("Funnel is still not enabled. Please enable it in your Tailscale admin and try again.");
1065
+ }
1066
+ }
1067
+ } catch (e) {
1068
+ if (e.message.includes("Funnel is still not enabled")) throw e;
1069
+ }
1070
+ try {
1071
+ const { stdout } = await execFileAsync2("tailscale", ["funnel", "status"]);
1072
+ if (stdout.includes("No serve config")) {
1073
+ throw new Error("Funnel setup failed. Run 'tailscale funnel --bg " + port + "' manually to debug.");
1074
+ }
1075
+ } catch {
1076
+ }
1077
+ console.log(` Tailscale Funnel active: https://${dnsName}`);
1078
+ return `https://${dnsName}`;
1079
+ }
854
1080
  async function runInit() {
855
1081
  console.log("\n gigai server setup\n");
856
1082
  const httpsProvider = await select({
@@ -858,7 +1084,6 @@ async function runInit() {
858
1084
  choices: [
859
1085
  { name: "Tailscale Funnel (recommended)", value: "tailscale" },
860
1086
  { name: "Cloudflare Tunnel", value: "cloudflare" },
861
- { name: "Let's Encrypt", value: "letsencrypt" },
862
1087
  { name: "Manual (provide certs)", value: "manual" },
863
1088
  { name: "None (dev mode only)", value: "none" }
864
1089
  ]
@@ -870,7 +1095,6 @@ async function runInit() {
870
1095
  provider: "tailscale",
871
1096
  funnelPort: 7443
872
1097
  };
873
- console.log(" Will use Tailscale Funnel for HTTPS.");
874
1098
  break;
875
1099
  case "cloudflare": {
876
1100
  const tunnelName = await input({
@@ -887,22 +1111,6 @@ async function runInit() {
887
1111
  };
888
1112
  break;
889
1113
  }
890
- case "letsencrypt": {
891
- const domain = await input({
892
- message: "Domain name:",
893
- required: true
894
- });
895
- const email = await input({
896
- message: "Email for Let's Encrypt:",
897
- required: true
898
- });
899
- httpsConfig = {
900
- provider: "letsencrypt",
901
- domain,
902
- email
903
- };
904
- break;
905
- }
906
1114
  case "manual": {
907
1115
  const certPath = await input({
908
1116
  message: "Path to TLS certificate:",
@@ -970,8 +1178,25 @@ async function runInit() {
970
1178
  config: { allowlist, allowSudo }
971
1179
  });
972
1180
  }
1181
+ let serverName;
1182
+ if (httpsProvider === "tailscale") {
1183
+ const dnsName = await getTailscaleDnsName();
1184
+ if (dnsName) {
1185
+ serverName = dnsName.split(".")[0];
1186
+ }
1187
+ } else if (httpsProvider === "cloudflare") {
1188
+ serverName = await input({
1189
+ message: "Server name (identifies this machine):",
1190
+ required: true
1191
+ });
1192
+ }
1193
+ if (!serverName) {
1194
+ const { hostname: osHostname } = await import("os");
1195
+ serverName = osHostname();
1196
+ }
973
1197
  const encryptionKey = generateEncryptionKey();
974
1198
  const config = {
1199
+ serverName,
975
1200
  server: {
976
1201
  port,
977
1202
  host: "0.0.0.0",
@@ -988,92 +1213,78 @@ async function runInit() {
988
1213
  await writeFile2(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
989
1214
  console.log(`
990
1215
  Config written to: ${configPath}`);
991
- const skillDir = resolve4("gigai-skill");
992
- await mkdir2(skillDir, { recursive: true });
993
- let serverUrl = "<YOUR_SERVER_URL>";
1216
+ let serverUrl;
994
1217
  if (httpsProvider === "tailscale") {
995
- const dnsName = await getTailscaleDnsName();
996
- if (dnsName) {
997
- serverUrl = `https://${dnsName}:${port}`;
998
- console.log(` Detected Tailscale URL: ${serverUrl}`);
1218
+ try {
1219
+ serverUrl = await ensureTailscaleFunnel(port);
1220
+ } catch (e) {
1221
+ console.error(` ${e.message}`);
1222
+ console.log(" You can enable Funnel later and run 'gigai server start' manually.\n");
999
1223
  }
1224
+ } else if (httpsProvider === "cloudflare" && httpsConfig && "domain" in httpsConfig && httpsConfig.domain) {
1225
+ serverUrl = `https://${httpsConfig.domain}`;
1226
+ console.log(` Cloudflare URL: ${serverUrl}`);
1227
+ }
1228
+ if (!serverUrl) {
1229
+ serverUrl = await input({
1230
+ message: "Server URL (how clients will reach this server):",
1231
+ required: true
1232
+ });
1233
+ }
1234
+ console.log("\n Starting server...");
1235
+ const serverArgs = ["server", "start", "--config", configPath];
1236
+ if (!httpsConfig) serverArgs.push("--dev");
1237
+ const child = spawn6("gigai", serverArgs, {
1238
+ detached: true,
1239
+ stdio: "ignore",
1240
+ cwd: resolve4(".")
1241
+ });
1242
+ child.unref();
1243
+ await new Promise((r) => setTimeout(r, 1500));
1244
+ try {
1245
+ const res = await fetch(`http://localhost:${port}/health`);
1246
+ if (res.ok) {
1247
+ console.log(` Server running on port ${port} (PID ${child.pid})`);
1248
+ }
1249
+ } catch {
1250
+ console.log(` Server starting in background (PID ${child.pid})`);
1251
+ }
1252
+ let code;
1253
+ const maxRetries = 5;
1254
+ for (let i = 0; i < maxRetries; i++) {
1255
+ try {
1256
+ const res = await fetch(`http://localhost:${port}/auth/pair/generate`);
1257
+ if (res.ok) {
1258
+ const data = await res.json();
1259
+ code = data.code;
1260
+ break;
1261
+ }
1262
+ } catch {
1263
+ await new Promise((r) => setTimeout(r, 1e3));
1264
+ }
1265
+ }
1266
+ if (!code) {
1267
+ console.log("\n Server is starting but not ready yet.");
1268
+ console.log(" Run 'gigai server pair' once it's up to get a pairing code.\n");
1269
+ return;
1000
1270
  }
1001
- const skillMd = `---
1002
- name: gigai
1003
- description: Access tools on the user's machine via the gigai CLI
1004
- ---
1005
-
1006
- # gigai Skill
1007
-
1008
- This skill gives you access to tools running on the user's machine via the gigai CLI.
1009
-
1010
- ## Quick Start
1011
-
1012
- 1. Connect to the server:
1013
- \`\`\`
1014
- gigai connect
1015
- \`\`\`
1016
-
1017
- 2. List available tools:
1018
- \`\`\`
1019
- gigai list
1020
- \`\`\`
1021
-
1022
- 3. Get help on a specific tool:
1023
- \`\`\`
1024
- gigai help <tool-name>
1025
- \`\`\`
1026
-
1027
- 4. Use a tool directly:
1028
- \`\`\`
1029
- gigai <tool-name> [args...]
1030
- \`\`\`
1031
-
1032
- ## Commands
1033
-
1034
- ### Connection
1035
- - \`gigai connect\` \u2014 Establish/renew a session with the server
1036
- - \`gigai status\` \u2014 Check connection status
1037
- - \`gigai pair <code> <server-url>\` \u2014 Pair with a new server
1038
-
1039
- ### Tool Usage
1040
- - \`gigai list\` \u2014 List all available tools
1041
- - \`gigai help <name>\` \u2014 Show detailed help for a tool
1042
- - \`gigai <name> [args...]\` \u2014 Execute a tool by name
1043
-
1044
- ### File Transfer
1045
- - \`gigai upload <file>\` \u2014 Upload a file to the server
1046
- - \`gigai download <id> <dest>\` \u2014 Download a file from the server
1047
-
1048
- ## Notes
1049
-
1050
- - The connection is authenticated and encrypted end-to-end
1051
- - Tools are scoped to what the user has explicitly configured
1052
- - Sessions auto-renew; if you get auth errors, run \`gigai connect\`
1053
- - All tool execution happens on the user's machine, not in this container
1054
- `;
1055
- await writeFile2(join3(skillDir, "SKILL.md"), skillMd);
1056
- const tokenPayload = {
1057
- orgUuid: "*",
1058
- serverFingerprint: randomBytes2(16).toString("hex"),
1059
- createdAt: Date.now()
1060
- };
1061
- const encryptedToken = encrypt(tokenPayload, encryptionKey);
1062
- const skillConfig = {
1063
- server: serverUrl,
1064
- token: JSON.stringify(encryptedToken)
1065
- };
1066
- await writeFile2(join3(skillDir, "config.json"), JSON.stringify(skillConfig, null, 2) + "\n", { mode: 384 });
1067
- console.log(` Skill template written to: ${skillDir}/`);
1068
- const store = new AuthStore();
1069
- const code = generatePairingCode(store, config.auth.pairingTtlSeconds);
1070
1271
  console.log(`
1071
- Pairing code: ${code} (expires in ${config.auth.pairingTtlSeconds / 60} min)`);
1072
- store.destroy();
1272
+ Paste this into Claude to pair:
1273
+ `);
1274
+ console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
1275
+ console.log(` Install the gigai CLI and pair with my server:
1276
+ `);
1277
+ console.log(` \`\`\`bash`);
1278
+ console.log(` npm install -g @schuttdev/gigai`);
1279
+ console.log(` gigai pair ${code} ${serverUrl}`);
1280
+ console.log(` \`\`\`
1281
+ `);
1282
+ console.log(` Then show me the skill file output so I can save it.`);
1283
+ console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
1073
1284
  console.log(`
1074
- Start the server with: gigai server start${httpsConfig ? "" : " --dev"}`);
1075
- console.log(` Then upload gigai-skill/ contents to your Claude Project.`);
1076
- console.log(` The token will bind to the first org UUID that connects.`);
1285
+ Pairing code expires in ${config.auth.pairingTtlSeconds / 60} minutes.`);
1286
+ console.log(` Run 'gigai server pair' to generate a new one.
1287
+ `);
1077
1288
  }
1078
1289
  async function loadConfigFile(path) {
1079
1290
  const configPath = resolve5(path ?? "gigai.config.json");
@@ -1186,17 +1397,165 @@ async function unwrapTool(name) {
1186
1397
  }
1187
1398
  async function generateServerPairingCode(configPath) {
1188
1399
  const { config } = await loadConfigFile(configPath);
1189
- const { AuthStore: AuthStore2 } = await import("./store-XDNMGPYX-5CGK2GXY.js");
1190
- const { generatePairingCode: generatePairingCode2 } = await import("./pairing-IGMDVOIZ-RA7GNFU7.js");
1191
- const store = new AuthStore2();
1192
- const code = generatePairingCode2(store, config.auth.pairingTtlSeconds);
1193
- console.log(`
1194
- Pairing code: ${code}`);
1195
- console.log(`Expires in ${config.auth.pairingTtlSeconds / 60} minutes.`);
1196
- console.log(`
1197
- Note: The server must be running for the client to use this code.`);
1198
- console.log(`For a live pairing code, use the server's /auth/pair/generate endpoint.`);
1199
- store.destroy();
1400
+ const port = config.server.port;
1401
+ try {
1402
+ const res = await fetch(`http://localhost:${port}/auth/pair/generate`);
1403
+ if (!res.ok) {
1404
+ const body = await res.text();
1405
+ throw new Error(`Server returned ${res.status}: ${body}`);
1406
+ }
1407
+ const data = await res.json();
1408
+ console.log(`
1409
+ Pairing code: ${data.code}`);
1410
+ console.log(`Expires in ${data.expiresIn / 60} minutes.`);
1411
+ } catch (e) {
1412
+ if (e.message.includes("fetch failed") || e.message.includes("ECONNREFUSED")) {
1413
+ console.error("Server is not running. Start it with: gigai server start");
1414
+ } else {
1415
+ console.error(`Error: ${e.message}`);
1416
+ }
1417
+ process.exitCode = 1;
1418
+ }
1419
+ }
1420
+ var execFileAsync3 = promisify3(execFile3);
1421
+ function getGigaiBin() {
1422
+ return process.argv[1] ?? "gigai";
1423
+ }
1424
+ function getLaunchdPlist(configPath) {
1425
+ const bin = getGigaiBin();
1426
+ return `<?xml version="1.0" encoding="UTF-8"?>
1427
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1428
+ <plist version="1.0">
1429
+ <dict>
1430
+ <key>Label</key>
1431
+ <string>com.gigai.server</string>
1432
+ <key>ProgramArguments</key>
1433
+ <array>
1434
+ <string>${bin}</string>
1435
+ <string>server</string>
1436
+ <string>start</string>
1437
+ <string>--config</string>
1438
+ <string>${configPath}</string>
1439
+ </array>
1440
+ <key>RunAtLoad</key>
1441
+ <true/>
1442
+ <key>KeepAlive</key>
1443
+ <true/>
1444
+ <key>StandardOutPath</key>
1445
+ <string>${join3(homedir(), ".gigai", "server.log")}</string>
1446
+ <key>StandardErrorPath</key>
1447
+ <string>${join3(homedir(), ".gigai", "server.log")}</string>
1448
+ <key>WorkingDirectory</key>
1449
+ <string>${homedir()}</string>
1450
+ </dict>
1451
+ </plist>
1452
+ `;
1453
+ }
1454
+ function getSystemdUnit(configPath) {
1455
+ const bin = getGigaiBin();
1456
+ return `[Unit]
1457
+ Description=gigai server
1458
+ After=network.target
1459
+
1460
+ [Service]
1461
+ Type=simple
1462
+ ExecStart=${bin} server start --config ${configPath}
1463
+ Restart=always
1464
+ RestartSec=5
1465
+ WorkingDirectory=${homedir()}
1466
+
1467
+ [Install]
1468
+ WantedBy=default.target
1469
+ `;
1470
+ }
1471
+ async function installDaemon(configPath) {
1472
+ const config = resolve6(configPath ?? "gigai.config.json");
1473
+ const os = platform();
1474
+ if (os === "darwin") {
1475
+ const plistPath = join3(homedir(), "Library", "LaunchAgents", "com.gigai.server.plist");
1476
+ await writeFile4(plistPath, getLaunchdPlist(config));
1477
+ console.log(` Wrote launchd plist: ${plistPath}`);
1478
+ try {
1479
+ await execFileAsync3("launchctl", ["load", plistPath]);
1480
+ console.log(" Service loaded and started.");
1481
+ } catch {
1482
+ console.log(` Load it with: launchctl load ${plistPath}`);
1483
+ }
1484
+ console.log(` Logs: ~/.gigai/server.log`);
1485
+ console.log(` Stop: launchctl unload ${plistPath}`);
1486
+ } else if (os === "linux") {
1487
+ const unitDir = join3(homedir(), ".config", "systemd", "user");
1488
+ const unitPath = join3(unitDir, "gigai.service");
1489
+ const { mkdir: mkdir2 } = await import("fs/promises");
1490
+ await mkdir2(unitDir, { recursive: true });
1491
+ await writeFile4(unitPath, getSystemdUnit(config));
1492
+ console.log(` Wrote systemd unit: ${unitPath}`);
1493
+ try {
1494
+ await execFileAsync3("systemctl", ["--user", "daemon-reload"]);
1495
+ await execFileAsync3("systemctl", ["--user", "enable", "--now", "gigai"]);
1496
+ console.log(" Service enabled and started.");
1497
+ } catch {
1498
+ console.log(" Enable it with: systemctl --user enable --now gigai");
1499
+ }
1500
+ console.log(` Logs: journalctl --user -u gigai -f`);
1501
+ console.log(` Stop: systemctl --user stop gigai`);
1502
+ console.log(` Remove: systemctl --user disable gigai`);
1503
+ } else {
1504
+ console.log(" Persistent daemon not supported on this platform.");
1505
+ console.log(" Run 'gigai server start' manually.");
1506
+ }
1507
+ }
1508
+ async function uninstallDaemon() {
1509
+ const os = platform();
1510
+ if (os === "darwin") {
1511
+ const plistPath = join3(homedir(), "Library", "LaunchAgents", "com.gigai.server.plist");
1512
+ try {
1513
+ await execFileAsync3("launchctl", ["unload", plistPath]);
1514
+ } catch {
1515
+ }
1516
+ const { unlink: unlink2 } = await import("fs/promises");
1517
+ try {
1518
+ await unlink2(plistPath);
1519
+ console.log(" Service removed.");
1520
+ } catch {
1521
+ console.log(" No service found.");
1522
+ }
1523
+ } else if (os === "linux") {
1524
+ try {
1525
+ await execFileAsync3("systemctl", ["--user", "disable", "--now", "gigai"]);
1526
+ } catch {
1527
+ }
1528
+ const unitPath = join3(homedir(), ".config", "systemd", "user", "gigai.service");
1529
+ const { unlink: unlink2 } = await import("fs/promises");
1530
+ try {
1531
+ await unlink2(unitPath);
1532
+ await execFileAsync3("systemctl", ["--user", "daemon-reload"]);
1533
+ console.log(" Service removed.");
1534
+ } catch {
1535
+ console.log(" No service found.");
1536
+ }
1537
+ }
1538
+ }
1539
+ async function stopServer() {
1540
+ const { execFileSync } = await import("child_process");
1541
+ let pids = [];
1542
+ try {
1543
+ const out = execFileSync("pgrep", ["-f", "gigai server start"], { encoding: "utf8" });
1544
+ pids = out.trim().split("\n").map(Number).filter((pid) => pid && pid !== process.pid);
1545
+ } catch {
1546
+ }
1547
+ if (pids.length === 0) {
1548
+ console.log("No running gigai server found.");
1549
+ return;
1550
+ }
1551
+ for (const pid of pids) {
1552
+ try {
1553
+ process.kill(pid, "SIGTERM");
1554
+ console.log(`Stopped gigai server (PID ${pid})`);
1555
+ } catch (e) {
1556
+ console.error(`Failed to stop PID ${pid}: ${e.message}`);
1557
+ }
1558
+ }
1200
1559
  }
1201
1560
  async function startServer() {
1202
1561
  const { values } = parseArgs({
@@ -1212,8 +1571,38 @@ async function startServer() {
1212
1571
  const host = config.server.host;
1213
1572
  await server.listen({ port, host });
1214
1573
  server.log.info(`gigai server listening on ${host}:${port}`);
1574
+ let cfTunnel;
1575
+ const httpsProvider = config.server.https?.provider;
1576
+ if (httpsProvider === "tailscale") {
1577
+ try {
1578
+ const funnelUrl = await enableFunnel(port);
1579
+ server.log.info(`Tailscale Funnel enabled: ${funnelUrl}`);
1580
+ } catch (e) {
1581
+ server.log.error(`Failed to enable Tailscale Funnel: ${e.message}`);
1582
+ }
1583
+ } else if (httpsProvider === "cloudflare") {
1584
+ try {
1585
+ const tunnelName = config.server.https.tunnelName;
1586
+ cfTunnel = runTunnel(tunnelName, port);
1587
+ server.log.info(`Cloudflare Tunnel started: ${tunnelName}`);
1588
+ } catch (e) {
1589
+ server.log.error(`Failed to start Cloudflare Tunnel: ${e.message}`);
1590
+ }
1591
+ }
1215
1592
  const shutdown = async () => {
1216
1593
  server.log.info("Shutting down...");
1594
+ if (httpsProvider === "tailscale") {
1595
+ try {
1596
+ await disableFunnel(port);
1597
+ } catch {
1598
+ }
1599
+ }
1600
+ if (cfTunnel) {
1601
+ try {
1602
+ cfTunnel.kill();
1603
+ } catch {
1604
+ }
1605
+ }
1217
1606
  await server.close();
1218
1607
  process.exit(0);
1219
1608
  };
@@ -1223,9 +1612,12 @@ async function startServer() {
1223
1612
  export {
1224
1613
  createServer,
1225
1614
  generateServerPairingCode,
1615
+ installDaemon,
1226
1616
  loadConfig,
1227
1617
  runInit,
1228
1618
  startServer,
1619
+ stopServer,
1620
+ uninstallDaemon,
1229
1621
  unwrapTool,
1230
1622
  wrapCli,
1231
1623
  wrapImport,