@staff0rd/assist 0.749.0 → 0.751.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.751.0",
10
10
  type: "module",
11
11
  main: "dist/index.js",
12
12
  bin: {
@@ -10696,7 +10696,7 @@ function startWebServer(label2, port, handler, initialPath, open = true) {
10696
10696
  const server = createServer((req, res) => {
10697
10697
  runHandler(handler, req, res, port);
10698
10698
  });
10699
- server.listen(port, () => {
10699
+ server.listen(port, "127.0.0.1", () => {
10700
10700
  console.log(chalk103.green(`${label2}: ${url}`));
10701
10701
  console.log(chalk103.dim("Press Ctrl+C to stop"));
10702
10702
  if (open) {
@@ -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 \`assist sessions nodes serve --port ${servePort}\` on ${spec.name}`;
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";
@@ -47553,15 +47643,74 @@ async function doctorNodes(name, options2) {
47553
47643
  else printReport(report2);
47554
47644
  }
47555
47645
 
47646
+ // src/commands/sessions/nodes/listNodes.ts
47647
+ import chalk254 from "chalk";
47648
+ async function nodesReport() {
47649
+ const live = await queryNodes();
47650
+ if (live)
47651
+ return { local: live.local, daemonRunning: true, links: live.links };
47652
+ return {
47653
+ local: resolveNodeName(),
47654
+ daemonRunning: false,
47655
+ links: loadLinkSpecs().map((link3) => ({ ...link3, state: "disconnected" }))
47656
+ };
47657
+ }
47658
+ async function listNodes(options2) {
47659
+ const report2 = await nodesReport();
47660
+ if (options2.json) {
47661
+ console.log(JSON.stringify(report2, null, 2));
47662
+ return;
47663
+ }
47664
+ console.log(`${report2.local} ${chalk254.dim("(this node)")}`);
47665
+ if (!report2.daemonRunning)
47666
+ console.log(chalk254.dim(" daemon not running; showing configured links"));
47667
+ if (report2.links.length === 0) console.log(chalk254.dim(" no links"));
47668
+ for (const link3 of report2.links) console.log(formatLink(link3));
47669
+ }
47670
+
47671
+ // src/commands/sessions/nodes/nodeLogs.ts
47672
+ async function fetchLogTail(name, lines2) {
47673
+ if (name === resolveNodeName())
47674
+ return {
47675
+ nodeName: name,
47676
+ path: daemonPaths.log,
47677
+ lines: readLogTail(daemonPaths.log, lines2)
47678
+ };
47679
+ const link3 = findLinkSpec(name);
47680
+ if (!link3)
47681
+ throw new Error(`No link named ${name}; see assist sessions nodes`);
47682
+ try {
47683
+ return await fetchPeerJson(
47684
+ link3.url,
47685
+ `/api/daemon-log?lines=${lines2}`
47686
+ );
47687
+ } catch (error) {
47688
+ throw new Error(
47689
+ `Could not read ${name}'s daemon log: ${describePeerError(error)}. Run assist sessions nodes doctor ${name}`
47690
+ );
47691
+ }
47692
+ }
47693
+ async function nodeLogs(name, options2) {
47694
+ const lines2 = Number.parseInt(options2.lines, 10);
47695
+ if (!Number.isInteger(lines2) || lines2 < 1)
47696
+ throw new Error(`-n must be a positive integer, got ${options2.lines}`);
47697
+ const tail = await fetchLogTail(name, lines2);
47698
+ if (options2.json) {
47699
+ console.log(JSON.stringify(tail, null, 2));
47700
+ return;
47701
+ }
47702
+ for (const line of tail.lines) console.log(line);
47703
+ }
47704
+
47556
47705
  // src/commands/sessions/nodes/linkNode.ts
47557
- import chalk255 from "chalk";
47706
+ import chalk256 from "chalk";
47558
47707
 
47559
47708
  // src/commands/sessions/nodes/notifyDaemonLinks.ts
47560
- import chalk254 from "chalk";
47709
+ import chalk255 from "chalk";
47561
47710
  async function notifyDaemonLinks() {
47562
47711
  if (!await isDaemonRunning()) {
47563
47712
  console.log(
47564
- chalk254.yellow(
47713
+ chalk255.yellow(
47565
47714
  "The sessions daemon is not running; it loads the links when it starts."
47566
47715
  )
47567
47716
  );
@@ -47569,11 +47718,11 @@ async function notifyDaemonLinks() {
47569
47718
  }
47570
47719
  try {
47571
47720
  await sendToDaemonAwaitAck({ type: "reload-links" });
47572
- console.log(chalk254.dim("The sessions daemon reloaded its links."));
47721
+ console.log(chalk255.dim("The sessions daemon reloaded its links."));
47573
47722
  } catch (error) {
47574
47723
  const message3 = error instanceof Error ? error.message : String(error);
47575
47724
  console.log(
47576
- chalk254.yellow(
47725
+ chalk255.yellow(
47577
47726
  `Could not notify the sessions daemon (${message3}); run \`assist daemon restart\` to apply the change.`
47578
47727
  )
47579
47728
  );
@@ -47596,134 +47745,191 @@ async function writeLinks(links2) {
47596
47745
  saveGlobalConfig(updated2);
47597
47746
  }
47598
47747
 
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;
47748
+ // src/commands/sessions/nodes/tailnetSuffix.ts
47749
+ function tailnetSuffix(status3) {
47750
+ if (status3.BackendState !== "Running")
47751
+ throw new Error(
47752
+ `Tailscale is not running on this node (${status3.BackendState ?? "unknown state"}) \u2014 run \`${tailscaleCli()} up\``
47753
+ );
47754
+ const suffix = status3.CurrentTailnet?.MagicDNSSuffix ?? status3.MagicDNSSuffix ?? "";
47755
+ if (status3.CurrentTailnet?.MagicDNSEnabled === false || !suffix)
47756
+ throw new Error(
47757
+ "MagicDNS is off in this tailnet \u2014 turn on MagicDNS and HTTPS certificates on the DNS page of the Tailscale admin console"
47758
+ );
47759
+ return suffix.replace(/\.$/, "");
47760
+ }
47761
+
47762
+ // src/commands/sessions/nodes/tailscaleLinkUrl.ts
47763
+ async function localTailnetSuffix() {
47764
+ return tailnetSuffix(await tailscaleStatus());
47605
47765
  }
47766
+ async function tailscaleLinkUrl(host, port, resolveSuffix = localTailnetSuffix) {
47767
+ const suffix = await resolveSuffix();
47768
+ const fqdn = host.replace(/\.$/, "");
47769
+ const bare = fqdn.endsWith(`.${suffix}`) ? fqdn.slice(0, -suffix.length - 1) : fqdn;
47770
+ return `https://${bare}.${suffix}:${port}`;
47771
+ }
47772
+
47773
+ // src/commands/sessions/nodes/parsePort.ts
47606
47774
  function parsePort(flag, value) {
47607
47775
  const port = Number(value);
47608
47776
  if (!Number.isInteger(port) || port < 1 || port > 65535)
47609
47777
  throw new Error(`${flag} must be a port number, got ${value}`);
47610
47778
  return port;
47611
47779
  }
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;
47780
+
47781
+ // src/commands/sessions/nodes/parseLinkUrl.ts
47782
+ function parseLinkUrl(url) {
47783
+ const parsed = new URL(url);
47784
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
47785
+ throw new Error(`link url must be http(s): ${url}`);
47786
+ return parsed.origin;
47617
47787
  }
47618
- function buildLink(name, url, options2, others) {
47619
- if (url && options2.ssh)
47620
- throw new Error("pass either <url> or --ssh <alias>, not both");
47788
+
47789
+ // src/commands/sessions/nodes/buildLink.ts
47790
+ async function buildLink(name, url, options2, others, resolveTailnetSuffix) {
47791
+ if ([url, options2.ssh, options2.tailscale].filter(Boolean).length > 1)
47792
+ throw new Error(
47793
+ "pass only one of <url>, --tailscale <host> or --ssh <alias>"
47794
+ );
47621
47795
  if (url) return { name, url: parseLinkUrl(url) };
47796
+ if (options2.tailscale) {
47797
+ const port2 = parsePort("--port", options2.port);
47798
+ return {
47799
+ name,
47800
+ url: await tailscaleLinkUrl(
47801
+ options2.tailscale,
47802
+ port2,
47803
+ resolveTailnetSuffix
47804
+ )
47805
+ };
47806
+ }
47622
47807
  if (!options2.ssh)
47623
- throw new Error("pass the peer's <url> or --ssh <alias> --port <port>");
47808
+ throw new Error(
47809
+ "pass the peer's <url>, --tailscale <host> --port <port> or --ssh <alias> --port <port>"
47810
+ );
47624
47811
  const port = parsePort("--port", options2.port);
47625
47812
  const localPort = options2.localPort ? parsePort("--local-port", options2.localPort) : freeTunnelPort(port, others);
47626
47813
  return { name, ssh: options2.ssh, port, localPort };
47627
47814
  }
47815
+ function freeTunnelPort(port, others) {
47816
+ const taken = new Set(others.map((link3) => toLinkSpec(link3).ssh?.localPort));
47817
+ let candidate = defaultTunnelPort(port);
47818
+ while (taken.has(candidate)) candidate++;
47819
+ return candidate;
47820
+ }
47821
+
47822
+ // src/commands/sessions/nodes/linkNode.ts
47628
47823
  async function linkNode(name, url, options2) {
47629
47824
  if (name.includes(":")) throw new Error("node names cannot contain ':'");
47630
47825
  if (name === resolveNodeName())
47631
47826
  throw new Error(`${name} is this node; link to a different node`);
47632
47827
  const others = readLinks().filter((link4) => link4.name !== name);
47633
- const link3 = buildLink(name, url, options2, others);
47828
+ const link3 = await buildLink(name, url, options2, others);
47634
47829
  await writeLinks([...others, link3]);
47635
47830
  console.log(
47636
- chalk255.green(`Linked ${name} (${describeTarget(toLinkSpec(link3))})`)
47831
+ chalk256.green(`Linked ${name} (${describeTarget(toLinkSpec(link3))})`)
47637
47832
  );
47638
47833
  await notifyDaemonLinks();
47639
47834
  }
47640
47835
 
47641
- // src/commands/sessions/nodes/listNodes.ts
47642
- import chalk256 from "chalk";
47643
- async function nodesReport() {
47644
- const live = await queryNodes();
47645
- if (live)
47646
- return { local: live.local, daemonRunning: true, links: live.links };
47647
- return {
47648
- local: resolveNodeName(),
47649
- daemonRunning: false,
47650
- links: loadLinkSpecs().map((link3) => ({ ...link3, state: "disconnected" }))
47651
- };
47652
- }
47653
- async function listNodes(options2) {
47654
- const report2 = await nodesReport();
47655
- if (options2.json) {
47656
- console.log(JSON.stringify(report2, null, 2));
47657
- return;
47658
- }
47659
- console.log(`${report2.local} ${chalk256.dim("(this node)")}`);
47660
- if (!report2.daemonRunning)
47661
- console.log(chalk256.dim(" daemon not running; showing configured links"));
47662
- if (report2.links.length === 0) console.log(chalk256.dim(" no links"));
47663
- for (const link3 of report2.links) console.log(formatLink(link3));
47836
+ // src/commands/sessions/nodes/registerLinkCommand.ts
47837
+ function registerLinkCommand(nodes) {
47838
+ nodes.command("link <name> [url]").description(
47839
+ "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)"
47840
+ ).option(
47841
+ "--tailscale <host>",
47842
+ "Dial the peer at https://<host>.<tailnet>.ts.net:<port> (the peer runs tailscale serve for its web server port)"
47843
+ ).option(
47844
+ "--ssh <alias>",
47845
+ "Tunnel to the peer through this ~/.ssh/config alias"
47846
+ ).option(
47847
+ "--port <port>",
47848
+ "The peer's web server port (with --tailscale or --ssh)"
47849
+ ).option(
47850
+ "--local-port <port>",
47851
+ "Local end of the ssh tunnel (default: 43000 + port % 1000, bumped past other links)"
47852
+ ).action(linkNode);
47664
47853
  }
47665
47854
 
47666
- // src/commands/sessions/nodes/nodeLogs.ts
47667
- async function fetchLogTail(name, lines2) {
47668
- if (name === resolveNodeName())
47669
- return {
47670
- nodeName: name,
47671
- path: daemonPaths.log,
47672
- lines: readLogTail(daemonPaths.log, lines2)
47673
- };
47674
- const link3 = findLinkSpec(name);
47675
- if (!link3)
47676
- throw new Error(`No link named ${name}; see assist sessions nodes`);
47677
- try {
47678
- return await fetchPeerJson(
47679
- link3.url,
47680
- `/api/daemon-log?lines=${lines2}`
47681
- );
47682
- } catch (error) {
47855
+ // src/commands/sessions/nodes/serveNode.ts
47856
+ import { execFile as execFile14, spawnSync as spawnSync12 } from "child_process";
47857
+ import { promisify as promisify13 } from "util";
47858
+ import chalk257 from "chalk";
47859
+
47860
+ // src/commands/sessions/nodes/planServe.ts
47861
+ function planServe(status3, serveConfig2, port) {
47862
+ tailnetSuffix(status3);
47863
+ const fqdn = status3.Self?.DNSName?.replace(/\.$/, "");
47864
+ if (!fqdn)
47683
47865
  throw new Error(
47684
- `Could not read ${name}'s daemon log: ${describePeerError(error)}. Run assist sessions nodes doctor ${name}`
47866
+ `\`${tailscaleCli()} status --json\` reports no DNS name for this machine`
47685
47867
  );
47686
- }
47868
+ const target = `http://127.0.0.1:${port}`;
47869
+ const served = serveConfig2.Web?.[`${fqdn}:${port}`]?.Handlers?.["/"]?.Proxy;
47870
+ return {
47871
+ host: fqdn.split(".")[0],
47872
+ url: `https://${fqdn}:${port}`,
47873
+ target,
47874
+ alreadyServing: served?.replace(/\/$/, "") === target,
47875
+ args: ["serve", "--bg", `--https=${port}`, target]
47876
+ };
47687
47877
  }
47688
- async function nodeLogs(name, options2) {
47689
- const lines2 = Number.parseInt(options2.lines, 10);
47690
- if (!Number.isInteger(lines2) || lines2 < 1)
47691
- throw new Error(`-n must be a positive integer, got ${options2.lines}`);
47692
- const tail = await fetchLogTail(name, lines2);
47693
- if (options2.json) {
47694
- console.log(JSON.stringify(tail, null, 2));
47695
- return;
47878
+
47879
+ // src/commands/sessions/nodes/serveNode.ts
47880
+ var execFileAsync12 = promisify13(execFile14);
47881
+ async function serveConfig() {
47882
+ const { stdout } = await execFileAsync12(
47883
+ tailscaleCli(),
47884
+ ["serve", "status", "--json"],
47885
+ { encoding: "utf8", timeout: 1e4, windowsHide: true }
47886
+ );
47887
+ return stdout.trim() ? JSON.parse(stdout) : {};
47888
+ }
47889
+ function runServe(args) {
47890
+ console.log(chalk257.dim(`${tailscaleCli()} ${args.join(" ")}`));
47891
+ const result = spawnSync12(tailscaleCli(), args, { stdio: "inherit" });
47892
+ if (result.error) throw result.error;
47893
+ if (result.status !== 0)
47894
+ throw new Error(`tailscale serve exited with ${result.status}`);
47895
+ }
47896
+ async function serveNode(options2) {
47897
+ const port = parsePort("--port", options2.port);
47898
+ const plan2 = planServe(await tailscaleStatus(), await serveConfig(), port);
47899
+ if (plan2.alreadyServing) {
47900
+ console.log(chalk257.dim(`${plan2.url} already serves ${plan2.target}`));
47901
+ } else {
47902
+ runServe(plan2.args);
47903
+ console.log(chalk257.green(`Serving ${plan2.target} at ${plan2.url}`));
47696
47904
  }
47697
- for (const line of tail.lines) console.log(line);
47905
+ console.log("Link this node from another node with:");
47906
+ console.log(
47907
+ ` assist sessions nodes link ${resolveNodeName()} --tailscale ${plan2.host} --port ${port}`
47908
+ );
47698
47909
  }
47699
47910
 
47700
47911
  // src/commands/sessions/nodes/unlinkNode.ts
47701
- import chalk257 from "chalk";
47912
+ import chalk258 from "chalk";
47702
47913
  async function unlinkNode(name) {
47703
47914
  const links2 = readLinks();
47704
47915
  const remaining = links2.filter((link3) => link3.name !== name);
47705
47916
  if (remaining.length === links2.length) {
47706
- console.log(chalk257.yellow(`No link named ${name}`));
47917
+ console.log(chalk258.yellow(`No link named ${name}`));
47707
47918
  return;
47708
47919
  }
47709
47920
  await writeLinks(remaining);
47710
- console.log(chalk257.green(`Unlinked ${name}`));
47921
+ console.log(chalk258.green(`Unlinked ${name}`));
47711
47922
  await notifyDaemonLinks();
47712
47923
  }
47713
47924
 
47714
47925
  // src/commands/sessions/nodes/registerNodes.ts
47715
47926
  function registerNodes(sessions) {
47716
47927
  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
- 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)"
47719
- ).option(
47720
- "--ssh <alias>",
47721
- "Tunnel to the peer through this ~/.ssh/config alias"
47722
- ).option("--port <port>", "The peer's loopback web server port (with --ssh)").option(
47723
- "--local-port <port>",
47724
- "Local end of the ssh tunnel (default: 43000 + port % 1000, bumped past other links)"
47725
- ).action(linkNode);
47928
+ registerLinkCommand(cmd);
47726
47929
  cmd.command("unlink <name>").description("Remove a linked node").action(unlinkNode);
47930
+ cmd.command("serve").description(
47931
+ "Expose this node's web server on the tailnet with tailscale serve (idempotent) and print the command that links it"
47932
+ ).option("-p, --port <port>", "This node's web server port", "3100").action(serveNode);
47727
47933
  cmd.command("doctor [name]").description(
47728
47934
  "Probe each hop of every link (or one) in order and stop at the first failure with a remediation"
47729
47935
  ).option("--json", "Output as JSON").action(
@@ -47846,7 +48052,7 @@ async function renameSession(title) {
47846
48052
 
47847
48053
  // src/commands/sessions/summarise.ts
47848
48054
  import * as fs58 from "fs";
47849
- import chalk258 from "chalk";
48055
+ import chalk259 from "chalk";
47850
48056
 
47851
48057
  // src/commands/sessions/summarise/shared.ts
47852
48058
  import * as fs57 from "fs";
@@ -47905,22 +48111,22 @@ ${firstMessage}`);
47905
48111
  async function summarise2(options2) {
47906
48112
  const files = await discoverSessionFiles();
47907
48113
  if (files.length === 0) {
47908
- console.log(chalk258.yellow("No sessions found."));
48114
+ console.log(chalk259.yellow("No sessions found."));
47909
48115
  return;
47910
48116
  }
47911
48117
  const toProcess = selectCandidates(files, options2);
47912
48118
  if (toProcess.length === 0) {
47913
- console.log(chalk258.green("All sessions already summarised."));
48119
+ console.log(chalk259.green("All sessions already summarised."));
47914
48120
  return;
47915
48121
  }
47916
48122
  console.log(
47917
- chalk258.cyan(
48123
+ chalk259.cyan(
47918
48124
  `Summarising ${toProcess.length} session(s) (${files.length} total)\u2026`
47919
48125
  )
47920
48126
  );
47921
48127
  const { succeeded, failed: failed3 } = processSessions(toProcess);
47922
48128
  console.log(
47923
- chalk258.green(`Done: ${succeeded} summarised`) + (failed3 > 0 ? chalk258.yellow(`, ${failed3} skipped`) : "")
48129
+ chalk259.green(`Done: ${succeeded} summarised`) + (failed3 > 0 ? chalk259.yellow(`, ${failed3} skipped`) : "")
47924
48130
  );
47925
48131
  }
47926
48132
  function selectCandidates(files, options2) {
@@ -47940,16 +48146,16 @@ function processSessions(files) {
47940
48146
  let failed3 = 0;
47941
48147
  for (let i = 0; i < files.length; i++) {
47942
48148
  const file = files[i];
47943
- process.stdout.write(chalk258.dim(` [${i + 1}/${files.length}] `));
48149
+ process.stdout.write(chalk259.dim(` [${i + 1}/${files.length}] `));
47944
48150
  const summary = summariseSession(file);
47945
48151
  if (summary) {
47946
48152
  writeSummary(file, summary);
47947
48153
  succeeded++;
47948
- process.stdout.write(`${chalk258.green("\u2713")} ${summary}
48154
+ process.stdout.write(`${chalk259.green("\u2713")} ${summary}
47949
48155
  `);
47950
48156
  } else {
47951
48157
  failed3++;
47952
- process.stdout.write(` ${chalk258.yellow("skip")}
48158
+ process.stdout.write(` ${chalk259.yellow("skip")}
47953
48159
  `);
47954
48160
  }
47955
48161
  }
@@ -47979,10 +48185,10 @@ function registerSessions(program2) {
47979
48185
  }
47980
48186
 
47981
48187
  // src/reportCliError.ts
47982
- import chalk259 from "chalk";
48188
+ import chalk260 from "chalk";
47983
48189
  function reportCliError(error) {
47984
48190
  if (error instanceof InvalidItemIdError || error instanceof AmbiguousRepoConfigError || error instanceof UnknownRepoConfigError || error instanceof MissingRunCwdError || error instanceof MiroExtractError) {
47985
- console.error(chalk259.red(error.message));
48191
+ console.error(chalk260.red(error.message));
47986
48192
  } else {
47987
48193
  console.error(error);
47988
48194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staff0rd/assist",
3
- "version": "0.749.0",
3
+ "version": "0.751.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {