itwillsync 1.0.6 → 1.2.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # itwillsync
2
2
 
3
- Sync any terminal-based coding agent to your phone over local network. Open source, agent-agnostic, zero cloud.
3
+ Sync any terminal-based coding agent to your phone. Local network or Tailscale. Open source, agent-agnostic, zero cloud.
4
4
 
5
5
  ```
6
6
  npx itwillsync -- claude
@@ -15,8 +15,6 @@ npx itwillsync -- bash
15
15
  3. Scan it on your phone — opens a terminal in your browser
16
16
  4. Control your agent from your phone (or both phone and laptop simultaneously)
17
17
 
18
- All data stays on your local network. No cloud, no relay, no account needed.
19
-
20
18
  ## Requirements
21
19
 
22
20
  - Node.js 20+
@@ -33,94 +31,56 @@ npm install -g itwillsync
33
31
  itwillsync -- aider --model gpt-4
34
32
  ```
35
33
 
36
- ## Options
37
-
38
- ```
39
- --port <number> Port to listen on (default: 3456)
40
- --localhost Bind to 127.0.0.1 only (no LAN access)
41
- --no-qr Don't display QR code
42
- -h, --help Show help
43
- -v, --version Show version
44
- ```
45
-
46
- ## Remote Access
47
-
48
- By default, itwillsync is accessible on your local network (same WiFi). For remote access from anywhere:
49
-
50
- - **Tailscale** (recommended): Install on both devices, access via Tailscale IP
51
- - **WireGuard / VPN**: Any VPN that puts devices on the same network
52
- - **SSH tunnel**: `ssh -L 3456:localhost:3456 your-machine`
53
-
54
- ## Security
55
-
56
- - Each session generates a random 64-character token
57
- - Token is embedded in the QR code URL
58
- - All WebSocket connections require the token
59
- - No data leaves your network
60
-
61
- ## Architecture
62
-
63
- ```
64
- Your Machine Your Phone
65
- ┌─────────────────────┐ ┌──────────────┐
66
- │ itwillsync │ WiFi/LAN │ Browser │
67
- │ ├─ PTY (your agent) │◄────────────►│ xterm.js │
68
- │ ├─ HTTP server │ WebSocket │ terminal │
69
- │ └─ WS server │ └──────────────┘
70
- └─────────────────────┘
71
- ```
72
-
73
- ## Session Behavior
34
+ On first run, a setup wizard asks how you want to connect — local WiFi or Tailscale. Your choice is saved for future sessions.
74
35
 
75
- - **No timeout**: Sessions live as long as the agent process runs. No TTL, no idle disconnect.
76
- - **Multiple devices**: Connect from phone, tablet, and laptop simultaneously — all see the same terminal.
77
- - **Reconnect**: If your phone disconnects (WiFi switch, screen lock), it auto-reconnects and catches up with recent output.
78
- - **Keepalive**: WebSocket pings every 30s prevent routers from closing idle connections.
79
- - **One session per instance**: Run multiple `itwillsync` instances on different ports for multiple agents.
36
+ ## Connect from Anywhere with Tailscale
80
37
 
