agent-yes 1.298.1 → 1.299.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/dist/{SUPPORTED_CLIS-AzChegaJ.js → SUPPORTED_CLIS-BIlQQJa6.js} +3 -3
- package/dist/{SUPPORTED_CLIS-sxjBFTrE.js → SUPPORTED_CLIS-Ct9reIJH.js} +2 -2
- package/dist/{agentShare-CDOvU64K.js → agentShare-uzFuaLUH.js} +4 -4
- package/dist/{callback-DBOgmPV6.js → callback-BxTZx3xJ.js} +3 -3
- package/dist/{callback-Dr5rqkUu.js → callback-DRrURxmb.js} +4 -4
- package/dist/cli.js +7 -7
- package/dist/{cmdPs-DKuMlynW.js → cmdPs-BGIWbeBM.js} +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-Ceyhz4el.js → notifyDaemon-EEbnpoYp.js} +4 -4
- package/dist/{parentPingLoop-C0hekeg6.js → parentPingLoop-C1XXpKK7.js} +2 -2
- package/dist/remotes-BDMnvGI6.js +5 -0
- package/dist/{remotes-5P__pAXA.js → remotes-DrYe_nfw.js} +101 -18
- package/dist/{rustBinary-BW3BGZ4y.js → rustBinary--pof-MTj.js} +2 -2
- package/dist/{rustBinary-0cD4pQIE.js → rustBinary-B7rq3nuz.js} +2 -2
- package/dist/{schedule-BRC5cDkf.js → schedule-BFUe9-Mf.js} +4 -4
- package/dist/{serve-BD-7GaKa.js → serve-Q4dnZc5l.js} +40 -21
- package/dist/{setup-Dad2A_-o.js → setup-aEuGSFjB.js} +3 -3
- package/dist/shareCmd-DkITELaM.js +415 -0
- package/dist/{subcommands--9gcc0ew.js → subcommands-CUWPMI_r.js} +36 -22
- package/dist/{subcommands-ClQCwxvk.js → subcommands-CuRZt3X6.js} +3 -3
- package/dist/{systemPathLink-VV-Vm6y2.js → systemPathLink-DoyBuRQq.js} +1 -1
- package/dist/{terminal-Bam4XVWi.js → terminal-CUn1_oPd.js} +2 -2
- package/dist/{todoCli-BDttv8mk.js → todoCli-DwYlxIAY.js} +1 -1
- package/dist/{tray-DWWsVS7Y.js → tray-BcpHVeMk.js} +1 -1
- package/dist/{trayApp-DOgsH0en.js → trayApp-BHwAJFzl.js} +2 -2
- package/dist/trayApp-ucuB_j5l.js +5 -0
- package/dist/{ts-BePSXUQl.js → ts-oIxnyQbE.js} +3 -3
- package/dist/{versionChecker-D6zM6Pdv.js → versionChecker-DOgDaagD.js} +2 -2
- package/dist/{webrtcLink-CnQf9pmu.js → webrtcLink-DFRyWNMs.js} +2 -2
- package/dist/{webrtcRemote-0RGrSwqd.js → webrtcRemote-Cd4vTrgv.js} +2 -2
- package/dist/{widget-DJgiOfgE.js → widget-CuagzQwz.js} +3 -3
- package/dist/{ws-Covfyaxn.js → ws-DcJBGzQP.js} +2 -2
- package/dist/{ws-DjGQGPCq.js → ws-ID7-zDiJ.js} +4 -4
- package/lab/ui/index.html +9 -3
- package/package.json +1 -1
- package/ts/remotes.ts +128 -13
- package/ts/serve.ts +22 -0
- package/ts/shareCmd.ts +369 -0
- package/ts/shareCore.spec.ts +236 -0
- package/ts/shareCore.ts +148 -0
- package/ts/subcommands.spec.ts +19 -0
- package/ts/subcommands.ts +33 -5
- package/ts/webrtcLink.ts +3 -1
- package/dist/remotes-CY69MeQB.js +0 -5
- package/dist/trayApp-s2VuJUP4.js +0 -5
package/ts/shareCmd.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { networkInterfaces } from "node:os";
|
|
2
|
+
import { stdin, stdout } from "node:process";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SHARE_PORT,
|
|
6
|
+
DEFAULT_TS_PATH,
|
|
7
|
+
SHARE_MODES,
|
|
8
|
+
type ShareMode,
|
|
9
|
+
dropFlagWithValue,
|
|
10
|
+
findTailscaleRoute,
|
|
11
|
+
flagValue,
|
|
12
|
+
hasFlag,
|
|
13
|
+
httpShareUrl,
|
|
14
|
+
pickLanIp,
|
|
15
|
+
planDaemonArgs,
|
|
16
|
+
portlessInstallArgv,
|
|
17
|
+
tailscaleDnsName,
|
|
18
|
+
tailscaleServeArgv,
|
|
19
|
+
tailscaleServeOffArgv,
|
|
20
|
+
} from "./shareCore.ts";
|
|
21
|
+
|
|
22
|
+
// `ay share [local|portless|lan|tailscale|webrtc]` — guided "make this machine reachable".
|
|
23
|
+
// Picks HOW (a TTY picker when no mode is given), makes sure the serve daemon runs
|
|
24
|
+
// with the args that mode needs (delegating to `ay serve install`), and prints ONE
|
|
25
|
+
// URL that both opens the web console in a browser and is what another machine
|
|
26
|
+
// passes to `ay connect`. The HTTP modes carry the host's serve token in the
|
|
27
|
+
// fragment (#k=), the webrtc mode the room link — same secret for UI and CLI.
|
|
28
|
+
//
|
|
29
|
+
// Every command we'd run on the user's behalf — our own daemon install and,
|
|
30
|
+
// above all, third-party tools (tailscale, portless) — is printed verbatim and
|
|
31
|
+
// gated on a [y/N] prompt (default no), so what happens is exactly what they
|
|
32
|
+
// read. `--yes` is the scripted form of that consent; the commands still print.
|
|
33
|
+
|
|
34
|
+
// Spawn a CLI that prints JSON, with a deadline. On timeout the reader is
|
|
35
|
+
// abandoned, not awaited (see CLAUDE.md: a grandchild may hold the pipe open).
|
|
36
|
+
async function runJson(cmd: string[], timeoutMs = 4000): Promise<any | null> {
|
|
37
|
+
try {
|
|
38
|
+
const p = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" });
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
const text = await Promise.race([
|
|
41
|
+
new Response(p.stdout).text(),
|
|
42
|
+
new Promise<null>((r) => (timer = setTimeout(() => r(null), timeoutMs))),
|
|
43
|
+
]);
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
if (text === null) {
|
|
46
|
+
p.kill();
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return JSON.parse(text);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function detectTailscale(): Promise<{ dns: string; serveStatus: any } | null> {
|
|
56
|
+
if (!Bun.which("tailscale")) return null;
|
|
57
|
+
const dns = tailscaleDnsName(await runJson(["tailscale", "status", "--json"]));
|
|
58
|
+
if (!dns) return null;
|
|
59
|
+
return { dns, serveStatus: await runJson(["tailscale", "serve", "status", "--json"]) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function ask(q: string): Promise<string> {
|
|
63
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
64
|
+
try {
|
|
65
|
+
return (await rl.question(q)).trim();
|
|
66
|
+
} finally {
|
|
67
|
+
rl.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const showCmd = (argv: string[]) => argv.map((a) => (/[\s'"$]/.test(a) ? `'${a}'` : a)).join(" ");
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Print the exact commands, then ask [y/N] (default NO). `--yes` answers yes;
|
|
75
|
+
* without a TTY and without --yes it's a no, and the caller prints the manual
|
|
76
|
+
* steps. Returns whether the user approved.
|
|
77
|
+
*/
|
|
78
|
+
async function confirmRun(why: string, cmds: string[][], yes: boolean): Promise<boolean> {
|
|
79
|
+
process.stderr.write(`\n${why}\n${cmds.map((c) => ` $ ${showCmd(c)}\n`).join("")}`);
|
|
80
|
+
if (yes) {
|
|
81
|
+
process.stderr.write(`(--yes: running it)\n`);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (!(stdin.isTTY && stdout.isTTY)) return false;
|
|
85
|
+
return /^y(es)?$/i.test(await ask(`run ${cmds.length > 1 ? "these" : "this"}? [y/N]: `));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function runInherit(argv: string[]): Promise<number> {
|
|
89
|
+
try {
|
|
90
|
+
const p = Bun.spawn(argv, { stdio: ["inherit", "inherit", "inherit"] });
|
|
91
|
+
return (await p.exited) ?? 1;
|
|
92
|
+
} catch (e) {
|
|
93
|
+
process.stderr.write(`failed to run ${argv[0]}: ${(e as Error).message}\n`);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** GET <base>/api/version with the token — the end-to-end health check. */
|
|
99
|
+
async function probe(base: string, token: string): Promise<string | null> {
|
|
100
|
+
try {
|
|
101
|
+
const r = await fetch(`${base}/api/version`, {
|
|
102
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
103
|
+
signal: AbortSignal.timeout(8000),
|
|
104
|
+
});
|
|
105
|
+
return r.ok ? null : `HTTP ${r.status}`;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return (e as Error).message;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const HELP =
|
|
112
|
+
`Usage: ay share [local|portless|lan|tailscale|webrtc] [options]\n` +
|
|
113
|
+
` ay share status\n\n` +
|
|
114
|
+
`Make this machine's agents reachable and print ONE share URL: open it in a\n` +
|
|
115
|
+
`browser for the web console, or pass it to \`ay connect\` on another machine.\n` +
|
|
116
|
+
`With no mode (in a terminal) it asks which way to share.\n\n` +
|
|
117
|
+
`Modes:\n` +
|
|
118
|
+
` local http://127.0.0.1:<port> — this machine only\n` +
|
|
119
|
+
` portless https://agent-yes.localhost — this machine, via portless (3rd-party)\n` +
|
|
120
|
+
` lan http://<lan-ip>:<port> — anyone on your network (plain HTTP)\n` +
|
|
121
|
+
` tailscale https://<machine>.<tailnet>.ts.net/ay/ — your tailnet devices,\n` +
|
|
122
|
+
` via \`tailscale serve\` (3rd-party)\n` +
|
|
123
|
+
` webrtc https://agent-yes.com/w/#… — anywhere, end-to-end encrypted\n` +
|
|
124
|
+
` through the agent-yes.com signaling server\n\n` +
|
|
125
|
+
`Every command ay would run for you (daemon install, tailscale, portless) is\n` +
|
|
126
|
+
`shown first and needs a y at a [y/N] prompt.\n\n` +
|
|
127
|
+
`Options:\n` +
|
|
128
|
+
` --port N HTTP port (default: ${DEFAULT_SHARE_PORT}, or the daemon's)\n` +
|
|
129
|
+
` --path /ay tailscale mount path (default: ${DEFAULT_TS_PATH})\n` +
|
|
130
|
+
` -y, --yes approve the shown commands without prompting\n` +
|
|
131
|
+
` --json print {mode, url} as JSON\n\n` +
|
|
132
|
+
`The URL carries this host's serve token: whoever holds it can read and steer\n` +
|
|
133
|
+
`every agent here. Rotate: rm ~/.agent-yes/.serve-token && ay serve install\n`;
|
|
134
|
+
|
|
135
|
+
// ay share status — end-to-end health of each way this machine is shared.
|
|
136
|
+
async function cmdShareStatus(mount: string): Promise<number> {
|
|
137
|
+
const { inspectServeDaemon } = await import("./serve.ts");
|
|
138
|
+
const [d, ts] = await Promise.all([inspectServeDaemon(), detectTailscale()]);
|
|
139
|
+
const line = (k: string, v: string) => process.stdout.write(`${k.padEnd(11)}${v}\n`);
|
|
140
|
+
if (d.args === null) {
|
|
141
|
+
line("daemon:", "not installed — run `ay share`");
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
line("daemon:", `ay serve ${d.args.join(" ") || "(defaults: portless)"}`);
|
|
145
|
+
const webrtc = hasFlag(d.args, "--webrtc") || hasFlag(d.args, "--share");
|
|
146
|
+
line(
|
|
147
|
+
"http:",
|
|
148
|
+
d.port ? (d.httpUp ? `up on 127.0.0.1:${d.port}` : `DOWN (127.0.0.1:${d.port})`) : "off",
|
|
149
|
+
);
|
|
150
|
+
if (d.consoleUrl) line("portless:", d.httpUp ? d.consoleUrl.replace(/#.*/, "") : "DOWN");
|
|
151
|
+
line("webrtc:", webrtc ? "on (agent-yes.com room)" : "off");
|
|
152
|
+
if (!ts) {
|
|
153
|
+
line("tailscale:", Bun.which("tailscale") ? "not running" : "not installed");
|
|
154
|
+
return d.httpUp || webrtc ? 0 : 1;
|
|
155
|
+
}
|
|
156
|
+
const route = d.port
|
|
157
|
+
? findTailscaleRoute(ts.serveStatus, ts.dns, mount, d.port)
|
|
158
|
+
: { status: "missing" as const };
|
|
159
|
+
if (route.status !== "ok") {
|
|
160
|
+
line(
|
|
161
|
+
"tailscale:",
|
|
162
|
+
route.status === "conflict" ? `${mount} → ${route.proxy} (not ay)` : `no ${mount} route`,
|
|
163
|
+
);
|
|
164
|
+
return d.httpUp || webrtc ? 0 : 1;
|
|
165
|
+
}
|
|
166
|
+
const base = `https://${ts.dns}${mount}`;
|
|
167
|
+
const err = await probe(base, d.token);
|
|
168
|
+
line("tailscale:", err ? `route ok, but ${base}/ FAILS: ${err}` : `${base}/ ok`);
|
|
169
|
+
return err ? 1 : 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function cmdShare(rest: string[]): Promise<number> {
|
|
173
|
+
if (rest.includes("-h") || rest.includes("--help")) {
|
|
174
|
+
process.stdout.write(HELP);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
const json = rest.includes("--json");
|
|
178
|
+
const yes = rest.includes("-y") || rest.includes("--yes");
|
|
179
|
+
const portArg = flagValue(rest, "--port");
|
|
180
|
+
const mount = "/" + (flagValue(rest, "--path") ?? DEFAULT_TS_PATH).replace(/^\/+|\/+$/g, "");
|
|
181
|
+
const valueIdx = new Set(
|
|
182
|
+
["--port", "--path"]
|
|
183
|
+
.map((f) => rest.indexOf(f))
|
|
184
|
+
.filter((i) => i >= 0)
|
|
185
|
+
.map((i) => i + 1),
|
|
186
|
+
);
|
|
187
|
+
const positional = rest.filter((a, i) => !a.startsWith("-") && !valueIdx.has(i));
|
|
188
|
+
const say = (s: string) => (json ? process.stderr : process.stdout).write(s);
|
|
189
|
+
|
|
190
|
+
if (positional[0] === "status") return cmdShareStatus(mount);
|
|
191
|
+
let mode = positional[0] as ShareMode | undefined;
|
|
192
|
+
if (mode && !SHARE_MODES.includes(mode)) {
|
|
193
|
+
process.stderr.write(`ay share: unknown mode '${mode}' — one of ${SHARE_MODES.join(", ")}\n`);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const { inspectServeDaemon, cmdServe } = await import("./serve.ts");
|
|
198
|
+
const [daemon, ts] = await Promise.all([inspectServeDaemon(), detectTailscale()]);
|
|
199
|
+
const lanIp = pickLanIp(networkInterfaces());
|
|
200
|
+
|
|
201
|
+
if (!mode) {
|
|
202
|
+
const opts: { mode: ShareMode; desc: string; ok: boolean }[] = [
|
|
203
|
+
{ mode: "local", desc: "http://127.0.0.1 — this machine only", ok: true },
|
|
204
|
+
{
|
|
205
|
+
mode: "portless",
|
|
206
|
+
desc: "https://agent-yes.localhost — this machine (3rd-party portless)",
|
|
207
|
+
ok: true,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
mode: "lan",
|
|
211
|
+
desc: lanIp ? `http://${lanIp}:<port> — your network, plain HTTP` : "no LAN address found",
|
|
212
|
+
ok: !!lanIp,
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
mode: "tailscale",
|
|
216
|
+
desc: ts ? `https://${ts.dns}${mount}/ — your tailnet` : "tailscale not installed/running",
|
|
217
|
+
ok: !!ts,
|
|
218
|
+
},
|
|
219
|
+
{ mode: "webrtc", desc: "agent-yes.com link — anywhere, e2e encrypted", ok: true },
|
|
220
|
+
];
|
|
221
|
+
const def = ts ? 4 : 1;
|
|
222
|
+
if (!(stdin.isTTY && stdout.isTTY)) {
|
|
223
|
+
process.stderr.write(`ay share: pick a mode — ${SHARE_MODES.join(" | ")}\n\n` + HELP);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
say(`How should this machine be shared?\n`);
|
|
227
|
+
opts.forEach((o, i) =>
|
|
228
|
+
say(` ${i + 1}) ${o.mode.padEnd(10)} ${o.desc}${o.ok ? "" : " (unavailable)"}\n`),
|
|
229
|
+
);
|
|
230
|
+
const ans = await ask(`choose [${def}]: `);
|
|
231
|
+
const n = ans ? Number(ans) : def;
|
|
232
|
+
const picked = opts[n - 1] ?? opts.find((o) => o.mode === ans);
|
|
233
|
+
if (!picked || !picked.ok) {
|
|
234
|
+
process.stderr.write(`ay share: '${ans}' is not an available choice\n`);
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
mode = picked.mode;
|
|
238
|
+
}
|
|
239
|
+
if (mode === "lan" && !lanIp) {
|
|
240
|
+
process.stderr.write(`ay share: no private LAN IPv4 address found on this machine\n`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
if (mode === "tailscale" && !ts) {
|
|
244
|
+
process.stderr.write(
|
|
245
|
+
`ay share: tailscale isn't running here — install it and \`tailscale up\` first\n` +
|
|
246
|
+
` https://tailscale.com/download\n`,
|
|
247
|
+
);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 0. portless is a third-party dependency: offer to install it, never silently.
|
|
252
|
+
if (mode === "portless" && !Bun.which("portless")) {
|
|
253
|
+
const argv = portlessInstallArgv((b) => !!Bun.which(b));
|
|
254
|
+
if (!argv) {
|
|
255
|
+
process.stderr.write(
|
|
256
|
+
`ay share: portless isn't installed and neither npm nor bun is on PATH\n`,
|
|
257
|
+
);
|
|
258
|
+
return 1;
|
|
259
|
+
}
|
|
260
|
+
if (
|
|
261
|
+
!(await confirmRun("portless (third-party) isn't installed. To install it:", [argv], yes))
|
|
262
|
+
) {
|
|
263
|
+
process.stderr.write(`not installed — run it yourself, then \`ay share portless\`\n`);
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
if ((await runInherit(argv)) !== 0 || !Bun.which("portless")) {
|
|
267
|
+
process.stderr.write(`ay share: installing portless failed\n`);
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 1. The daemon: install / reconfigure it when the mode needs different args.
|
|
273
|
+
const want = planDaemonArgs(daemon.args, mode, portArg ? Number(portArg) : undefined);
|
|
274
|
+
if (
|
|
275
|
+
portArg &&
|
|
276
|
+
mode !== "webrtc" &&
|
|
277
|
+
mode !== "portless" &&
|
|
278
|
+
flagValue(want, "--port") !== portArg
|
|
279
|
+
) {
|
|
280
|
+
want.splice(0, want.length, ...dropFlagWithValue(want, "--port"), "--port", portArg);
|
|
281
|
+
}
|
|
282
|
+
const changed = JSON.stringify(want) !== JSON.stringify(daemon.args);
|
|
283
|
+
const down = mode !== "webrtc" && !daemon.httpUp;
|
|
284
|
+
let state = daemon;
|
|
285
|
+
if (changed || down) {
|
|
286
|
+
const why =
|
|
287
|
+
daemon.args === null
|
|
288
|
+
? `no serve daemon yet — sharing needs one:`
|
|
289
|
+
: changed
|
|
290
|
+
? `the serve daemon runs \`ay serve ${daemon.args.join(" ") || "(defaults)"}\`; ${mode} needs:`
|
|
291
|
+
: `the serve daemon isn't answering — restart it:`;
|
|
292
|
+
const note =
|
|
293
|
+
mode === "portless"
|
|
294
|
+
? `\n(the daemon then runs under third-party portless: \`portless agent-yes ay serve …\`)`
|
|
295
|
+
: "";
|
|
296
|
+
if (!(await confirmRun(why + note, [["ay", "serve", "install", ...want]], yes))) {
|
|
297
|
+
process.stderr.write(`not changed — run the command above, then \`ay share ${mode}\`\n`);
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
const code = await cmdServe(["install", ...want]);
|
|
301
|
+
if (code !== 0) return code;
|
|
302
|
+
state = await inspectServeDaemon();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 2. The URL (+ for tailscale, the route and an end-to-end check).
|
|
306
|
+
let url: string;
|
|
307
|
+
const port = state.port ?? (portArg ? Number(portArg) : DEFAULT_SHARE_PORT);
|
|
308
|
+
if (mode === "webrtc") {
|
|
309
|
+
const { loadOrCreateShareRoom, shareLinkFromRoomUrl } = await import("./share.ts");
|
|
310
|
+
url = shareLinkFromRoomUrl(await loadOrCreateShareRoom());
|
|
311
|
+
} else if (mode === "portless") {
|
|
312
|
+
url = state.consoleUrl ?? httpShareUrl("https://agent-yes.localhost", state.token);
|
|
313
|
+
} else if (mode === "local") {
|
|
314
|
+
url = httpShareUrl(`http://127.0.0.1:${port}`, state.token);
|
|
315
|
+
} else if (mode === "lan") {
|
|
316
|
+
url = httpShareUrl(`http://${lanIp}:${port}`, state.token);
|
|
317
|
+
} else {
|
|
318
|
+
const base = `https://${ts!.dns}${mount}`;
|
|
319
|
+
url = httpShareUrl(base, state.token);
|
|
320
|
+
const route = findTailscaleRoute(ts!.serveStatus, ts!.dns, mount, port);
|
|
321
|
+
if (route.status !== "ok") {
|
|
322
|
+
const cmds = [
|
|
323
|
+
...(route.status === "conflict" ? [tailscaleServeOffArgv(mount)] : []),
|
|
324
|
+
tailscaleServeArgv(mount, port),
|
|
325
|
+
];
|
|
326
|
+
const why =
|
|
327
|
+
(route.status === "conflict"
|
|
328
|
+
? `${mount} on your tailnet already proxies to ${route.proxy}; to point it at ay instead`
|
|
329
|
+
: `to route https://${ts!.dns}${mount}/ to ay (tailnet-only, TLS by tailscale)`) +
|
|
330
|
+
`, ay will run third-party tailscale:`;
|
|
331
|
+
if (await confirmRun(why, cmds, yes)) {
|
|
332
|
+
for (const c of cmds) {
|
|
333
|
+
if ((await runInherit(c)) !== 0) {
|
|
334
|
+
process.stderr.write(
|
|
335
|
+
`ay share: \`${showCmd(c)}\` failed. "Access denied"? allow your user once:\n` +
|
|
336
|
+
` sudo tailscale set --operator=$USER\n`,
|
|
337
|
+
);
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
process.stderr.write(
|
|
343
|
+
`not changed — run the command(s) above yourself, then \`ay share status\`\n`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Health: the route actually reaches THIS daemon with THIS token, end to end.
|
|
348
|
+
const err = await probe(base, state.token);
|
|
349
|
+
say(
|
|
350
|
+
err
|
|
351
|
+
? `\nhealth: ${base}/ not reachable yet (${err}) — check \`ay share status\`\n`
|
|
352
|
+
: `\nhealth: ${base}/ ok\n`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (json) {
|
|
357
|
+
process.stdout.write(JSON.stringify({ mode, url }) + "\n");
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
say(
|
|
361
|
+
`\nshare URL (web console + CLI, same token):\n ${url}\n\n` +
|
|
362
|
+
`open it in a browser, or from another machine:\n ay connect '${url}'\n` +
|
|
363
|
+
(mode === "lan"
|
|
364
|
+
? `\nnote: plain HTTP — the token is visible to anyone sniffing this network.\n`
|
|
365
|
+
: ``) +
|
|
366
|
+
`anyone with this URL can read and steer every agent here.\n`,
|
|
367
|
+
);
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { cmdConnect, defaultConnectAlias, parseShareUrl } from "./remotes.ts";
|
|
3
|
+
import {
|
|
4
|
+
findTailscaleRoute,
|
|
5
|
+
httpShareUrl,
|
|
6
|
+
pickLanIp,
|
|
7
|
+
planDaemonArgs,
|
|
8
|
+
portlessInstallArgv,
|
|
9
|
+
tailscaleDnsName,
|
|
10
|
+
tailscaleServeArgv,
|
|
11
|
+
tailscaleServeOffArgv,
|
|
12
|
+
} from "./shareCore.ts";
|
|
13
|
+
|
|
14
|
+
describe("planDaemonArgs", () => {
|
|
15
|
+
it("fresh install per mode", () => {
|
|
16
|
+
expect(planDaemonArgs(null, "local")).toEqual(["--port", "7432"]);
|
|
17
|
+
expect(planDaemonArgs(null, "portless")).toEqual([]);
|
|
18
|
+
expect(planDaemonArgs(null, "tailscale")).toEqual(["--port", "7432"]);
|
|
19
|
+
expect(planDaemonArgs(null, "lan")).toEqual(["--port", "7432", "--host", "0.0.0.0"]);
|
|
20
|
+
expect(planDaemonArgs(null, "webrtc")).toEqual(["--webrtc"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("keeps an existing fitting config unchanged", () => {
|
|
24
|
+
expect(planDaemonArgs(["--port", "9000"], "tailscale")).toEqual(["--port", "9000"]);
|
|
25
|
+
expect(planDaemonArgs(["--share"], "webrtc")).toEqual(["--share"]);
|
|
26
|
+
expect(planDaemonArgs(["--share", "--port", "9000"], "tailscale")).toEqual([
|
|
27
|
+
"--share",
|
|
28
|
+
"--port",
|
|
29
|
+
"9000",
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("adds a way without dropping the existing one", () => {
|
|
34
|
+
// http daemon → also webrtc: keep HTTP on
|
|
35
|
+
expect(planDaemonArgs(["--port", "9000"], "webrtc")).toEqual([
|
|
36
|
+
"--port",
|
|
37
|
+
"9000",
|
|
38
|
+
"--http",
|
|
39
|
+
"--webrtc",
|
|
40
|
+
]);
|
|
41
|
+
// webrtc-only daemon → tailscale needs HTTP + a fixed port
|
|
42
|
+
expect(planDaemonArgs(["--webrtc"], "tailscale")).toEqual([
|
|
43
|
+
"--webrtc",
|
|
44
|
+
"--http",
|
|
45
|
+
"--port",
|
|
46
|
+
"7432",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("lan rebinds a loopback host to 0.0.0.0", () => {
|
|
51
|
+
expect(planDaemonArgs(["--host", "127.0.0.1", "--port", "1"], "lan")).toEqual([
|
|
52
|
+
"--port",
|
|
53
|
+
"1",
|
|
54
|
+
"--host",
|
|
55
|
+
"0.0.0.0",
|
|
56
|
+
]);
|
|
57
|
+
expect(planDaemonArgs(["--host=0.0.0.0", "--port=1"], "lan")).toEqual([
|
|
58
|
+
"--host=0.0.0.0",
|
|
59
|
+
"--port=1",
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("portless vs fixed port", () => {
|
|
65
|
+
it("portless is the mode without --port; local pins one", () => {
|
|
66
|
+
expect(planDaemonArgs(["--port", "9000"], "portless")).toEqual([]);
|
|
67
|
+
expect(planDaemonArgs(["--webrtc"], "portless")).toEqual(["--webrtc", "--http"]);
|
|
68
|
+
expect(planDaemonArgs([], "local")).toEqual(["--port", "7432"]);
|
|
69
|
+
expect(planDaemonArgs([], "portless")).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("installs portless with npm, else bun", () => {
|
|
73
|
+
expect(portlessInstallArgv((b) => b === "npm")).toEqual(["npm", "install", "-g", "portless"]);
|
|
74
|
+
expect(portlessInstallArgv((b) => b === "bun")).toEqual(["bun", "add", "-g", "portless"]);
|
|
75
|
+
expect(portlessInstallArgv(() => false)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("pickLanIp", () => {
|
|
80
|
+
const iface = (address: string, internal = false) =>
|
|
81
|
+
({ address, family: "IPv4", internal, netmask: "", mac: "", cidr: null }) as any;
|
|
82
|
+
it("skips loopback and tailscale CGNAT, returns a private address", () => {
|
|
83
|
+
expect(
|
|
84
|
+
pickLanIp({
|
|
85
|
+
lo: [iface("127.0.0.1", true)],
|
|
86
|
+
ts: [iface("100.64.0.9")],
|
|
87
|
+
eth0: [iface("192.168.1.5")],
|
|
88
|
+
}),
|
|
89
|
+
).toBe("192.168.1.5");
|
|
90
|
+
expect(pickLanIp({ a: [iface("10.0.0.2")] })).toBe("10.0.0.2");
|
|
91
|
+
expect(pickLanIp({ a: [iface("172.20.1.1")] })).toBe("172.20.1.1");
|
|
92
|
+
expect(pickLanIp({ a: [iface("8.8.8.8")] })).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("tailscale", () => {
|
|
97
|
+
it("reads the MagicDNS name only when running", () => {
|
|
98
|
+
expect(
|
|
99
|
+
tailscaleDnsName({ BackendState: "Running", Self: { DNSName: "box.tailnet.ts.net." } }),
|
|
100
|
+
).toBe("box.tailnet.ts.net");
|
|
101
|
+
expect(
|
|
102
|
+
tailscaleDnsName({ BackendState: "Stopped", Self: { DNSName: "box.ts.net." } }),
|
|
103
|
+
).toBeNull();
|
|
104
|
+
expect(tailscaleDnsName(null)).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const serve = (handlers: Record<string, string>) => ({
|
|
108
|
+
Web: {
|
|
109
|
+
"box.tailnet.ts.net:443": {
|
|
110
|
+
Handlers: Object.fromEntries(Object.entries(handlers).map(([k, v]) => [k, { Proxy: v }])),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
it("finds the /ay route to our port", () => {
|
|
115
|
+
const dns = "box.tailnet.ts.net";
|
|
116
|
+
expect(findTailscaleRoute(serve({ "/ay": "http://127.0.0.1:7432" }), dns, "/ay", 7432)).toEqual(
|
|
117
|
+
{
|
|
118
|
+
status: "ok",
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
expect(
|
|
122
|
+
findTailscaleRoute(serve({ "/ay/": "http://127.0.0.1:7432/" }), dns, "/ay", 7432),
|
|
123
|
+
).toEqual({
|
|
124
|
+
status: "ok",
|
|
125
|
+
});
|
|
126
|
+
expect(findTailscaleRoute(serve({ "/other": "http://127.0.0.1:1" }), dns, "/ay", 7432)).toEqual(
|
|
127
|
+
{
|
|
128
|
+
status: "missing",
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
expect(findTailscaleRoute(null, dns, "/ay", 7432)).toEqual({ status: "missing" });
|
|
132
|
+
// wrong port, or a target path the server wouldn't route
|
|
133
|
+
expect(
|
|
134
|
+
findTailscaleRoute(serve({ "/ay": "http://127.0.0.1:1" }), dns, "/ay", 7432).status,
|
|
135
|
+
).toBe("conflict");
|
|
136
|
+
expect(
|
|
137
|
+
findTailscaleRoute(serve({ "/ay": "http://127.0.0.1:7432/ay" }), dns, "/ay", 7432).status,
|
|
138
|
+
).toBe("conflict");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("tailscale commands", () => {
|
|
143
|
+
it("are exactly what the [y/N] prompt shows", () => {
|
|
144
|
+
expect(tailscaleServeArgv("/ay", 7432)).toEqual([
|
|
145
|
+
"tailscale",
|
|
146
|
+
"serve",
|
|
147
|
+
"--bg",
|
|
148
|
+
"--https=443",
|
|
149
|
+
"--set-path=/ay",
|
|
150
|
+
"http://127.0.0.1:7432",
|
|
151
|
+
]);
|
|
152
|
+
expect(tailscaleServeOffArgv("/ay")).toEqual([
|
|
153
|
+
"tailscale",
|
|
154
|
+
"serve",
|
|
155
|
+
"--https=443",
|
|
156
|
+
"--set-path=/ay",
|
|
157
|
+
"off",
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("share URL round trip", () => {
|
|
163
|
+
it("the console link is what ay connect parses", () => {
|
|
164
|
+
const url = httpShareUrl("https://box.tailnet.ts.net/ay", "tok123");
|
|
165
|
+
expect(url).toBe("https://box.tailnet.ts.net/ay/#k=tok123");
|
|
166
|
+
expect(parseShareUrl(url)).toEqual({ url: "https://box.tailnet.ts.net/ay", token: "tok123" });
|
|
167
|
+
expect(parseShareUrl("http://127.0.0.1:7432/#k=t")).toEqual({
|
|
168
|
+
url: "http://127.0.0.1:7432",
|
|
169
|
+
token: "t",
|
|
170
|
+
});
|
|
171
|
+
expect(parseShareUrl("https://h/ay/index.html#k=t")).toEqual({
|
|
172
|
+
url: "https://h/ay",
|
|
173
|
+
token: "t",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("accepts the legacy userinfo form and rejects tokenless URLs", () => {
|
|
178
|
+
expect(parseShareUrl("http://tok@192.168.1.5:7432")).toEqual({
|
|
179
|
+
url: "http://192.168.1.5:7432",
|
|
180
|
+
token: "tok",
|
|
181
|
+
});
|
|
182
|
+
expect(parseShareUrl("https://box.ts.net/ay/")).toBeNull();
|
|
183
|
+
expect(parseShareUrl("alias:kw")).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("default alias is the host's short name", () => {
|
|
187
|
+
expect(defaultConnectAlias("https://box.tailnet.ts.net/ay/#k=t")).toBe("box");
|
|
188
|
+
expect(defaultConnectAlias("http://192.168.1.5:7432/#k=t")).toBe("192-168-1-5");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("never throws on a malformed URL — resolveRemoteSpec feeds it every spec", () => {
|
|
192
|
+
// A stray percent is not valid percent-encoding: decodeURIComponent threw a
|
|
193
|
+
// URIError here, so `ay ls 'http://100%@host/'` crashed instead of reporting
|
|
194
|
+
// an unusable spec.
|
|
195
|
+
expect(() => parseShareUrl("http://100%@host:7432/")).not.toThrow();
|
|
196
|
+
expect(parseShareUrl("http://100%@host:7432/")).toEqual({
|
|
197
|
+
url: "http://host:7432",
|
|
198
|
+
token: "100%",
|
|
199
|
+
});
|
|
200
|
+
expect(parseShareUrl("http://%@%/")).not.toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("ay connect", () => {
|
|
205
|
+
const stderr = () => {
|
|
206
|
+
let out = "";
|
|
207
|
+
const spy = vi.spyOn(process.stderr, "write").mockImplementation((s: unknown) => {
|
|
208
|
+
out += String(s);
|
|
209
|
+
return true;
|
|
210
|
+
});
|
|
211
|
+
return { read: () => out, restore: () => spy.mockRestore() };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
it("explains a scheme-less paste instead of failing on URL parsing", async () => {
|
|
215
|
+
// The alias used to be derived (via `new URL`) BEFORE the link was checked,
|
|
216
|
+
// so this printed a bare "Invalid URL" and the hint below never ran.
|
|
217
|
+
const err = stderr();
|
|
218
|
+
try {
|
|
219
|
+
expect(await cmdConnect(["box.tailnet.ts.net/ay/#k=tok"])).toBe(1);
|
|
220
|
+
} finally {
|
|
221
|
+
err.restore();
|
|
222
|
+
}
|
|
223
|
+
expect(err.read()).toContain("not a share URL");
|
|
224
|
+
expect(err.read()).toContain("the # starts a comment");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("rejects an http URL with no token without touching the remote store", async () => {
|
|
228
|
+
const err = stderr();
|
|
229
|
+
try {
|
|
230
|
+
expect(await cmdConnect(["https://box.ts.net/ay/"])).toBe(1);
|
|
231
|
+
} finally {
|
|
232
|
+
err.restore();
|
|
233
|
+
}
|
|
234
|
+
expect(err.read()).toContain("not a share URL");
|
|
235
|
+
});
|
|
236
|
+
});
|