@stim-cli/server 1.17.1 → 1.17.2

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 CHANGED
@@ -13,7 +13,8 @@ The design is in
13
13
  ## Commands
14
14
 
15
15
  ```bash
16
- stim-server [--port <n>] # serve paired clients, port 7787 by default
16
+ stim-server [--port <n>] [--loopback-only]
17
+ # serve paired clients, port 7787 by default
17
18
  stim-server pair [--port <n>] [--control]
18
19
  # print a single-use pairing payload
19
20
  stim-server devices [list] # list pairings, approvals, requests and hosted sessions
@@ -28,6 +29,13 @@ stim-server service install|status|update|rollback|uninstall
28
29
  # run stim-server as a macOS LaunchAgent, see below
29
30
  ```
30
31
 
32
+ `--loopback-only` makes the server listen on `127.0.0.1` only and refuse any request
33
+ that names a remote peer (`X-Forwarded-For`, as `tailscale serve --https` adds), so
34
+ phones and other Macs cannot connect; `/health` then reports `loopbackOnly: true`.
35
+ Without it, the server also listens on this Mac's Tailscale addresses while Tailscale
36
+ runs (see [Tailscale](#tailscale)). Stim Desktop starts its own server with the flag
37
+ unless it serves phones.
38
+
31
39
  `--env KEY=VALUE` and `--path-prepend <dir>` (each repeatable) apply to the
32
40
  serving command: see [Run as a service](#run-as-a-service).
33
41
 
@@ -208,8 +216,9 @@ same tailnet or with the Mac shared to the phone's user.
208
216
  addresses, never on every interface. It re-reads the Tailscale state in the
209
217
  background, so a server that started before Tailscale was up, or while it did
210
218
  not answer, starts listening on the Tailscale addresses once it runs, and stops
211
- when it goes away. In Stim Desktop, choose **Set up connection** in Settings >
212
- Phones to create and verify the private route without a terminal command. A
219
+ when it goes away. In Stim Desktop, the Pair a Phone wizard (Settings > Phones) creates and
220
+ verifies the private route without a terminal command; with the Phone app flag
221
+ off, **Set up connection** on Settings > Server does. A
213
222
  missing or unreadable route keeps Desktop pairing unavailable; HTTPS setup may
214
223
  require Tailscale browser approval. Existing routes stay unchanged.
215
224
 
@@ -848,15 +848,27 @@ function answersAs(health, expected) {
848
848
  return !health.stimBuild || !expected.stimBuild || health.stimBuild === expected.stimBuild;
849
849
  }
850
850
  const packageNames = (entries) => entries.map((entry) => isJsonObject(entry) ? `${String(entry.name)}@${String(entry.version)}` : "?").join(", ");
851
- /** Why `npm audit signatures --json` output does not vouch for every installed package, or null when it does. */
852
- function signatureProblem(output) {
851
+ const lastLines = (text) => text.trim().split("\n").slice(-8).join("\n");
852
+ /**
853
+ * Why `npm audit signatures --json` output does not vouch for every installed package, or null when it does. When npm
854
+ * fails it prints `{"error":{"summary","detail"}}` on stdout, or only text on stderr; the message carries that error.
855
+ */
856
+ function signatureProblem(output, stderr = "") {
853
857
  let report;
854
858
  try {
855
859
  report = JSON.parse(output);
856
860
  } catch {
857
- return "npm audit signatures did not print a JSON report";
861
+ report = null;
862
+ }
863
+ if (isJsonObject(report) && isJsonObject(report.error)) {
864
+ const { summary, detail } = report.error;
865
+ const text = [summary, detail].filter((part) => typeof part === "string" && part.trim() !== "");
866
+ if (text.length) return `npm audit signatures failed: ${text.map((part) => part.trim()).join(" ")}`;
867
+ }
868
+ if (!isJsonObject(report) || !Array.isArray(report.invalid) || !Array.isArray(report.missing)) {
869
+ const error = lastLines(stderr);
870
+ return `npm audit signatures did not print a JSON report${error ? `:\n${error}` : ""}`;
858
871
  }
859
- if (!isJsonObject(report) || !Array.isArray(report.invalid) || !Array.isArray(report.missing)) return "npm audit signatures did not print a JSON report";
860
872
  if (report.invalid.length) return `npm found invalid registry signatures or attestations: ${packageNames(report.invalid)}`;
861
873
  if (report.missing.length) return `npm found packages without registry signatures: ${packageNames(report.missing)}`;
862
874
  return null;
@@ -1107,6 +1119,10 @@ function findTailscale(env) {
1107
1119
  for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "tailscale"))) return join(dir, "tailscale");
1108
1120
  return executable(MAC_APP_BINARY) ? MAC_APP_BINARY : null;
1109
1121
  }
1122
+ function tailscaleInstall(env, isExecutable = executable) {
1123
+ if (isExecutable(MAC_APP_BINARY)) return "app";
1124
+ return (env.PATH ?? "").split(delimiter).some((dir) => dir && isExecutable(join(dir, "tailscale"))) ? "cli" : "none";
1125
+ }
1110
1126
  function parseTailscaleStatus(value) {
1111
1127
  if (!isJsonObject(value) || typeof value.BackendState !== "string") return {
1112
1128
  state: "unavailable",
@@ -1900,7 +1916,6 @@ function serverBuild(script) {
1900
1916
  }
1901
1917
  }
1902
1918
  const describeBuild = (build) => `stim-server ${build.version}${build.stimBuild ? ` (Stim build ${build.stimBuild})` : ""}`;
1903
- const lastLines = (text) => text.trim().split("\n").slice(-8).join("\n");
1904
1919
  function sameFile(a, b) {
1905
1920
  try {
1906
1921
  return realpathSync(a) === realpathSync(b);
@@ -1931,6 +1946,32 @@ function installedIntegrity(staging) {
1931
1946
  return null;
1932
1947
  }
1933
1948
  }
1949
+ /**
1950
+ * The uploaded package files by package name, as `file:` specs. npm `audit signatures` skips a node only when its
1951
+ * dependent's spec is a file spec (npm lib/utils/verify-signatures.js), so the staging project lists every uploaded
1952
+ * package as a direct dependency and overrides every edge to it with `$name`.
1953
+ */
1954
+ async function localPackageSpecs(dir, names) {
1955
+ const specs = {};
1956
+ for (const file of names) {
1957
+ const path = join(dir, file);
1958
+ const manifest = await run$2("/usr/bin/tar", [
1959
+ "-xzOf",
1960
+ path,
1961
+ "package/package.json"
1962
+ ], process.env, 6e4);
1963
+ let name;
1964
+ try {
1965
+ name = JSON.parse(manifest.stdout).name;
1966
+ } catch {
1967
+ name = null;
1968
+ }
1969
+ if (!manifest.ok || typeof name !== "string" || !name) throw new ServiceError(`${path} is not an npm package file.${manifest.ok ? "" : `\n${lastLines(manifest.stderr)}`}`);
1970
+ if (specs[name]) throw new ServiceError(`${dir} has more than one package file for ${name}.`);
1971
+ specs[name] = `file:${path}`;
1972
+ }
1973
+ return specs;
1974
+ }
1934
1975
  async function installServer(versions, source, node, log) {
1935
1976
  mkdirSync(versions, { recursive: true });
1936
1977
  for (const entry of readdirSync(versions)) if (entry.startsWith(".install-")) rmSync(join(versions, entry), {
@@ -1944,6 +1985,7 @@ async function installServer(versions, source, node, log) {
1944
1985
  const registry = [`--registry=${REGISTRY}`, `--@stim-cli:registry=${REGISTRY}`];
1945
1986
  let files = [];
1946
1987
  let specs;
1988
+ let localPackages = null;
1947
1989
  if ("release" in source) specs = [`${SERVER_PACKAGE}@${source.release}`];
1948
1990
  else {
1949
1991
  const names = readdirSync(source.from).filter((name) => name.endsWith(".tgz"));
@@ -1952,9 +1994,16 @@ async function installServer(versions, source, node, log) {
1952
1994
  name,
1953
1995
  sha256: createHash("sha256").update(readFileSync(join(source.from, name))).digest("hex")
1954
1996
  }));
1955
- specs = names.map((name) => join(source.from, name));
1956
- }
1957
- writeFileSync(join(staging, "package.json"), "{ \"private\": true }\n");
1997
+ specs = [];
1998
+ localPackages = await localPackageSpecs(source.from, names);
1999
+ }
2000
+ const overrides = localPackages && Object.fromEntries(Object.keys(localPackages).map((name) => [name, `$${name}`]));
2001
+ const project = localPackages ? {
2002
+ private: true,
2003
+ dependencies: localPackages,
2004
+ overrides
2005
+ } : { private: true };
2006
+ writeFileSync(join(staging, "package.json"), `${JSON.stringify(project, null, 2)}\n`);
1958
2007
  log(`Installing ${"release" in source ? `${SERVER_PACKAGE}@${source.release} from ${REGISTRY}` : `${files.length} package file(s) from ${source.from}`}.`);
1959
2008
  const installed = await run$2(npm, [
1960
2009
  "install",
@@ -1969,14 +2018,15 @@ async function installServer(versions, source, node, log) {
1969
2018
  ...specs
1970
2019
  ], env, NPM_TIMEOUT_MS);
1971
2020
  if (!installed.ok) throw new ServiceError(`npm install failed:\n${lastLines(installed.stderr)}`);
1972
- const problem = signatureProblem((await run$2(npm, [
2021
+ const audit = await run$2(npm, [
1973
2022
  "audit",
1974
2023
  "signatures",
1975
2024
  "--json",
1976
2025
  "--prefix",
1977
2026
  staging,
1978
2027
  ...registry
1979
- ], env, NPM_TIMEOUT_MS)).stdout);
2028
+ ], env, NPM_TIMEOUT_MS);
2029
+ const problem = signatureProblem(audit.stdout, audit.stderr);
1980
2030
  if (problem) throw new ServiceError(`${problem}. Not switching to it.`);
1981
2031
  const script = join(staging, ...SERVER_SCRIPT);
1982
2032
  const build = existsSync(script) ? serverBuild(script) : null;
@@ -3499,6 +3549,11 @@ function setupVersionDecision(current, desired, managed) {
3499
3549
  if (managed && compareVersions(current, desired) < 0) return "update";
3500
3550
  return compareVersions(current.replace(/-.*/, ""), SETUP_MIN_VERSION) < 0 ? "too-old" : "reuse";
3501
3551
  }
3552
+ function tailscaleRefusal(install) {
3553
+ if (install === "none") return "Install Tailscale on this Mac and sign in to the same tailnet: https://tailscale.com/download/mac";
3554
+ if (install === "app") return "Turn on Tailscale from its menu bar icon on this Mac, then run this again.";
3555
+ return "Run tailscale up on this Mac, then run this again.";
3556
+ }
3502
3557
  function setupExitCode(journal) {
3503
3558
  if (journal.steps.some((s) => s.state === "failed")) return 1;
3504
3559
  if (journal.granted.length === 0) return 2;
@@ -3594,7 +3649,7 @@ function defaultSetupDeps() {
3594
3649
  if (process.platform !== "darwin") throw new SetupRefusal("setup runs only on macOS.");
3595
3650
  if (compareVersions(process.versions.node, "22.12.0") < 0) throw new SetupRefusal("setup needs Node 22.12 or later.");
3596
3651
  const status = await readRawTailscaleStatus(binary(), process.env);
3597
- if (!isJsonObject(status) || !isJsonObject(status.Self) || typeof status.Self.DNSName !== "string" || !status.Self.DNSName) throw new SetupRefusal("Start Tailscale on this Mac: tailscale up.");
3652
+ if (!isJsonObject(status) || !isJsonObject(status.Self) || typeof status.Self.DNSName !== "string" || !status.Self.DNSName) throw new SetupRefusal(tailscaleRefusal(tailscaleInstall(process.env)));
3598
3653
  if (!isJsonObject(status.Peer) || !Object.values(status.Peer).some((p) => isJsonObject(p) && p.ID === options.nodeId)) throw new SetupRefusal("That Mac is not on this tailnet");
3599
3654
  if (!(await run$2("launchctl", ["print", `gui/${process.getuid?.()}`])).ok) throw new SetupRefusal("Log in to a GUI session on this Mac before running setup.");
3600
3655
  return { dnsName: status.Self.DNSName.replace(/\.$/, "") };
@@ -14939,6 +14994,10 @@ async function startServer(options) {
14939
14994
  return;
14940
14995
  }
14941
14996
  const peer = peerAddress(request);
14997
+ if (options.loopbackOnly && peer !== null) {
14998
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
14999
+ return;
15000
+ }
14942
15001
  if (limiter.blocked(peer ?? "local")) {
14943
15002
  socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
14944
15003
  return;
@@ -14980,7 +15039,8 @@ async function startServer(options) {
14980
15039
  },
14981
15040
  tailscale: healthTailscale(tailscale),
14982
15041
  route,
14983
- nativeViewerOpened
15042
+ nativeViewerOpened,
15043
+ ...options.loopbackOnly ? { loopbackOnly: true } : {}
14984
15044
  };
14985
15045
  response.writeHead(startup.state === "ready" ? 200 : 503, { "content-type": "application/json" }).end(JSON.stringify(body));
14986
15046
  };
@@ -15027,6 +15087,10 @@ async function startServer(options) {
15027
15087
  };
15028
15088
  async function listenOn(host) {
15029
15089
  const server = createServer$1((request, response) => {
15090
+ if (options.loopbackOnly && peerAddress(request) !== null) {
15091
+ response.writeHead(403, { "content-type": "text/plain" }).end("This stim-server serves this Mac only.\n");
15092
+ return;
15093
+ }
15030
15094
  if (localHealthRequest(request)) {
15031
15095
  answerHealth(response);
15032
15096
  return;
@@ -15095,7 +15159,7 @@ async function startServer(options) {
15095
15159
  const monitor = options.tailscaleMonitor;
15096
15160
  const listenFailures = /* @__PURE__ */ new Set();
15097
15161
  const reconcile = async (snapshot) => {
15098
- const tailnet = snapshot.state.state === "running" ? snapshot.state.ips : [];
15162
+ const tailnet = snapshot.state.state === "running" && !options.loopbackOnly ? snapshot.state.ips : [];
15099
15163
  const wanted = /* @__PURE__ */ new Set([...options.hosts, ...tailnet]);
15100
15164
  for (const [host, listener] of servers) {
15101
15165
  if (wanted.has(host)) continue;
@@ -15247,8 +15311,9 @@ function watchTailscale(options) {
15247
15311
  //#endregion
15248
15312
  //#region bin/stim-server.ts
15249
15313
  const USAGE = `Usage:
15250
- stim-server [--port <n>] [--env KEY=VALUE]... [--path-prepend <dir>]...
15314
+ stim-server [--port <n>] [--loopback-only] [--env KEY=VALUE]... [--path-prepend <dir>]...
15251
15315
  serve paired clients (default port ${DEFAULT_PORT});
15316
+ --loopback-only serves this Mac only (127.0.0.1, no remote peers);
15252
15317
  --env and --path-prepend apply after the login shell's environment
15253
15318
  stim-server setup --client <node-id> --ticket <t> --expires <iso>
15254
15319
  [--build] [--device-host] [--port <n>] [--label <name>]
@@ -15298,7 +15363,7 @@ function routeNote(route, port) {
15298
15363
  function macName(tailscale) {
15299
15364
  return tailscale.state === "running" && tailscale.hostName || hostname();
15300
15365
  }
15301
- async function serve(port, extraEnv, pathPrepend) {
15366
+ async function serve(port, extraEnv, pathPrepend, loopbackOnly) {
15302
15367
  const captureHost = hostFromExecutable(process.env.STIM_HOST_EXECUTABLE);
15303
15368
  const login = loginShellEnvironment();
15304
15369
  if (!login) console.error("stim-server: could not read the login shell environment; using this process environment.");
@@ -15324,6 +15389,7 @@ async function serve(port, extraEnv, pathPrepend) {
15324
15389
  name: macName(tailscale),
15325
15390
  host: captureHost,
15326
15391
  hosts: ["127.0.0.1"],
15392
+ ...loopbackOnly ? { loopbackOnly: true } : {},
15327
15393
  port,
15328
15394
  stimCli: stim.cli,
15329
15395
  stimVersion: stim.version,
@@ -15354,13 +15420,13 @@ async function serve(port, extraEnv, pathPrepend) {
15354
15420
  }
15355
15421
  if (note) console.error(note);
15356
15422
  };
15357
- await announce(tailscaleBinary, tailscale);
15358
- monitor.onChange((snapshot, previous) => {
15423
+ if (!loopbackOnly) monitor.onChange((snapshot, previous) => {
15359
15424
  const { state } = snapshot;
15360
15425
  if (state.state === "running") console.log("Tailscale is running.");
15361
15426
  else if (previous.state.state === "running") console.error("Tailscale stopped.");
15362
15427
  if (state.state === "running" || previous.state.state === "running") announce(snapshot.binary, state);
15363
15428
  });
15429
+ if (!loopbackOnly) await announce(tailscaleBinary, tailscale);
15364
15430
  const shutdown = () => {
15365
15431
  monitor.stop();
15366
15432
  server.close().catch((error) => {
@@ -15491,6 +15557,7 @@ async function main() {
15491
15557
  allowPositionals: true,
15492
15558
  options: {
15493
15559
  port: { type: "string" },
15560
+ "loopback-only": { type: "boolean" },
15494
15561
  json: { type: "boolean" },
15495
15562
  control: { type: "boolean" },
15496
15563
  read: { type: "boolean" },
@@ -15533,7 +15600,8 @@ async function main() {
15533
15600
  }
15534
15601
  if ((values.label !== void 0 || values.serve) && !service) fail(`--label and --serve apply only to \`service\`.\n${USAGE}`);
15535
15602
  if ((values.release !== void 0 || values.from !== void 0) && !service) fail(`--release and --from apply only to \`service update\`.\n${USAGE}`);
15536
- if (command === void 0) return serve(port, extraEnv, pathPrepend);
15603
+ if (values["loopback-only"] && command !== void 0) fail(`--loopback-only applies only to serving.\n${USAGE}`);
15604
+ if (command === void 0) return serve(port, extraEnv, pathPrepend, values["loopback-only"] === true);
15537
15605
  const grant = command === "devices" && sub === "grant";
15538
15606
  if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
15539
15607
  if (values.build && !grant) fail(`--build applies only to \`devices grant\`.\n${USAGE}`);
@@ -4,6 +4,6 @@ const encode = (version) => version.split(".", 3).reduce((sum, part) => sum * 1e
4
4
  if (encode(process.versions.node) < encode("22.12.0")) {
5
5
  process.stderr.write(`STIM_NODE_UNSUPPORTED: stim-server needs Node 22.12.0 or later; this is Node ${process.versions.node} at ${process.execPath}. To run stim-server where a project pins an older Node, see https://stim.appandflow.com/docs/requirements#older-node-pins\n`);
6
6
  process.exitCode = 1;
7
- } else import("./stim-server-CGqz0EFG.mjs");
7
+ } else import("./stim-server-CJIlV28Y.mjs");
8
8
  //#endregion
9
9
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/server",
3
- "version": "1.17.1",
3
+ "version": "1.17.2",
4
4
  "description": "Serves Stim status to paired clients over Tailscale, and runs reload and stop for clients granted control.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,8 +28,8 @@
28
28
  "./protocol.schema.json": "./dist/protocol.schema.json"
29
29
  },
30
30
  "dependencies": {
31
- "@stim-cli/core": "^1.17.1",
32
- "stim": "1.17.1",
31
+ "@stim-cli/core": "^1.17.2",
32
+ "stim": "1.17.2",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {