@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/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.749.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/diagnoseLink.ts
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(6)} ${hop.detail ?? hop.error}`);
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/linkNode.ts
47600
- function parseLinkUrl(url) {
47601
- const parsed = new URL(url);
47602
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
47603
- throw new Error(`link url must be http(s): ${url}`);
47604
- return parsed.origin;
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
- function freeTunnelPort(port, others) {
47613
- const taken = new Set(others.map((link3) => toLinkSpec(link3).ssh?.localPort));
47614
- let candidate = defaultTunnelPort(port);
47615
- while (taken.has(candidate)) candidate++;
47616
- return candidate;
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
- function buildLink(name, url, options2, others) {
47619
- if (url && options2.ssh)
47620
- throw new Error("pass either <url> or --ssh <alias>, not both");
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("pass the peer's <url> or --ssh <alias> --port <port>");
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("--port <port>", "The peer's loopback web server port (with --ssh)").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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staff0rd/assist",
3
- "version": "0.749.0",
3
+ "version": "0.750.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {