-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.js
More file actions
executable file
·401 lines (365 loc) · 18.8 KB
/
Copy pathcli.js
File metadata and controls
executable file
·401 lines (365 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
#!/usr/bin/env node
// codeout - the command users run. Prints the banner, starts the daemon, and shows
// how to pair a device (QR + a typeable code + the daemon fingerprint).
//
// codeout opens a Cloudflare tunnel: a stable <name>.codeout.dev, reachable anywhere
// codeout --local local only (LAN + Tailscale); nothing is exposed to the internet
// codeout --port N listen on port N (default 8400)
// codeout --help
//
// The tunnel is the DEFAULT and `--local` is the opt-out: the single branch that decides
// is `if (!args.local)` further down. This comment used to say the opposite (local by
// default, --public to expose), which is the most dangerous thing in the file to get
// wrong. The HELP text below and the published docs were always correct.
import os from 'node:os';
import { spawn, execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import qrcode from 'qrcode-terminal';
import { startDaemon } from './server.js';
import { TOKEN } from './auth.js';
import { initCrypto, loadIdentity, daemonPublicKeyB64, daemonFingerprint, mintPairCode, formatPairCode } from './crypto.js';
import { loadConfig, saveConfig, setMasterPassword, hasMasterPassword } from './config.js';
import platform from './platform/index.js';
import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync, chmodSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
const CODEOUT_HOME = process.env.CODEOUT_HOME || join(os.homedir(), '.codeout');
const TUNNEL_FILE = join(CODEOUT_HOME, 'tunnel.json'); // last broker tunnel, for `--destroy`
const pink = (s) => `\x1b[38;2;255;106;193m${s}\x1b[0m`;
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
// pixel CODEOUT
const BANNER = [
'█████ █████ ████ █████ █████ █ █ █████',
'█ █ █ █ █ █ █ █ █ █ █ ',
'█ █ █ █ █ ███ █ █ █ █ █ ',
'█ █ █ █ █ █ █ █ █ █ █ ',
'█████ █████ ████ █████ █████ █████ █ '
];
const HELP = `
${pink('codeout')} - your AI coding agents, self-hosted, in your pocket
codeout run + open a public tunnel: a stable <name>.codeout.dev, reachable anywhere
codeout --local local only (LAN + Tailscale); nothing is exposed to the internet
codeout --port N listen on port N (default 8400 or saved config)
codeout --setup interactive configuration wizard (port, password, exposure)
codeout --reset-password reset/change the master password for the control deck
codeout --pair print a fresh pairing code to add another device
codeout --install start on boot (opens the tunnel; add --local for local-only)
codeout --uninstall remove the boot service
codeout --destroy tear down this machine's tunnel + its DNS
codeout --help show this
Your code never leaves your machine. Over the public tunnel the daemon accepts only
PAIRED-DEVICE tokens, and the terminal/chat stream is end-to-end encrypted (sealed
ciphertext the tunnel can't read). The owner token and the plaintext stream are
local-only (LAN / Tailscale / this machine); they never work over the tunnel.
`;
function parseArgs() {
const a = process.argv.slice(2);
const i = a.indexOf('--port');
const KNOWN = new Set(['--public', '--local', '--pair', '--install', '--uninstall', '--destroy', '--setup', 'setup', '--reset-password', '--yes', '-y', '--help', '-h', '--port']);
const cfg = loadConfig();
return {
public: a.includes('--public'),
local: a.includes('--local') || (!a.includes('--public') && cfg.server?.exposure === 'local'),
pair: a.includes('--pair'),
setup: a.includes('--setup') || a.includes('setup'),
resetPassword: a.includes('--reset-password'),
destroy: a.includes('--destroy'),
install: a.includes('--install'),
uninstall: a.includes('--uninstall'),
yes: a.includes('--yes') || a.includes('-y'),
help: a.includes('--help') || a.includes('-h'),
port: Number(i >= 0 ? a[i + 1] : (process.env.PORT || cfg.server?.port)) || 8400,
unknown: a.filter((t, idx) => t.startsWith('-') && !KNOWN.has(t) && a[idx - 1] !== '--port')
};
}
function lanIPs() {
const out = [];
for (const ifs of Object.values(os.networkInterfaces())) {
for (const i of ifs || []) if (i.family === 'IPv4' && !i.internal) out.push(i.address);
}
return out;
}
// The cloudflared binary to run: 'cloudflared' on PATH, or an auto-downloaded copy under
// ~/.codeout/bin. Set by ensureCloudflared() before any tunnel starts.
let CLOUDFLARED = 'cloudflared';
/** Make cloudflared available with zero manual install: use it if it's on PATH, else reuse a
* previously-downloaded copy, else fetch the right release binary into ~/.codeout/bin. On any
* failure it stays 'cloudflared' and the tunnel simply won't come up (the caller falls back). */
async function ensureCloudflared() {
try { execFileSync('cloudflared', ['--version'], { stdio: 'ignore' }); return; } catch { /* not on PATH */ }
const dir = join(CODEOUT_HOME, 'bin');
const dest = join(dir, platform.cloudflaredBin);
if (existsSync(dest)) { CLOUDFLARED = dest; return; }
const asset = platform.cloudflaredAsset();
if (!asset) return; // unknown arch — leave as 'cloudflared'
try {
mkdirSync(dir, { recursive: true, mode: 0o700 });
process.stdout.write(' ' + dim('fetching cloudflared (one-time) ... '));
const r = await fetch(`https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.name}`, { redirect: 'follow' });
if (!r.ok) throw new Error('http ' + r.status);
const buf = Buffer.from(await r.arrayBuffer());
if (asset.tgz) {
const tmp = join(dir, asset.name);
writeFileSync(tmp, buf);
execFileSync('tar', ['-xzf', tmp, '-C', dir], { stdio: 'ignore' }); // extracts a `cloudflared` binary
try { unlinkSync(tmp); } catch { /* ignore */ }
} else {
// Atomic: write to a temp path then rename, so an interrupted write can't leave a
// truncated binary that existsSync() would then reuse forever.
const tmp = dest + '.tmp';
writeFileSync(tmp, buf, { mode: 0o755 });
renameSync(tmp, dest);
}
try { chmodSync(dest, 0o755); } catch { /* windows / already set */ }
CLOUDFLARED = dest;
console.log(pink('ok'));
} catch (e) {
console.log(dim('skipped (' + (e?.message ?? e) + ') — run with --local, or install cloudflared'));
}
}
// Quick Cloudflare tunnel (no account, ephemeral *.trycloudflare.com). Uses the cloudflared
// resolved by ensureCloudflared(). Returns the public URL, or null if it did not come up.
function openTunnel(port) {
return new Promise((resolve) => {
let proc;
try {
proc = spawn(CLOUDFLARED, ['--no-autoupdate', 'tunnel', '--url', `http://localhost:${port}`], { stdio: ['ignore', 'pipe', 'pipe'] });
} catch {
return resolve(null);
}
let url = null;
const scan = (b) => {
const m = String(b).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (m && !url) { url = m[0]; resolve(url); }
};
proc.stdout.on('data', scan);
proc.stderr.on('data', scan);
proc.on('error', () => resolve(null));
setTimeout(() => resolve(url), 15000);
});
}
// Managed broker: ask broker.codeout.dev for a clean slug.codeout.dev + connector token,
// then run it over http2. Returns the https URL, or null to fall back to a quick-tunnel.
async function brokerTunnel(port) {
if (port !== 8400) return null; // the broker provisions for the default port
let reg;
try {
const r = await fetch('https://broker.codeout.dev/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ pubkey: daemonPublicKeyB64() })
});
if (!r.ok) return null;
reg = await r.json();
} catch { return null; }
if (!reg?.hostname || !reg?.token) return null;
return new Promise((resolve) => {
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
let proc;
try {
// global flags (--no-autoupdate) MUST precede the `tunnel` subcommand, else
// cloudflared rejects the flag, prints usage, and exits 0 without connecting.
proc = spawn(CLOUDFLARED, ['--no-autoupdate', 'tunnel', 'run', '--protocol', 'http2', '--token', reg.token], { stdio: ['ignore', 'pipe', 'pipe'] });
} catch { return finish(null); }
const scan = (b) => { if (/Registered tunnel connection/.test(String(b))) { saveTunnel({ tunnel: reg.tunnel, hostname: reg.hostname, token: reg.token }); finish(`https://${reg.hostname}`); } };
proc.stdout.on('data', scan);
proc.stderr.on('data', scan);
proc.on('error', () => finish(null));
proc.on('exit', () => finish(null)); // exited before a live connection -> fall back, don't claim success
setTimeout(() => finish(null), 25000); // never connected in time -> fall back instead of lying "ready"
});
}
// Remember the broker tunnel we just opened so `--destroy` can tear it down later.
function saveTunnel(rec) {
try { mkdirSync(CODEOUT_HOME, { recursive: true, mode: 0o700 }); writeFileSync(TUNNEL_FILE, JSON.stringify(rec, null, 2), { mode: 0o600 }); }
catch { /* non-fatal: --destroy just won't have a record */ }
}
function confirm(q) {
return new Promise((resolve) => {
if (!process.stdin.isTTY) return resolve(true); // headless: assume intent
const rl = createInterface({ input: process.stdin, output: process.stdout });
rl.question(q + ' [y/N] ', (ans) => { rl.close(); resolve(/^y(es)?$/i.test(ans.trim())); });
});
}
// Ask a daemon that's ALREADY running for a fresh pairing code (so `--pair` doesn't
// start a second one). Returns the code payload, or null if nothing is listening.
async function mintAgainstRunning(port) {
try {
const r = await fetch(`http://127.0.0.1:${port}/api/pair/code`, { headers: { authorization: `Bearer ${TOKEN}` } });
return r.ok ? await r.json() : null;
} catch { return null; }
}
function printNewDevice(got, port) {
const ips = lanIPs();
const ts = ips.find((x) => x.startsWith('100.'));
const lan = ips.find((x) => x.startsWith('192.168.') || x.startsWith('10.') || /^172\.(1[6-9]|2\d|3[01])\./.test(x));
let publicHost = null;
try { publicHost = JSON.parse(readFileSync(TUNNEL_FILE, 'utf8'))?.hostname || null; } catch { /* no tunnel on record */ }
const address = publicHost || (lan ? `${lan}:${port}` : (ts ? `${ts}:${port}` : `localhost:${port}`));
const uri = `codeout://pair?host=${encodeURIComponent(address)}&spk=${daemonPublicKeyB64()}&c=${got.code}&v=2`;
let qr = '';
qrcode.generate(uri, { small: true }, (o) => { qr = o; });
console.log();
console.log(' ' + bold('Pair a new device') + dim(' scan the QR, or type the code in app.codeout.dev / iOS app'));
console.log(qr.replace(/\n/g, '\n ').replace(/^/, ' '));
console.log(' address ' + (publicHost ? `https://${publicHost}` : (lan ? `http://${lan}:${port}` : `http://localhost:${port}`)));
if (lan && (publicHost || ts)) console.log(' LAN Network ' + dim(`http://${lan}:${port}`));
if (ts && (publicHost || lan)) console.log(' Tailscale ' + dim(`http://${ts}:${port}`));
console.log(' code ' + pink(got.display || formatPairCode(got.code)) + dim(' valid 5 min'));
console.log(' fingerprint ' + bold(got.fingerprint || daemonFingerprint()));
console.log();
}
// Tear down this machine's broker tunnel + its DNS (the broker checks the token belongs
// to the tunnel, so only its owner can remove it).
async function destroy(skipConfirm) {
if (!existsSync(TUNNEL_FILE)) { console.log(dim(' No public tunnel on record for this machine. Nothing to destroy.')); return; }
let t;
try { t = JSON.parse(readFileSync(TUNNEL_FILE, 'utf8')); }
catch { try { unlinkSync(TUNNEL_FILE); } catch { /* ignore */ } console.log(dim(' tunnel record was unreadable; removed it.')); return; }
console.log(' This removes the tunnel ' + bold(t.hostname || t.tunnel) + ' and its DNS.');
if (!skipConfirm && !(await confirm(' Destroy it?'))) { console.log(dim(' cancelled.')); return; }
try {
const r = await fetch('https://broker.codeout.dev/deregister', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tunnel: t.tunnel, token: t.token, hostname: t.hostname })
});
const j = await r.json().catch(() => ({}));
if (r.ok && j.ok) console.log(pink(' destroyed') + dim(' ' + (t.hostname || '')));
else console.log(dim(' broker could not remove it: ' + (j.error || r.status)));
} catch (e) { console.log(dim(' could not reach the broker: ' + (e?.message ?? e))); }
try { unlinkSync(TUNNEL_FILE); } catch { /* ignore */ }
}
// Boot service: install/uninstall are OS-specific (systemd on POSIX, Task Scheduler on
// Windows) and live in the platform layer. Here we only build the argv that launches the
// daemon and hand it over, so this file never branches on the OS.
function install(opts) {
const execArgs = [process.execPath, fileURLToPath(import.meta.url)];
if (opts.local) execArgs.push('--local');
if (opts.port && opts.port !== 8400) execArgs.push('--port', String(opts.port));
platform.installService(execArgs, opts, { dim, pink });
}
function uninstall() {
platform.uninstallService({ dim, pink });
}
async function runResetPassword() {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q) => new Promise((resolve) => rl.question(q, (a) => resolve(a.trim())));
console.log();
console.log(' ' + bold('codeout - Reset Master Password'));
const pw = await ask(' Enter new master password: ');
if (!pw) {
console.log(dim(' Password cannot be empty. Aborted.'));
rl.close();
process.exit(1);
}
const confirm = await ask(' Confirm new password: ');
if (pw !== confirm) {
console.log(dim(' Passwords do not match. Aborted.'));
rl.close();
process.exit(1);
}
setMasterPassword(pw);
console.log(' ' + pink('✓') + ' Master password updated in ~/.codeout/config.json');
console.log();
rl.close();
}
async function runSetup() {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q, def) => new Promise((resolve) => rl.question(` ${q}${def ? ` [${def}]` : ''}: `, (a) => resolve(a.trim() || def)));
console.log();
for (const row of BANNER) console.log(' ' + pink(row));
console.log();
console.log(' ' + bold('codeout interactive setup & configuration wizard'));
console.log(' ' + dim('Configure port, admin password, exposure mode, and AI engines'));
console.log();
const cfg = loadConfig();
const defaultPort = cfg.server?.port || 8400;
const portAns = await ask('Daemon listen port', String(defaultPort));
const port = Number(portAns) || 8400;
const setPw = await ask('Set/update control deck master password? (y/n)', hasMasterPassword() ? 'n' : 'y');
if (setPw.toLowerCase().startsWith('y')) {
const pw = await ask('Enter new master password', '');
if (pw) {
setMasterPassword(pw);
console.log(' ' + pink('✓') + ' Master password saved.');
}
}
const exp = await ask('Network exposure: 1) Public E2E Tunnel 2) Local/Tailscale only', cfg.server?.exposure === 'local' ? '2' : '1');
const exposure = exp === '2' ? 'local' : 'tunnel';
cfg.server.port = port;
cfg.server.exposure = exposure;
saveConfig(cfg);
console.log();
console.log(' ' + pink('✓') + ' Configuration saved to ~/.codeout/config.json');
console.log();
rl.close();
}
const args = parseArgs();
if (args.help) { console.log(HELP); process.exit(0); }
if (args.unknown.length) {
console.error(' unknown option: ' + args.unknown.join(' '));
console.log(HELP);
process.exit(1);
}
if (args.resetPassword) { await runResetPassword(); process.exit(0); }
if (args.setup) { await runSetup(); process.exit(0); }
if (args.uninstall) { uninstall(); process.exit(0); }
if (args.install) { install(args); process.exit(0); }
await initCrypto();
loadIdentity();
if (args.destroy) { await destroy(args.yes); process.exit(0); }
if (args.pair) {
// A freshly-installed service may still be binding the port, so retry briefly.
// Never fall through to starting a daemon here - that races the service for the port.
let got = null;
for (let i = 0; i < 6 && !got; i++) {
got = await mintAgainstRunning(args.port);
if (!got && i < 5) await new Promise((r) => setTimeout(r, 500));
}
if (got) { printNewDevice(got, args.port); process.exit(0); }
console.log(dim(` codeout is not running on port ${args.port}. Start it (codeout, or codeout --install), then run codeout --pair.`));
process.exit(1);
}
const { port } = await startDaemon({ port: args.port, host: '0.0.0.0' });
const ips = lanIPs();
const ts = ips.find((i) => i.startsWith('100.'));
const lan = ips.find((i) => i.startsWith('192.168.') || i.startsWith('10.') || /^172\.(1[6-9]|2\d|3[01])\./.test(i));
let address = `${lan || ts || 'localhost'}:${port}`;
let publicUrl = null;
console.log();
for (const row of BANNER) console.log(' ' + pink(row));
console.log();
console.log(' ' + dim('self-hosted AI coding agents, in your pocket'));
console.log();
if (!args.local) {
await ensureCloudflared(); // fetch cloudflared into ~/.codeout/bin if it isn't already installed
process.stdout.write(' opening tunnel ' + dim('(codeout.dev)') + ' ... ');
publicUrl = await brokerTunnel(port);
if (!publicUrl) { process.stdout.write(dim('broker unavailable, quick-tunnel ... ')); publicUrl = await openTunnel(port); }
if (publicUrl) { console.log(pink('ready')); address = publicUrl.replace(/^https?:\/\//, ''); }
else { console.log(dim('no tunnel - run with --local, or check your network.')); }
}
const code = mintPairCode();
const uri = `codeout://pair?host=${encodeURIComponent(address)}&spk=${daemonPublicKeyB64()}&c=${code}&v=2`;
let qr = '';
qrcode.generate(uri, { small: true }, (out) => { qr = out; });
console.log(' ' + bold('Host Control Deck & Endpoints:'));
console.log(' Localhost http://localhost:' + port + '/');
if (lan) console.log(' LAN Network http://' + lan + ':' + port + '/ ' + dim('(Home Wi-Fi / Local Network)'));
if (ts) console.log(' Tailscale http://' + ts + ':' + port + '/ ' + dim('(Tailnet VPN)'));
if (publicUrl) console.log(' Tunnel (E2E) ' + publicUrl + ' ' + dim('(Public Internet)'));
console.log();
console.log(' ' + bold('Pair a device') + dim(' scan the QR, or type the code in app.codeout.dev / iOS app'));
console.log(qr.replace(/\n/g, '\n ').replace(/^/, ' '));
console.log(' address ' + (publicUrl || (lan ? `http://${lan}:${port}` : (ts ? `http://${ts}:${port}` : `http://localhost:${port}`))));
console.log(' code ' + pink(formatPairCode(code)) + dim(' valid 5 min'));
console.log(' fingerprint ' + bold(daemonFingerprint()) + dim(' confirm this matches in the app'));
console.log();
console.log(' ' + (publicUrl
? dim('Reachable from anywhere via the tunnel above. The link stays end-to-end encrypted.')
: dim('Local only (LAN / Wi-Fi + Tailscale). Run `codeout` with no flag to open a public tunnel.')));
console.log(' ' + dim('Ctrl-C to stop. Your sessions keep running and reattach next launch.'));
console.log();