81
- ## Development
38
+ By default, your phone needs to be on the same WiFi. With [Tailscale](https://tailscale.com), you can connect from anywhere — coffee shop, cellular, different network.
82
39
 
83
40
  ```bash
84
- # 1. Clone and enter the project
85
- git clone https://github.com/your-username/itwillsync
86
- cd itwillsync
87
-
88
- # 2. Use Node 22 (required for node-pty native bindings)
89
- nvm use # reads .nvmrc
41
+ # First time: the setup wizard will detect Tailscale automatically
42
+ itwillsync -- claude
90
43
 
91
- # 3. Install dependencies
92
- pnpm install
44
+ # Or use Tailscale for a single session
45
+ itwillsync --tailscale -- claude
93
46
 
94
- # 4. Build everything (web client first, then CLI)
95
- pnpm build
47
+ # Switch back to local WiFi for a session
48
+ itwillsync --local -- claude
96
49
 
97
- # 5. Test it
98
- node packages/cli/dist/index.js -- bash
50
+ # Re-run setup anytime
51
+ itwillsync setup
99
52
  ```
100
53
 
101
- ### Project Structure
54
+ **Setup:** Install Tailscale on both your computer and phone. That's it — itwillsync detects it automatically.
55
+
56
+ ## Options
102
57
 
103
58
  ```
104
- packages/
105
- ├── cli/ # Main npm package PTY, server, auth, CLI
106
- └── web-client/ # Browser terminal — xterm.js, mobile-friendly CSS
59
+ Commands:
60
+ setup Run the setup wizard (change networking mode)
61
+
62
+ Options:
63
+ --port <number> Port to listen on (default: 3456)
64
+ --localhost Bind to 127.0.0.1 only (no LAN access)
65
+ --tailscale Use Tailscale for this session
66
+ --local Use local WiFi for this session
67
+ --no-qr Don't display QR code
68
+ -h, --help Show help
69
+ -v, --version Show version
107
70
  ```
108
71
 
109
- ### Contributing
72
+ ## Security
110
73
 
111
- 1. Fork the repo
112
- 2. Create a feature branch
113
- 3. Make your changes
114
- 4. Run `pnpm build` to verify
115
- 5. Open a PR
74
+ - Each session generates a random 64-character token
75
+ - Token is embedded in the QR code URL
76
+ - All WebSocket connections require the token
77
+ - No data leaves your network (local mode) or your Tailscale tailnet
116
78
 
117
- ## Roadmap
79
+ ## Links
118
80
 
119
- - [ ] Chat-style input bar + quick action buttons on mobile
120
- - [ ] Agent detection + structured view for Claude Code
121
- - [ ] React Native mobile app
122
- - [ ] VS Code extension adapter
123
- - [ ] Agent Sync Protocol specification
81
+ - **Website**: https://shrijayan.github.io/itwillsync/
82
+ - **GitHub**: https://github.com/shrijayan/itwillsync
83
+ - **Demo**: https://youtu.be/Zc0Tb98CXh0
124
84
 
125
85
  ## License
126
86
 
package/dist/index.js CHANGED
@@ -3759,6 +3759,70 @@ function validateToken(provided, expected) {
3759
3759
  // src/network.ts
3760
3760
  import { networkInterfaces } from "os";
3761
3761
  import { createServer } from "net";
3762
+
3763
+ // src/tailscale.ts
3764
+ import { execFile } from "child_process";
3765
+ function execCommand(cmd, args) {
3766
+ return new Promise((resolve, reject) => {
3767
+ execFile(cmd, args, { timeout: 5e3 }, (error, stdout, stderr) => {
3768
+ if (error) {
3769
+ reject(error);
3770
+ } else {
3771
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
3772
+ }
3773
+ });
3774
+ });
3775
+ }
3776
+ function getTailscalePaths() {
3777
+ const paths = ["tailscale"];
3778
+ if (process.platform === "darwin") {
3779
+ paths.push("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
3780
+ }
3781
+ return paths;
3782
+ }
3783
+ async function tryExec(args) {
3784
+ let lastError = null;
3785
+ for (const bin of getTailscalePaths()) {
3786
+ try {
3787
+ const result = await execCommand(bin, args);
3788
+ return { status: "success", ...result };
3789
+ } catch (err) {
3790
+ if (err.code === "ENOENT") {
3791
+ continue;
3792
+ }
3793
+ lastError = err;
3794
+ }
3795
+ }
3796
+ if (lastError) {
3797
+ return { status: "error", error: lastError };
3798
+ }
3799
+ return { status: "not_found" };
3800
+ }
3801
+ async function getTailscaleStatus() {
3802
+ const result = await tryExec(["ip", "-4"]);
3803
+ if (result.status === "not_found") {
3804
+ return { installed: false, running: false, ip: null, hostname: null };
3805
+ }
3806
+ if (result.status === "error") {
3807
+ return { installed: true, running: false, ip: null, hostname: null };
3808
+ }
3809
+ const ip = result.stdout.split("\n")[0]?.trim();
3810
+ if (!ip || !/^100\./.test(ip)) {
3811
+ return { installed: true, running: false, ip: null, hostname: null };
3812
+ }
3813
+ let hostname = null;
3814
+ try {
3815
+ const statusResult = await tryExec(["status", "--json"]);
3816
+ if (statusResult.status === "success") {
3817
+ const json = JSON.parse(statusResult.stdout);
3818
+ hostname = json?.Self?.HostName ?? null;
3819
+ }
3820
+ } catch {
3821
+ }
3822
+ return { installed: true, running: true, ip, hostname };
3823
+ }
3824
+
3825
+ // src/network.ts
3762
3826
  function getLocalIP() {
3763
3827
  const interfaces = networkInterfaces();
3764
3828
  for (const addresses of Object.values(interfaces)) {
@@ -3771,6 +3835,20 @@ function getLocalIP() {
3771
3835
  }
3772
3836
  return "127.0.0.1";
3773
3837
  }
3838
+ async function resolveSessionIP(mode, isLocalhost) {
3839
+ if (isLocalhost) return "127.0.0.1";
3840
+ if (mode === "tailscale") {
3841
+ const status = await getTailscaleStatus();
3842
+ if (!status.running || !status.ip) {
3843
+ console.warn(
3844
+ "\n \u26A0 Tailscale is not running. Falling back to local WiFi.\n"
3845
+ );
3846
+ return getLocalIP();
3847
+ }
3848
+ return status.ip;
3849
+ }
3850
+ return getLocalIP();
3851
+ }
3774
3852
  function findAvailablePort(startPort) {
3775
3853
  return new Promise((resolve, reject) => {
3776
3854
  const server = createServer();
@@ -3959,41 +4037,126 @@ function displayQR(url) {
3959
4037
  });
3960
4038
  }
3961
4039
 
3962
- // src/index.ts
3963
- import { fileURLToPath as fileURLToPath2 } from "url";
3964
- import { join as join3, dirname as dirname2 } from "path";
3965
- import { spawn as spawn2 } from "child_process";
3966
- function preventSleep() {
4040
+ // src/config.ts
4041
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4042
+ import { homedir } from "os";
4043
+ import { join as join3 } from "path";
4044
+ var DEFAULT_CONFIG = {
4045
+ networkingMode: "local"
4046
+ };
4047
+ function getConfigDir() {
4048
+ return process.env.ITWILLSYNC_CONFIG_DIR || join3(homedir(), ".itwillsync");
4049
+ }
4050
+ function getConfigPath() {
4051
+ return join3(getConfigDir(), "config.json");
4052
+ }
4053
+ function configExists() {
4054
+ return existsSync(getConfigPath());
4055
+ }
4056
+ function loadConfig() {
3967
4057
  try {
3968
- if (process.platform === "darwin") {
3969
- const child = spawn2("caffeinate", ["-i", "-w", String(process.pid)], {
3970
- stdio: "ignore",
3971
- detached: true
4058
+ const raw = readFileSync(getConfigPath(), "utf-8");
4059
+ const parsed = JSON.parse(raw);
4060
+ return { ...DEFAULT_CONFIG, ...parsed };
4061
+ } catch {
4062
+ return { ...DEFAULT_CONFIG };
4063
+ }
4064
+ }
4065
+ function saveConfig(config) {
4066
+ const dir = getConfigDir();
4067
+ mkdirSync(dir, { recursive: true });
4068
+ writeFileSync(
4069
+ join3(dir, "config.json"),
4070
+ JSON.stringify(config, null, 2) + "\n",
4071
+ "utf-8"
4072
+ );
4073
+ }
4074
+
4075
+ // src/wizard.ts
4076
+ import * as p from "@clack/prompts";
4077
+ async function runSetupWizard() {
4078
+ p.intro("itwillsync setup");
4079
+ const networkingMode = await p.select({
4080
+ message: "How do you want to connect your phone?",
4081
+ options: [
4082
+ {
4083
+ value: "local",
4084
+ label: "Local Network",
4085
+ hint: "Phone and computer on the same WiFi"
4086
+ },
4087
+ {
4088
+ value: "tailscale",
4089
+ label: "Tailscale",
4090
+ hint: "Connect from anywhere via Tailscale VPN"
4091
+ }
4092
+ ]
4093
+ });
4094
+ if (p.isCancel(networkingMode)) {
4095
+ p.cancel("Setup cancelled.");
4096
+ process.exit(0);
4097
+ }
4098
+ if (networkingMode === "tailscale") {
4099
+ const s = p.spinner();
4100
+ s.start("Checking Tailscale status...");
4101
+ const status = await getTailscaleStatus();
4102
+ s.stop("Tailscale check complete");
4103
+ if (!status.installed) {
4104
+ p.log.error("Tailscale is not installed.");
4105
+ const installHint = process.platform === "darwin" ? "brew install tailscale or https://tailscale.com/download" : process.platform === "win32" ? "https://tailscale.com/download/windows" : "curl -fsSL https://tailscale.com/install.sh | sh";
4106
+ p.log.info(`Install: ${installHint}`);
4107
+ const saveAnyway = await p.confirm({
4108
+ message: "Save Tailscale as your default anyway? (You can install it later)",
4109
+ initialValue: false
3972
4110
  });
3973
- child.unref();
3974
- return child;
3975
- } else if (process.platform === "linux") {
3976
- return spawn2("systemd-inhibit", [
3977
- "--what=idle",
3978
- "--who=itwillsync",
3979
- "--why=Terminal sync session active",
3980
- "sleep",
3981
- "infinity"
3982
- ], { stdio: "ignore" });
4111
+ if (p.isCancel(saveAnyway) || !saveAnyway) {
4112
+ const config2 = { networkingMode: "local" };
4113
+ saveConfig(config2);
4114
+ p.outro("Saved as Local Network. Run 'itwillsync setup' to change later.");
4115
+ return config2;
4116
+ }
4117
+ } else if (!status.running) {
4118
+ p.log.warn("Tailscale is installed but not connected.");
4119
+ p.log.info("Run 'tailscale up' or start the Tailscale app to connect.");
4120
+ const saveAnyway = await p.confirm({
4121
+ message: "Save Tailscale as your default anyway?",
4122
+ initialValue: true
4123
+ });
4124
+ if (p.isCancel(saveAnyway) || !saveAnyway) {
4125
+ const config2 = { networkingMode: "local" };
4126
+ saveConfig(config2);
4127
+ p.outro("Saved as Local Network. Run 'itwillsync setup' to change later.");
4128
+ return config2;
4129
+ }
4130
+ } else {
4131
+ p.log.success(
4132
+ `Tailscale detected! IP: ${status.ip}${status.hostname ? ` (${status.hostname})` : ""}`
4133
+ );
3983
4134
  }
3984
- } catch {
3985
4135
  }
3986
- return null;
4136
+ const config = { networkingMode };
4137
+ saveConfig(config);
4138
+ const modeLabel = networkingMode === "tailscale" ? "Tailscale" : "Local Network";
4139
+ p.outro(`Saved! Your phone will connect via ${modeLabel}.`);
4140
+ return config;
3987
4141
  }
4142
+
4143
+ // src/cli-options.ts
3988
4144
  var DEFAULT_PORT = 3456;
3989
4145
  function parseArgs(argv) {
3990
4146
  const options = {
3991
4147
  port: DEFAULT_PORT,
3992
4148
  localhost: false,
3993
4149
  noQr: false,
3994
- command: []
4150
+ command: [],
4151
+ subcommand: null,
4152
+ tailscale: false,
4153
+ local: false
3995
4154
  };
3996
4155
  const args = argv.slice(2);
4156
+ if (args.length > 0 && args[0] === "setup") {
4157
+ options.subcommand = "setup";
4158
+ return options;
4159
+ }
3997
4160
  let i = 0;
3998
4161
  while (i < args.length) {
3999
4162
  const arg = args[i];
@@ -4006,6 +4169,12 @@ function parseArgs(argv) {
4006
4169
  } else if (arg === "--localhost") {
4007
4170
  options.localhost = true;
4008
4171
  i++;
4172
+ } else if (arg === "--tailscale") {
4173
+ options.tailscale = true;
4174
+ i++;
4175
+ } else if (arg === "--local") {
4176
+ options.local = true;
4177
+ i++;
4009
4178
  } else if (arg === "--no-qr") {
4010
4179
  options.noQr = true;
4011
4180
  i++;
@@ -4029,36 +4198,91 @@ itwillsync \u2014 Sync any terminal agent to your phone
4029
4198
  Usage:
4030
4199
  itwillsync [options] -- <command> [args...]
4031
4200
  itwillsync [options] <command> [args...]
4201
+ itwillsync setup
4032
4202
 
4033
4203
  Examples:
4034
4204
  itwillsync -- claude
4035
4205
  itwillsync -- aider --model gpt-4
4036
4206
  itwillsync bash
4037
4207
  itwillsync --port 8080 -- claude
4208
+ itwillsync --tailscale -- claude
4209
+ itwillsync setup
4210
+
4211
+ Commands:
4212
+ setup Run the setup wizard (configure networking mode)
4038
4213
 
4039
4214
  Options:
4040
- --port <number> Port to listen on (default: ${DEFAULT_PORT})
4041
- --localhost Bind to 127.0.0.1 only (no LAN access)
4042
- --no-qr Don't display QR code
4043
- -h, --help Show this help
4044
- -v, --version Show version
4215
+ --port <number> Port to listen on (default: ${DEFAULT_PORT})
4216
+ --localhost Bind to 127.0.0.1 only (no LAN access)
4217
+ --tailscale Use Tailscale IP for this session
4218
+ --local Use local network IP for this session
4219
+ --no-qr Don't display QR code
4220
+ -h, --help Show this help
4221
+ -v, --version Show version
4045
4222
  `);
4046
4223
  }
4224
+
4225
+ // src/index.ts
4226
+ import { fileURLToPath as fileURLToPath2 } from "url";
4227
+ import { join as join4, dirname as dirname2 } from "path";
4228
+ import { spawn as spawn2 } from "child_process";
4229
+ function preventSleep() {
4230
+ try {
4231
+ if (process.platform === "darwin") {
4232
+ const child = spawn2("caffeinate", ["-i", "-w", String(process.pid)], {
4233
+ stdio: "ignore",
4234
+ detached: true
4235
+ });
4236
+ child.unref();
4237
+ return child;
4238
+ } else if (process.platform === "linux") {
4239
+ return spawn2("systemd-inhibit", [
4240
+ "--what=idle",
4241
+ "--who=itwillsync",
4242
+ "--why=Terminal sync session active",
4243
+ "sleep",
4244
+ "infinity"
4245
+ ], { stdio: "ignore" });
4246
+ }
4247
+ } catch {
4248
+ }
4249
+ return null;
4250
+ }
4047
4251
  async function main() {
4048
4252
  const options = parseArgs(process.argv);
4253
+ if (options.subcommand === "setup") {
4254
+ await runSetupWizard();
4255
+ return;
4256
+ }
4257
+ if (options.tailscale && options.local) {
4258
+ console.error("Error: Cannot use both --tailscale and --local.\n");
4259
+ process.exit(1);
4260
+ }
4261
+ if (!configExists() && !options.tailscale && !options.local && process.stdin.isTTY) {
4262
+ await runSetupWizard();
4263
+ }
4049
4264
  if (options.command.length === 0) {
4050
4265
  console.error("Error: No command specified.\n");
4051
4266
  printHelp();
4052
4267
  process.exit(1);
4053
4268
  }
4269
+ let networkingMode = "local";
4270
+ if (options.tailscale) {
4271
+ networkingMode = "tailscale";
4272
+ } else if (options.local) {
4273
+ networkingMode = "local";
4274
+ } else {
4275
+ const config = loadConfig();
4276
+ networkingMode = config.networkingMode;
4277
+ }
4054
4278
  const [cmd, ...cmdArgs] = options.command;
4055
4279
  const token = generateToken();
4056
4280
  const port = await findAvailablePort(options.port);
4057
4281
  const host = options.localhost ? "127.0.0.1" : "0.0.0.0";
4058
- const ip = options.localhost ? "127.0.0.1" : getLocalIP();
4282
+ const ip = await resolveSessionIP(networkingMode, options.localhost);
4059
4283
  const url = `http://${ip}:${port}?token=${token}`;
4060
4284
  const __dirname = dirname2(fileURLToPath2(import.meta.url));
4061
- const webClientPath = join3(__dirname, "web-client");
4285
+ const webClientPath = join4(__dirname, "web-client");
4062
4286
  const ptyManager = new PtyManager(cmd, cmdArgs);
4063
4287
  const server = createSyncServer({
4064
4288
  ptyManager,