@staff0rd/assist 0.749.0 → 0.750.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 +24 -3
- package/dist/commands/sessions/web/bundle.js +1 -1
- package/dist/index.js +165 -23
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Command } from "commander";
|
|
|
6
6
|
// package.json
|
|
7
7
|
var package_default = {
|
|
8
8
|
name: "@staff0rd/assist",
|
|
9
|
-
version: "0.
|
|
9
|
+
version: "0.750.0",
|
|
10
10
|
type: "module",
|
|
11
11
|
main: "dist/index.js",
|
|
12
12
|
bin: {
|
|
@@ -12903,8 +12903,8 @@ var seqConfigHelp = [
|
|
|
12903
12903
|
var nodesConfigHelp = [
|
|
12904
12904
|
{
|
|
12905
12905
|
key: "sessions.links",
|
|
12906
|
-
setter: "assist sessions nodes link <name> http://127.0.0.1:<port> (or --ssh <alias> --port <port>)",
|
|
12907
|
-
note: "linked nodes whose sessions merge into this node's web UI, each direct (url) or over an ssh tunnel (ssh alias, peer port, localPort); each name must match the peer's sessions.nodeName"
|
|
12906
|
+
setter: "assist sessions nodes link <name> http://127.0.0.1:<port> (or --tailscale <host> --port <port>, or --ssh <alias> --port <port>)",
|
|
12907
|
+
note: "linked nodes whose sessions merge into this node's web UI, each direct (url, including a Tailscale <host>.<tailnet>.ts.net https url) or over an ssh tunnel (ssh alias, peer port, localPort); each name must match the peer's sessions.nodeName"
|
|
12908
12908
|
},
|
|
12909
12909
|
{
|
|
12910
12910
|
key: "sessions.linkVersionCheck",
|
|
@@ -47295,6 +47295,73 @@ async function sshHop(ssh, probes) {
|
|
|
47295
47295
|
);
|
|
47296
47296
|
}
|
|
47297
47297
|
|
|
47298
|
+
// src/commands/sessions/nodes/tailscaleStatus.ts
|
|
47299
|
+
import { execFile as execFile13 } from "child_process";
|
|
47300
|
+
import { promisify as promisify12 } from "util";
|
|
47301
|
+
var execFileAsync11 = promisify12(execFile13);
|
|
47302
|
+
var STATUS_TIMEOUT_MS2 = 1e4;
|
|
47303
|
+
function tailscaleCli() {
|
|
47304
|
+
return detectPlatform() === "wsl" ? "tailscale.exe" : "tailscale";
|
|
47305
|
+
}
|
|
47306
|
+
async function tailscaleStatus() {
|
|
47307
|
+
const { stdout } = await execFileAsync11(tailscaleCli(), ["status", "--json"], {
|
|
47308
|
+
encoding: "utf8",
|
|
47309
|
+
timeout: STATUS_TIMEOUT_MS2,
|
|
47310
|
+
windowsHide: true
|
|
47311
|
+
});
|
|
47312
|
+
return JSON.parse(stdout);
|
|
47313
|
+
}
|
|
47314
|
+
|
|
47315
|
+
// src/commands/sessions/nodes/findTailscalePeer.ts
|
|
47316
|
+
function findTailscalePeer(status3, hostname2) {
|
|
47317
|
+
const nodes = [
|
|
47318
|
+
...status3.Self ? [{ ...status3.Self, Online: true }] : [],
|
|
47319
|
+
...Object.values(status3.Peer ?? {})
|
|
47320
|
+
];
|
|
47321
|
+
return nodes.find(
|
|
47322
|
+
(node) => node.DNSName?.replace(/\.$/, "").toLowerCase() === hostname2
|
|
47323
|
+
);
|
|
47324
|
+
}
|
|
47325
|
+
|
|
47326
|
+
// src/commands/sessions/nodes/tailscaleHop.ts
|
|
47327
|
+
async function tailscaleHop(spec, probes) {
|
|
47328
|
+
const cli = tailscaleCli();
|
|
47329
|
+
let status3;
|
|
47330
|
+
try {
|
|
47331
|
+
status3 = await probes.tailscale();
|
|
47332
|
+
} catch (error) {
|
|
47333
|
+
return failed2(
|
|
47334
|
+
"tailscale",
|
|
47335
|
+
describePeerError(error),
|
|
47336
|
+
`\`${cli} status --json\` failed \u2014 install Tailscale on this node and sign in with \`${cli} up\``
|
|
47337
|
+
);
|
|
47338
|
+
}
|
|
47339
|
+
if (status3.BackendState !== "Running")
|
|
47340
|
+
return failed2(
|
|
47341
|
+
"tailscale",
|
|
47342
|
+
`Tailscale on this node is ${status3.BackendState ?? "not running"}`,
|
|
47343
|
+
`run \`${cli} up\` on this node`
|
|
47344
|
+
);
|
|
47345
|
+
const { hostname: hostname2 } = new URL(spec.url);
|
|
47346
|
+
const peer = findTailscalePeer(status3, hostname2);
|
|
47347
|
+
if (!peer)
|
|
47348
|
+
return failed2(
|
|
47349
|
+
"tailscale",
|
|
47350
|
+
`${hostname2} is not in this node's tailnet`,
|
|
47351
|
+
`check the host in \`${cli} status\`, then relink with \`assist sessions nodes link ${spec.name} --tailscale <host> --port <port>\``
|
|
47352
|
+
);
|
|
47353
|
+
if (!peer.Online)
|
|
47354
|
+
return failed2(
|
|
47355
|
+
"tailscale",
|
|
47356
|
+
`${hostname2} is offline in the tailnet`,
|
|
47357
|
+
`is ${spec.name}'s machine awake with Tailscale running and signed in?`
|
|
47358
|
+
);
|
|
47359
|
+
return passed(
|
|
47360
|
+
"tailscale",
|
|
47361
|
+
`${hostname2} online${peer.TailscaleIPs?.[0] ? ` (${peer.TailscaleIPs[0]})` : ""}`
|
|
47362
|
+
);
|
|
47363
|
+
}
|
|
47364
|
+
|
|
47298
47365
|
// src/commands/sessions/nodes/tunnelHop.ts
|
|
47299
47366
|
async function tunnelHop(spec, ssh, probes) {
|
|
47300
47367
|
const bound = `127.0.0.1:${ssh.localPort}`;
|
|
@@ -47307,10 +47374,26 @@ async function tunnelHop(spec, ssh, probes) {
|
|
|
47307
47374
|
);
|
|
47308
47375
|
}
|
|
47309
47376
|
|
|
47377
|
+
// src/commands/sessions/nodes/tailscaleServeRemediation.ts
|
|
47378
|
+
function tailscaleServeRemediation(spec, error) {
|
|
47379
|
+
const status3 = error.status;
|
|
47380
|
+
if (status3 !== void 0 && status3 !== 502) return void 0;
|
|
47381
|
+
const { hostname: hostname2, port } = new URL(spec.url);
|
|
47382
|
+
const servePort = port || "443";
|
|
47383
|
+
return status3 === 502 ? `tailscale serve on ${hostname2} answered but nothing listens on 127.0.0.1:${servePort} behind it \u2014 is ${spec.name}'s web server running (project-switch webservers)?` : `nothing serves https on ${servePort} on ${hostname2} \u2014 run \`tailscale serve --bg --https=${servePort} http://127.0.0.1:${servePort}\` on ${spec.name} (tailscale.exe under WSL)`;
|
|
47384
|
+
}
|
|
47385
|
+
|
|
47386
|
+
// src/commands/sessions/nodes/isTailscaleLink.ts
|
|
47387
|
+
function isTailscaleLink(spec) {
|
|
47388
|
+
return new URL(spec.url).hostname.endsWith(".ts.net");
|
|
47389
|
+
}
|
|
47390
|
+
|
|
47310
47391
|
// src/commands/sessions/nodes/webHop.ts
|
|
47311
47392
|
function webRemediation(spec, error) {
|
|
47312
47393
|
if (spec.ssh && error.status === void 0)
|
|
47313
47394
|
return `nothing listening on ${spec.ssh.port} on ${spec.ssh.alias} \u2014 is project-switch running ${spec.name}'s web server?`;
|
|
47395
|
+
const serve = isTailscaleLink(spec) ? tailscaleServeRemediation(spec, error) : void 0;
|
|
47396
|
+
if (serve) return serve;
|
|
47314
47397
|
const { hostname: hostname2, port } = new URL(spec.url);
|
|
47315
47398
|
switch (peerErrorCode(error)) {
|
|
47316
47399
|
case "ECONNREFUSED":
|
|
@@ -47350,7 +47433,7 @@ async function webHop(spec, probes) {
|
|
|
47350
47433
|
};
|
|
47351
47434
|
}
|
|
47352
47435
|
|
|
47353
|
-
// src/commands/sessions/nodes/
|
|
47436
|
+
// src/commands/sessions/nodes/peerDaemonHop.ts
|
|
47354
47437
|
function peerDaemonHop(spec, health2) {
|
|
47355
47438
|
if (health2.daemon?.reachable) return passed("daemon", "reachable");
|
|
47356
47439
|
return failed2(
|
|
@@ -47359,6 +47442,8 @@ function peerDaemonHop(spec, health2) {
|
|
|
47359
47442
|
`run \`assist daemon status\` on ${spec.name}; restarting its web server starts the daemon`
|
|
47360
47443
|
);
|
|
47361
47444
|
}
|
|
47445
|
+
|
|
47446
|
+
// src/commands/sessions/nodes/diagnoseLink.ts
|
|
47362
47447
|
async function diagnoseLink(spec, probes) {
|
|
47363
47448
|
const hops = [];
|
|
47364
47449
|
const done2 = () => ({ ...spec, ok: hops.every((h) => h.ok), hops });
|
|
@@ -47373,6 +47458,10 @@ async function diagnoseLink(spec, probes) {
|
|
|
47373
47458
|
if (!hops.at(-1)?.ok) return done2();
|
|
47374
47459
|
}
|
|
47375
47460
|
}
|
|
47461
|
+
if (isTailscaleLink(spec)) {
|
|
47462
|
+
hops.push(await tailscaleHop(spec, probes));
|
|
47463
|
+
if (!hops.at(-1)?.ok) return done2();
|
|
47464
|
+
}
|
|
47376
47465
|
const web3 = await webHop(spec, probes);
|
|
47377
47466
|
hops.push(web3.hop);
|
|
47378
47467
|
if (!web3.health) return done2();
|
|
@@ -47520,7 +47609,7 @@ function printDiagnosis(diagnosis) {
|
|
|
47520
47609
|
console.log(`${chalk253.bold(diagnosis.name)} ${chalk253.dim(diagnosis.url)}`);
|
|
47521
47610
|
for (const hop of diagnosis.hops) {
|
|
47522
47611
|
const mark = hop.ok ? chalk253.green("\u2713") : chalk253.red("\u2717");
|
|
47523
|
-
console.log(` ${mark} ${hop.hop.padEnd(
|
|
47612
|
+
console.log(` ${mark} ${hop.hop.padEnd(9)} ${hop.detail ?? hop.error}`);
|
|
47524
47613
|
if (hop.remediation) console.log(` ${chalk253.yellow(hop.remediation)}`);
|
|
47525
47614
|
}
|
|
47526
47615
|
}
|
|
@@ -47533,6 +47622,7 @@ function doctorProbes(live) {
|
|
|
47533
47622
|
sshAgent: probeSshAgent,
|
|
47534
47623
|
ssh: probeSsh,
|
|
47535
47624
|
tunnel: (localPort) => portAccepts(localPort),
|
|
47625
|
+
tailscale: tailscaleStatus,
|
|
47536
47626
|
linkState: (name) => {
|
|
47537
47627
|
if (!live) return "no-daemon";
|
|
47538
47628
|
return live.links.find((l) => l.name === name) ?? "unknown-link";
|
|
@@ -47596,41 +47686,87 @@ async function writeLinks(links2) {
|
|
|
47596
47686
|
saveGlobalConfig(updated2);
|
|
47597
47687
|
}
|
|
47598
47688
|
|
|
47599
|
-
// src/commands/sessions/nodes/
|
|
47600
|
-
function
|
|
47601
|
-
|
|
47602
|
-
|
|
47603
|
-
|
|
47604
|
-
|
|
47689
|
+
// src/commands/sessions/nodes/tailnetSuffix.ts
|
|
47690
|
+
function tailnetSuffix(status3) {
|
|
47691
|
+
if (status3.BackendState !== "Running")
|
|
47692
|
+
throw new Error(
|
|
47693
|
+
`Tailscale is not running on this node (${status3.BackendState ?? "unknown state"}) \u2014 run \`${tailscaleCli()} up\``
|
|
47694
|
+
);
|
|
47695
|
+
const suffix = status3.CurrentTailnet?.MagicDNSSuffix ?? status3.MagicDNSSuffix ?? "";
|
|
47696
|
+
if (status3.CurrentTailnet?.MagicDNSEnabled === false || !suffix)
|
|
47697
|
+
throw new Error(
|
|
47698
|
+
"MagicDNS is off in this tailnet \u2014 turn on MagicDNS and HTTPS certificates on the DNS page of the Tailscale admin console"
|
|
47699
|
+
);
|
|
47700
|
+
return suffix.replace(/\.$/, "");
|
|
47701
|
+
}
|
|
47702
|
+
|
|
47703
|
+
// src/commands/sessions/nodes/tailscaleLinkUrl.ts
|
|
47704
|
+
async function localTailnetSuffix() {
|
|
47705
|
+
return tailnetSuffix(await tailscaleStatus());
|
|
47706
|
+
}
|
|
47707
|
+
async function tailscaleLinkUrl(host, port, resolveSuffix = localTailnetSuffix) {
|
|
47708
|
+
const suffix = await resolveSuffix();
|
|
47709
|
+
const fqdn = host.replace(/\.$/, "");
|
|
47710
|
+
const bare = fqdn.endsWith(`.${suffix}`) ? fqdn.slice(0, -suffix.length - 1) : fqdn;
|
|
47711
|
+
return `https://${bare}.${suffix}:${port}`;
|
|
47605
47712
|
}
|
|
47713
|
+
|
|
47714
|
+
// src/commands/sessions/nodes/parsePort.ts
|
|
47606
47715
|
function parsePort(flag, value) {
|
|
47607
47716
|
const port = Number(value);
|
|
47608
47717
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
47609
47718
|
throw new Error(`${flag} must be a port number, got ${value}`);
|
|
47610
47719
|
return port;
|
|
47611
47720
|
}
|
|
47612
|
-
|
|
47613
|
-
|
|
47614
|
-
|
|
47615
|
-
|
|
47616
|
-
|
|
47721
|
+
|
|
47722
|
+
// src/commands/sessions/nodes/parseLinkUrl.ts
|
|
47723
|
+
function parseLinkUrl(url) {
|
|
47724
|
+
const parsed = new URL(url);
|
|
47725
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
47726
|
+
throw new Error(`link url must be http(s): ${url}`);
|
|
47727
|
+
return parsed.origin;
|
|
47617
47728
|
}
|
|
47618
|
-
|
|
47619
|
-
|
|
47620
|
-
|
|
47729
|
+
|
|
47730
|
+
// src/commands/sessions/nodes/buildLink.ts
|
|
47731
|
+
async function buildLink(name, url, options2, others, resolveTailnetSuffix) {
|
|
47732
|
+
if ([url, options2.ssh, options2.tailscale].filter(Boolean).length > 1)
|
|
47733
|
+
throw new Error(
|
|
47734
|
+
"pass only one of <url>, --tailscale <host> or --ssh <alias>"
|
|
47735
|
+
);
|
|
47621
47736
|
if (url) return { name, url: parseLinkUrl(url) };
|
|
47737
|
+
if (options2.tailscale) {
|
|
47738
|
+
const port2 = parsePort("--port", options2.port);
|
|
47739
|
+
return {
|
|
47740
|
+
name,
|
|
47741
|
+
url: await tailscaleLinkUrl(
|
|
47742
|
+
options2.tailscale,
|
|
47743
|
+
port2,
|
|
47744
|
+
resolveTailnetSuffix
|
|
47745
|
+
)
|
|
47746
|
+
};
|
|
47747
|
+
}
|
|
47622
47748
|
if (!options2.ssh)
|
|
47623
|
-
throw new Error(
|
|
47749
|
+
throw new Error(
|
|
47750
|
+
"pass the peer's <url>, --tailscale <host> --port <port> or --ssh <alias> --port <port>"
|
|
47751
|
+
);
|
|
47624
47752
|
const port = parsePort("--port", options2.port);
|
|
47625
47753
|
const localPort = options2.localPort ? parsePort("--local-port", options2.localPort) : freeTunnelPort(port, others);
|
|
47626
47754
|
return { name, ssh: options2.ssh, port, localPort };
|
|
47627
47755
|
}
|
|
47756
|
+
function freeTunnelPort(port, others) {
|
|
47757
|
+
const taken = new Set(others.map((link3) => toLinkSpec(link3).ssh?.localPort));
|
|
47758
|
+
let candidate = defaultTunnelPort(port);
|
|
47759
|
+
while (taken.has(candidate)) candidate++;
|
|
47760
|
+
return candidate;
|
|
47761
|
+
}
|
|
47762
|
+
|
|
47763
|
+
// src/commands/sessions/nodes/linkNode.ts
|
|
47628
47764
|
async function linkNode(name, url, options2) {
|
|
47629
47765
|
if (name.includes(":")) throw new Error("node names cannot contain ':'");
|
|
47630
47766
|
if (name === resolveNodeName())
|
|
47631
47767
|
throw new Error(`${name} is this node; link to a different node`);
|
|
47632
47768
|
const others = readLinks().filter((link4) => link4.name !== name);
|
|
47633
|
-
const link3 = buildLink(name, url, options2, others);
|
|
47769
|
+
const link3 = await buildLink(name, url, options2, others);
|
|
47634
47770
|
await writeLinks([...others, link3]);
|
|
47635
47771
|
console.log(
|
|
47636
47772
|
chalk255.green(`Linked ${name} (${describeTarget(toLinkSpec(link3))})`)
|
|
@@ -47715,11 +47851,17 @@ async function unlinkNode(name) {
|
|
|
47715
47851
|
function registerNodes(sessions) {
|
|
47716
47852
|
const cmd = sessions.command("nodes").description("List this node and every linked node with its link state").option("--json", "Output as JSON").action(listNodes);
|
|
47717
47853
|
cmd.command("link <name> [url]").description(
|
|
47718
|
-
"Link a peer node by its web server URL, or over ssh with --ssh and --port (name must match the peer's sessions.nodeName)"
|
|
47854
|
+
"Link a peer node by its web server URL, by its Tailscale name with --tailscale and --port, or over ssh with --ssh and --port (name must match the peer's sessions.nodeName)"
|
|
47855
|
+
).option(
|
|
47856
|
+
"--tailscale <host>",
|
|
47857
|
+
"Dial the peer at https://<host>.<tailnet>.ts.net:<port> (the peer runs tailscale serve for its web server port)"
|
|
47719
47858
|
).option(
|
|
47720
47859
|
"--ssh <alias>",
|
|
47721
47860
|
"Tunnel to the peer through this ~/.ssh/config alias"
|
|
47722
|
-
).option(
|
|
47861
|
+
).option(
|
|
47862
|
+
"--port <port>",
|
|
47863
|
+
"The peer's web server port (with --tailscale or --ssh)"
|
|
47864
|
+
).option(
|
|
47723
47865
|
"--local-port <port>",
|
|
47724
47866
|
"Local end of the ssh tunnel (default: 43000 + port % 1000, bumped past other links)"
|
|
47725
47867
|
).action(linkNode);
|