@stim-cli/server 1.17.1 → 1.18.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 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
 
@@ -104,8 +112,9 @@ GUI login session.
104
112
 
105
113
  Running setup on the worker is the worker-side approval. It pre-approves at
106
114
  most one request per chosen capability, from this node, carrying this ticket,
107
- until this expiry. A terminal asks y/N for each grant (default No); use `--yes`
108
- to approve without those questions, including without a terminal. Without
115
+ until this expiry. A terminal asks Y/n for each grant (default Yes: Enter, `y` and `yes` approve;
116
+ `n`, `no` and any other answer decline); use `--yes` to approve without those
117
+ questions, including without a terminal. Without
109
118
  a terminal or `--yes`, setup refuses before installing anything unless every
110
119
  chosen capability already has a matching approval. A person on
111
120
  the worker still approves; an SSH-driven run is not offered. Build and
@@ -147,7 +156,7 @@ nothing. Android and CocoaPods checks are not needed for hosting alone.
147
156
  In a terminal, setup prints a banner, then one line per step with a green check,
148
157
  a yellow arrow while a step runs, or a red cross; `NO_COLOR`, `TERM=dumb` and a
149
158
  non-terminal give plain `[ok]`, `[failed]` and `[pending]` marks, no colors and no
150
- in-place line. The y/N questions stay, one line each. The run ends with a
159
+ in-place line. The Y/n questions stay, one line each. The run ends with a
151
160
  headline such as "janics-mac-mini is ready to build for Janic's MacBook Pro", one
152
161
  line per approved capability, a `Fix` line for each skipped permission or missing
153
162
  tool, and one `To undo` line. `--verbose` prints every step with its detail, the
@@ -208,8 +217,9 @@ same tailnet or with the Mac shared to the phone's user.
208
217
  addresses, never on every interface. It re-reads the Tailscale state in the
209
218
  background, so a server that started before Tailscale was up, or while it did
210
219
  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
220
+ when it goes away. In Stim Desktop, the Pair a Phone wizard (Settings > Phones) creates and
221
+ verifies the private route without a terminal command; with the Phone app flag
222
+ off, **Set up connection** on Settings > Server does. A
213
223
  missing or unreadable route keeps Desktop pairing unavailable; HTTPS setup may
214
224
  require Tailscale browser approval. Existing routes stay unchanged.
215
225
 
@@ -1153,7 +1163,11 @@ The server re-reads the build clients on every call, and revoking a client
1153
1163
  closes its connections and cancels its builds.
1154
1164
 
1155
1165
  - `build.offer` takes `repo` (the client's name for its repository: letters,
1156
- digits, `.`, `_` and `-`, at most 80) and an optional `lockfile` sha256. It
1166
+ digits, `.`, `_` and `-`, at most 80), an optional `lockfile` sha256, and an
1167
+ optional `rubyVersion` (the project's normalized `.ruby-version`, a nonempty
1168
+ installation name without path separators, NUL or line breaks). The CocoaPods probe uses the
1169
+ same Ruby selection and UTF-8 locale defaults as `pod install`; omitting
1170
+ `rubyVersion` uses the worker's default Ruby. It
1157
1171
  returns `toolchain` (`stimBuild`, a digest of the bundled Stim's built code;
1158
1172
  `arch`; `xcode`; `simulatorSdk`; `macosSdk` (from
1159
1173
  `xcrun --sdk macosx --show-sdk-version`); `cocoapods`; `runtimes`, the simulator
@@ -1169,11 +1183,12 @@ closes its connections and cancels its builds.
1169
1183
  average per CPU), `builds` (this Mac's own Stim runs in prebuild, pods or
1170
1184
  compile that it did not offload, plus the offloaded builds it runs), `maxBuilds` (its
1171
1185
  `concurrency.maxBuilds`, 0 when unlimited), `maxLoadPerCore` (its
1172
- `server.maxLoadPerCore`, default 2), and `declined`: why it would refuse a
1186
+ `server.maxLoadPerCore`, default 2), `memoryUsedBytes` (Activity Monitor's
1187
+ Memory Used) and `memoryTotalBytes`, and `declined`: why it would refuse a
1173
1188
  build now, or null. It declines while it runs its limit of offloaded builds,
1174
1189
  while the worker root's volume has less than 10 GiB free, while `builds`
1175
1190
  reaches a non-zero `maxBuilds`, or while `loadPerCore` is at or above
1176
- `maxLoadPerCore`. The toolchain is read at most once a minute; capacity on
1191
+ `maxLoadPerCore`. The toolchain is read at most once a minute per Ruby context; capacity on
1177
1192
  every offer.
1178
1193
  - `build.sync` takes `repo`, `files` and `done`. `files` is one page of the
1179
1194
  manifest, each `{ "path", "kind": "file"|"exec"|"link", "size", "sha256" }`,
@@ -1881,8 +1896,10 @@ after. With a `stim` whose status has no `activity.recent`, every app log
1881
1896
  record counts as activity, so an app that keeps logging never looks stuck or
1882
1897
  finished.
1883
1898
 
1884
- Each workspace notifies once per episode: a stuck agent notifies again only
1885
- after new activity and a new quiet stretch, a loop only after a success or a
1899
+ Each workspace notifies once per episode: an agent stop pushes `finished` once
1900
+ per run, which ends when the workspace goes idle, and a workspace pushes
1901
+ `attention` once per run, which starts over when the workspace stops being idle
1902
+ or has no item left; a stuck agent notifies again only after new activity and a new quiet stretch, a loop only after a success or a
1886
1903
  different failure. A push carries a collapse id for its workspace and
1887
1904
  category, so a later one replaces the earlier notification on the phone
1888
1905
  instead of stacking. What is already true when the server starts or a device
@@ -1,5 +1,5 @@
1
1
  import "node:module";
2
- import { ACTIONS, BUILD_REPO_PATTERN, CAPABILITIES, CONTROL_END_REASONS, DEVICE_POSTURES, ERROR_CODES, FEATURES, FRAME_EDGE, FRAME_FPS, INPUT_BUTTONS, INPUT_KEYS, KEY_MODIFIERS, LEGACY_PUSH_EVENTS, LOG_LEVELS, LOG_SOURCES, MAX_INPUT_TEXT, MAX_LOG_TAIL, NOTIFICATION_LEVELS, NOTIFICATION_SUPPRESSIONS, PLATFORMS, PROTOCOL_VERSION, PUSH_EVENTS, PUSH_TOKEN_PATTERN, RELOAD_PLATFORMS, REPLAY_MARKER_KINDS, REPLAY_RATES, ROTATE_DIRECTIONS, TOUCH_PHASES, VIDEO_CODECS } from "@stim-cli/core/protocol";
2
+ import { ACTIONS, BUILD_REPO_PATTERN, BUILD_RUBY_VERSION_PATTERN, CAPABILITIES, CONTROL_END_REASONS, DEVICE_POSTURES, ERROR_CODES, FEATURES, FRAME_EDGE, FRAME_FPS, INPUT_BUTTONS, INPUT_KEYS, KEY_MODIFIERS, LEGACY_PUSH_EVENTS, LOG_LEVELS, LOG_SOURCES, MAX_INPUT_TEXT, MAX_LOG_TAIL, NOTIFICATION_LEVELS, NOTIFICATION_SUPPRESSIONS, PLATFORMS, PROTOCOL_VERSION, PUSH_EVENTS, PUSH_TOKEN_PATTERN, RELOAD_PLATFORMS, REPLAY_MARKER_KINDS, REPLAY_RATES, ROTATE_DIRECTIONS, TOUCH_PHASES, VIDEO_CODECS } from "@stim-cli/core/protocol";
3
3
  //#region \0rolldown/runtime.js
4
4
  var __defProp = Object.defineProperty;
5
5
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -785,7 +785,11 @@ function protocolJsonSchema() {
785
785
  required: ["name", "version"],
786
786
  properties: {
787
787
  name: { type: "string" },
788
- version: { type: "string" }
788
+ version: { type: "string" },
789
+ runId: {
790
+ type: "string",
791
+ pattern: "^[A-Za-z0-9._-]{1,64}$"
792
+ }
789
793
  }
790
794
  },
791
795
  auth: { oneOf: [
@@ -2209,7 +2213,11 @@ function protocolJsonSchema() {
2209
2213
  additionalProperties: false,
2210
2214
  properties: {
2211
2215
  repo: buildRepo,
2212
- lockfile: sha256
2216
+ lockfile: sha256,
2217
+ rubyVersion: {
2218
+ type: "string",
2219
+ pattern: BUILD_RUBY_VERSION_PATTERN
2220
+ }
2213
2221
  }
2214
2222
  }),
2215
2223
  request("build.sync", {
package/dist/protocol.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as protocolJsonSchema } from "./protocol-Btu-PwQS.mjs";
1
+ import { t as protocolJsonSchema } from "./protocol-qo5TTavl.mjs";
2
2
  export * from "@stim-cli/core/protocol";
3
3
  export { protocolJsonSchema };
@@ -1145,6 +1145,10 @@
1145
1145
  },
1146
1146
  "version": {
1147
1147
  "type": "string"
1148
+ },
1149
+ "runId": {
1150
+ "type": "string",
1151
+ "pattern": "^[A-Za-z0-9._-]{1,64}$"
1148
1152
  }
1149
1153
  }
1150
1154
  },
@@ -1420,7 +1424,8 @@
1420
1424
  "device",
1421
1425
  "build",
1422
1426
  "agent",
1423
- "maintenance"
1427
+ "maintenance",
1428
+ "placement"
1424
1429
  ]
1425
1430
  }
1426
1431
  },
@@ -5215,6 +5220,10 @@
5215
5220
  "lockfile": {
5216
5221
  "type": "string",
5217
5222
  "pattern": "^[0-9a-f]{64}$"
5223
+ },
5224
+ "rubyVersion": {
5225
+ "type": "string",
5226
+ "pattern": "^(?!\\.{1,2}$)[^/\\\\\\u0000\\r\\n]+$"
5218
5227
  }
5219
5228
  }
5220
5229
  }
@@ -1,8 +1,8 @@
1
- import { n as protocol_exports } from "./protocol-Btu-PwQS.mjs";
1
+ import { n as protocol_exports } from "./protocol-qo5TTavl.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
4
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { HOSTED_APP_CHUNK_BYTES, HOSTED_MACOS_APP_SLOTS, RECORDING_CAP_BYTES, RECORDING_FOOTAGE_MS, RECORDING_PLATFORMS, archiveDir, assertHostedDeviceLedger, buildWorkerRoot, capDrops, closedSegmentName, compileGrep, createAgentActionReader, deviceHostArea, deviceHostRoot, deviceViewersDir, footageDrops, getConcurrencyLimits, getParkedMax, hostedAndroidPlacements, hostedAppArea, hostedAppAttempt, hostedAppBlobs, hostedDeviceId, hostedIosPlacements, hostedMacosBundleId, hostedMacosLogsDir, hostedNativeLogsDir, isJournalExpired, isJsonObject, isSetupJournalPrunable, listRecordedDevices, listSegments, loadConfig, machineCapacity, macosAppState, openSegmentName, parseHostedAppOffer, parseHostedLocalDeviceCount, parseHostedLogsCursor, parseHostedNativeOffer, parseHostedOfferRequest, parseHostedPlatformDevice, parseHostedRequest, parseNdjsonLine, pinnedEndpoint, queryJsonLogs, readArchive, readArchiveState, readBuildHistory, readBuildMachines, readDeviceHostMachines, readHostedApp, readHostedAppMetadata, readHostedDevice, readHostedDeviceLedger, readHostedMacosApp, readHostedNativeLogsCheckpoint, readHostedSessions, readJsonObject, readLogTimeline, readLogsSince, readMacosRecord, readSetupJournal, readWorkspaceState, recordingDeviceName, rememberHostedAppBlobRename, saturation, serverSetupDir, settingDefinition, settingValueError, setupJournalFile, sortByTs, stimBuildDigest, tryAcquireBuildSlotClaim, validHostedAppBlob, workspaceRecordingsDir } from "@stim-cli/core/state";
5
+ import { HOSTED_APP_CHUNK_BYTES, HOSTED_MACOS_APP_SLOTS, RECORDING_CAP_BYTES, RECORDING_FOOTAGE_MS, RECORDING_PLATFORMS, archiveDir, assertHostedDeviceLedger, buildWorkerRoot, capDrops, closedSegmentName, compileGrep, createAgentActionReader, createDebugLog, deviceHostArea, deviceHostRoot, deviceViewersDir, footageDrops, getConcurrencyLimits, getParkedMax, hostedAndroidPlacements, hostedAppArea, hostedAppAttempt, hostedAppBlobs, hostedDeviceId, hostedIosPlacements, hostedMacosBundleId, hostedMacosLogsDir, hostedNativeLogsDir, isJournalExpired, isJsonObject, isSetupJournalPrunable, listRecordedDevices, listSegments, loadConfig, machineCapacity, macosAppState, openSegmentName, parseHostedAppOffer, parseHostedLocalDeviceCount, parseHostedLogsCursor, parseHostedNativeOffer, parseHostedOfferRequest, parseHostedPlatformDevice, parseHostedRequest, parseNdjsonLine, pinnedEndpoint, queryJsonLogs, readArchive, readArchiveState, readBuildHistory, readBuildMachines, readDeviceHostMachines, readHostedApp, readHostedAppMetadata, readHostedDevice, readHostedDeviceLedger, readHostedMacosApp, readHostedNativeLogsCheckpoint, readHostedSessions, readJsonObject, readLogTimeline, readLogsSince, readMacosRecord, readSetupJournal, readWorkspaceState, recordingDeviceName, rememberHostedAppBlobRename, saturation, serverSetupDir, settingDefinition, settingValueError, setupJournalFile, sortByTs, stimBuildDigest, tryAcquireBuildSlotClaim, validHostedAppBlob, validRunId, workspaceRecordingsDir } from "@stim-cli/core/state";
6
6
  import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, cpSync, createReadStream, existsSync, futimesSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, statfsSync, watch, writeFileSync, writeSync } from "node:fs";
7
7
  import { availableParallelism, cpus, freemem, homedir, hostname, loadavg, tmpdir, totalmem } from "node:os";
8
8
  import { parseArgs } from "node:util";
@@ -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",
@@ -1170,30 +1186,6 @@ function readTailscaleStatus(binary, env, timeoutMs = TIMEOUT_MS$1) {
1170
1186
  });
1171
1187
  });
1172
1188
  }
1173
- function tailscaleStatus(binary, env) {
1174
- if (!binary) return {
1175
- state: "unavailable",
1176
- reason: "the tailscale command was not found"
1177
- };
1178
- try {
1179
- const output = execFileSync(binary, ["status", "--json"], {
1180
- env,
1181
- timeout: TIMEOUT_MS$1,
1182
- encoding: "utf8",
1183
- stdio: [
1184
- "ignore",
1185
- "pipe",
1186
- "ignore"
1187
- ]
1188
- });
1189
- return parseTailscaleStatus(JSON.parse(output));
1190
- } catch (error) {
1191
- return {
1192
- state: "unavailable",
1193
- reason: `\`tailscale status --json\` failed: ${error.message}`
1194
- };
1195
- }
1196
- }
1197
1189
  function reaches(address, target, hosts) {
1198
1190
  if (typeof address !== "string") return null;
1199
1191
  let url;
@@ -1540,7 +1532,7 @@ async function waitForHealth(port, label) {
1540
1532
  }
1541
1533
  async function prepareRoute(port, previous) {
1542
1534
  const binary = findTailscale(process.env);
1543
- const status = tailscaleStatus(binary, process.env);
1535
+ const status = await readTailscaleStatus(binary, process.env);
1544
1536
  if (!binary || status.state !== "running") throw new ServiceError("--serve needs Tailscale running on this Mac, and the tailscale command on PATH.");
1545
1537
  const plan = planServe(await serveRoute(binary, process.env, port, status.ips), port, previous?.serve ?? null);
1546
1538
  if ("refusal" in plan) throw new ServiceError(plan.refusal);
@@ -1818,7 +1810,7 @@ async function uninstallJob(label) {
1818
1810
  let routeOff = null;
1819
1811
  const tailscale = findTailscale(process.env);
1820
1812
  if (installed.serve?.created && installed.port !== null) {
1821
- const status = tailscaleStatus(tailscale, process.env);
1813
+ const status = await readTailscaleStatus(tailscale, process.env);
1822
1814
  if (!tailscale || status.state !== "running") throw new ServiceError(`${label} created the tailscale serve route on https port ${installed.serve.port}, and Tailscale is not answering, so uninstall cannot check it. Start Tailscale and run this again.`);
1823
1815
  const route = await serveRoute(tailscale, process.env, installed.port, status.ips);
1824
1816
  if (route.state === "routed" && route.port === installed.serve.port) routeOff = installed.serve.port;
@@ -1900,7 +1892,6 @@ function serverBuild(script) {
1900
1892
  }
1901
1893
  }
1902
1894
  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
1895
  function sameFile(a, b) {
1905
1896
  try {
1906
1897
  return realpathSync(a) === realpathSync(b);
@@ -1931,6 +1922,32 @@ function installedIntegrity(staging) {
1931
1922
  return null;
1932
1923
  }
1933
1924
  }
1925
+ /**
1926
+ * The uploaded package files by package name, as `file:` specs. npm `audit signatures` skips a node only when its
1927
+ * dependent's spec is a file spec (npm lib/utils/verify-signatures.js), so the staging project lists every uploaded
1928
+ * package as a direct dependency and overrides every edge to it with `$name`.
1929
+ */
1930
+ async function localPackageSpecs(dir, names) {
1931
+ const specs = {};
1932
+ for (const file of names) {
1933
+ const path = join(dir, file);
1934
+ const manifest = await run$2("/usr/bin/tar", [
1935
+ "-xzOf",
1936
+ path,
1937
+ "package/package.json"
1938
+ ], process.env, 6e4);
1939
+ let name;
1940
+ try {
1941
+ name = JSON.parse(manifest.stdout).name;
1942
+ } catch {
1943
+ name = null;
1944
+ }
1945
+ if (!manifest.ok || typeof name !== "string" || !name) throw new ServiceError(`${path} is not an npm package file.${manifest.ok ? "" : `\n${lastLines(manifest.stderr)}`}`);
1946
+ if (specs[name]) throw new ServiceError(`${dir} has more than one package file for ${name}.`);
1947
+ specs[name] = `file:${path}`;
1948
+ }
1949
+ return specs;
1950
+ }
1934
1951
  async function installServer(versions, source, node, log) {
1935
1952
  mkdirSync(versions, { recursive: true });
1936
1953
  for (const entry of readdirSync(versions)) if (entry.startsWith(".install-")) rmSync(join(versions, entry), {
@@ -1944,6 +1961,7 @@ async function installServer(versions, source, node, log) {
1944
1961
  const registry = [`--registry=${REGISTRY}`, `--@stim-cli:registry=${REGISTRY}`];
1945
1962
  let files = [];
1946
1963
  let specs;
1964
+ let localPackages = null;
1947
1965
  if ("release" in source) specs = [`${SERVER_PACKAGE}@${source.release}`];
1948
1966
  else {
1949
1967
  const names = readdirSync(source.from).filter((name) => name.endsWith(".tgz"));
@@ -1952,9 +1970,16 @@ async function installServer(versions, source, node, log) {
1952
1970
  name,
1953
1971
  sha256: createHash("sha256").update(readFileSync(join(source.from, name))).digest("hex")
1954
1972
  }));
1955
- specs = names.map((name) => join(source.from, name));
1956
- }
1957
- writeFileSync(join(staging, "package.json"), "{ \"private\": true }\n");
1973
+ specs = [];
1974
+ localPackages = await localPackageSpecs(source.from, names);
1975
+ }
1976
+ const overrides = localPackages && Object.fromEntries(Object.keys(localPackages).map((name) => [name, `$${name}`]));
1977
+ const project = localPackages ? {
1978
+ private: true,
1979
+ dependencies: localPackages,
1980
+ overrides
1981
+ } : { private: true };
1982
+ writeFileSync(join(staging, "package.json"), `${JSON.stringify(project, null, 2)}\n`);
1958
1983
  log(`Installing ${"release" in source ? `${SERVER_PACKAGE}@${source.release} from ${REGISTRY}` : `${files.length} package file(s) from ${source.from}`}.`);
1959
1984
  const installed = await run$2(npm, [
1960
1985
  "install",
@@ -1969,14 +1994,15 @@ async function installServer(versions, source, node, log) {
1969
1994
  ...specs
1970
1995
  ], env, NPM_TIMEOUT_MS);
1971
1996
  if (!installed.ok) throw new ServiceError(`npm install failed:\n${lastLines(installed.stderr)}`);
1972
- const problem = signatureProblem((await run$2(npm, [
1997
+ const audit = await run$2(npm, [
1973
1998
  "audit",
1974
1999
  "signatures",
1975
2000
  "--json",
1976
2001
  "--prefix",
1977
2002
  staging,
1978
2003
  ...registry
1979
- ], env, NPM_TIMEOUT_MS)).stdout);
2004
+ ], env, NPM_TIMEOUT_MS);
2005
+ const problem = signatureProblem(audit.stdout, audit.stderr);
1980
2006
  if (problem) throw new ServiceError(`${problem}. Not switching to it.`);
1981
2007
  const script = join(staging, ...SERVER_SCRIPT);
1982
2008
  const build = existsSync(script) ? serverBuild(script) : null;
@@ -2569,8 +2595,7 @@ var BuildHost = class {
2569
2595
  retained = /* @__PURE__ */ new Map();
2570
2596
  closed = false;
2571
2597
  draining = null;
2572
- toolchainAt = 0;
2573
- toolchainValue = null;
2598
+ toolchains = /* @__PURE__ */ new Map();
2574
2599
  options;
2575
2600
  sweeping = Promise.resolve();
2576
2601
  queued = null;
@@ -2624,11 +2649,17 @@ var BuildHost = class {
2624
2649
  clientDir(client) {
2625
2650
  return join(this.root(), client);
2626
2651
  }
2627
- toolchain() {
2628
- if (this.toolchainValue && Date.now() - this.toolchainAt < this.limits.toolchainTtlMs) return this.toolchainValue;
2629
- this.toolchainAt = Date.now();
2630
- this.toolchainValue = new Promise((resolve) => {
2631
- const child = spawn(process.execPath, [this.options.worker, "offer"], {
2652
+ toolchain(rubyVersion = null) {
2653
+ const now = Date.now();
2654
+ for (const [context, entry] of this.toolchains) if (now - entry.at >= this.limits.toolchainTtlMs) this.toolchains.delete(context);
2655
+ const cached = this.toolchains.get(rubyVersion);
2656
+ if (cached) return cached.value;
2657
+ const value = new Promise((resolve) => {
2658
+ const child = spawn(process.execPath, [
2659
+ this.options.worker,
2660
+ "offer",
2661
+ ...rubyVersion ? [rubyVersion] : []
2662
+ ], {
2632
2663
  env: this.options.env,
2633
2664
  stdio: [
2634
2665
  "ignore",
@@ -2644,21 +2675,26 @@ var BuildHost = class {
2644
2675
  child.on("close", () => {
2645
2676
  clearTimeout(timer);
2646
2677
  try {
2647
- const value = JSON.parse(out);
2648
- resolve(isJsonObject(value) ? value : null);
2678
+ const report = JSON.parse(out);
2679
+ resolve(isJsonObject(report) ? report : null);
2649
2680
  } catch {
2650
2681
  resolve(null);
2651
2682
  }
2652
2683
  });
2653
2684
  });
2654
- this.toolchainValue.then((value) => {
2655
- if (!value) this.toolchainValue = null;
2685
+ this.toolchains.set(rubyVersion, {
2686
+ at: now,
2687
+ value
2688
+ });
2689
+ value.then((result) => {
2690
+ if (!result && this.toolchains.get(rubyVersion)?.value === value) this.toolchains.delete(rubyVersion);
2656
2691
  });
2657
- return this.toolchainValue;
2692
+ return value;
2658
2693
  }
2659
2694
  async offer(client, params) {
2660
2695
  if (!isJsonObject(params) || !validRepo(params.repo)) return refusal("bad-request", "build.offer needs params.repo.");
2661
- const toolchain = await this.toolchain();
2696
+ if (params.rubyVersion !== void 0 && (typeof params.rubyVersion !== "string" || !new RegExp(protocol_exports.BUILD_RUBY_VERSION_PATTERN).test(params.rubyVersion))) return refusal("bad-request", "build.offer needs a Ruby installation name without path separators.");
2697
+ const toolchain = await this.toolchain(params.rubyVersion ?? null);
2662
2698
  if (!toolchain) return refusal("build-refused", "This Mac could not read its build toolchain.");
2663
2699
  const root = this.root();
2664
2700
  mkdirSync(root, {
@@ -2670,9 +2706,18 @@ var BuildHost = class {
2670
2706
  try {
2671
2707
  lockfile = readFileSync(join(area, "lockfile"), "utf8");
2672
2708
  } catch {}
2709
+ const available = await readAvailableMemory();
2710
+ const total = totalmem();
2711
+ const memory = available === null ? {} : {
2712
+ memoryTotalBytes: total,
2713
+ memoryUsedBytes: total - available
2714
+ };
2673
2715
  return { result: {
2674
2716
  toolchain,
2675
- capacity: this.capacity(),
2717
+ capacity: {
2718
+ ...this.capacity(),
2719
+ ...memory
2720
+ },
2676
2721
  warm: {
2677
2722
  checkout: existsSync(join(area, "src")),
2678
2723
  dependencies: typeof params.lockfile === "string" && lockfile === params.lockfile,
@@ -3499,6 +3544,11 @@ function setupVersionDecision(current, desired, managed) {
3499
3544
  if (managed && compareVersions(current, desired) < 0) return "update";
3500
3545
  return compareVersions(current.replace(/-.*/, ""), SETUP_MIN_VERSION) < 0 ? "too-old" : "reuse";
3501
3546
  }
3547
+ function tailscaleRefusal(install) {
3548
+ if (install === "none") return "Install Tailscale on this Mac and sign in to the same tailnet: https://tailscale.com/download/mac";
3549
+ if (install === "app") return "Turn on Tailscale from its menu bar icon on this Mac, then run this again.";
3550
+ return "Run tailscale up on this Mac, then run this again.";
3551
+ }
3502
3552
  function setupExitCode(journal) {
3503
3553
  if (journal.steps.some((s) => s.state === "failed")) return 1;
3504
3554
  if (journal.granted.length === 0) return 2;
@@ -3544,7 +3594,7 @@ async function confirmSetup(input, question, timeoutMs) {
3544
3594
  input.on("SIGINT", interrupt);
3545
3595
  input.on("close", interrupt);
3546
3596
  try {
3547
- return /^(y|yes)$/i.test((await input.question(question, { signal: controller.signal })).trim());
3597
+ return /^(|y|yes)$/i.test((await input.question(question, { signal: controller.signal })).trim());
3548
3598
  } catch (error) {
3549
3599
  if (controller.signal.reason instanceof SetupRefusal) throw controller.signal.reason;
3550
3600
  if (error.name === "AbortError") return false;
@@ -3556,6 +3606,15 @@ async function confirmSetup(input, question, timeoutMs) {
3556
3606
  input.close();
3557
3607
  }
3558
3608
  }
3609
+ const discardInput = () => void 0;
3610
+ async function discardTypedAhead(stream) {
3611
+ if (!stream.isTTY) return;
3612
+ stream.on("data", discardInput);
3613
+ stream.resume();
3614
+ await sleep(300);
3615
+ stream.off("data", discardInput);
3616
+ stream.pause();
3617
+ }
3559
3618
  function defaultSetupDeps() {
3560
3619
  return {
3561
3620
  now: Date.now,
@@ -3564,10 +3623,13 @@ function defaultSetupDeps() {
3564
3623
  display: setupDisplayFor(process.stdout.isTTY === true, process.env, (text) => void process.stdout.write(text)),
3565
3624
  stdout: (line) => console.log(line),
3566
3625
  stderr: (line) => console.error(line),
3567
- confirm: (question, timeoutMs) => confirmSetup(createInterface({
3568
- input: process.stdin,
3569
- output: process.stderr
3570
- }), question, timeoutMs),
3626
+ confirm: async (question, timeoutMs) => {
3627
+ await discardTypedAhead(process.stdin);
3628
+ return confirmSetup(createInterface({
3629
+ input: process.stdin,
3630
+ output: process.stderr
3631
+ }), question, timeoutMs);
3632
+ },
3571
3633
  permissionWait(ms) {
3572
3634
  if (!process.stdin.isTTY) return sleep(ms).then(() => false);
3573
3635
  return new Promise((resolve, reject) => {
@@ -3594,7 +3656,7 @@ function defaultSetupDeps() {
3594
3656
  if (process.platform !== "darwin") throw new SetupRefusal("setup runs only on macOS.");
3595
3657
  if (compareVersions(process.versions.node, "22.12.0") < 0) throw new SetupRefusal("setup needs Node 22.12 or later.");
3596
3658
  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.");
3659
+ if (!isJsonObject(status) || !isJsonObject(status.Self) || typeof status.Self.DNSName !== "string" || !status.Self.DNSName) throw new SetupRefusal(tailscaleRefusal(tailscaleInstall(process.env)));
3598
3660
  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
3661
  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
3662
  return { dnsName: status.Self.DNSName.replace(/\.$/, "") };
@@ -3743,9 +3805,9 @@ const capabilityNoun = (capability) => capability === "build" ? "builds" : "host
3743
3805
  function approvalQuestion(concise, name, capability, nodeId) {
3744
3806
  if (!concise) {
3745
3807
  const verb = capability === "build" ? "build on this Mac" : "host simulators on this Mac";
3746
- return `${name} (node ${nodeId.slice(0, 4)}...) asks to ${verb}. Approve? [y/N] `;
3808
+ return `${name} (node ${nodeId.slice(0, 4)}...) asks to ${verb}. Approve? [Y/n] `;
3747
3809
  }
3748
- return `Approve ${name} to ${capability === "build" ? "build here (runs its project code" : "host simulators here (runs its app code"} on this Mac)? [y/N] `;
3810
+ return `Approve ${name} to ${capability === "build" ? "build here (runs its project code" : "host simulators here (runs its app code"} on this Mac)? [Y/n] `;
3749
3811
  }
3750
3812
  function serverText(desktop, decision, version) {
3751
3813
  if (desktop) return `using the app's stim-server ${version}`;
@@ -4125,6 +4187,93 @@ async function runSetup(args, version, deps) {
4125
4187
  return journal.exit;
4126
4188
  }
4127
4189
  //#endregion
4190
+ //#region src/request-log.ts
4191
+ const SLOW_MS = 1e3;
4192
+ const METHOD = /^[a-z][a-z0-9.-]{0,47}$/;
4193
+ const ERROR_REPEAT_MS = 6e4;
4194
+ const MAX_REMEMBERED = 256;
4195
+ /** One token of a service-log line: no whitespace or control characters, so a client cannot forge another field or line. */
4196
+ const printable = (value) => String(value).replace(/[^\x21-\x7e]+/g, "_").slice(0, 80);
4197
+ const text = (fields) => Object.entries(fields).filter(([, value]) => value !== void 0 && value !== null).map(([key, value]) => `${key}=${printable(value)}`).join(" ");
4198
+ /**
4199
+ * Request timing for stim-server. An error reply always goes to the service log (stderr), once a minute per
4200
+ * client, method and code. With debug logging on, every request goes to the service log and to
4201
+ * `logs/debug/server.ndjson` with its duration and the slow steps. Lines carry the method, the client's device id,
4202
+ * the CLI run id, durations and the error code: never params, messages, tokens or tickets.
4203
+ */
4204
+ function createRequestLog({ service = (line) => console.error(line), debug = createDebugLog("server"), now = Date.now } = {}) {
4205
+ const lastError = /* @__PURE__ */ new Map();
4206
+ const errorDue = (key) => {
4207
+ const at = now();
4208
+ const last = lastError.get(key);
4209
+ if (last !== void 0 && at - last < ERROR_REPEAT_MS) return false;
4210
+ lastError.delete(key);
4211
+ lastError.set(key, at);
4212
+ if (lastError.size > MAX_REMEMBERED) lastError.delete(lastError.keys().next().value);
4213
+ return true;
4214
+ };
4215
+ return {
4216
+ track() {
4217
+ let client = {
4218
+ id: null,
4219
+ runId: null
4220
+ };
4221
+ const inflight = /* @__PURE__ */ new Map();
4222
+ return {
4223
+ identify(next) {
4224
+ client = next;
4225
+ },
4226
+ begin(id, method) {
4227
+ if (id === null) return;
4228
+ if (inflight.size >= MAX_REMEMBERED) inflight.delete(inflight.keys().next().value);
4229
+ inflight.set(id, {
4230
+ method: METHOD.test(method) ? method : "unknown",
4231
+ start: now(),
4232
+ steps: {}
4233
+ });
4234
+ },
4235
+ step(id, name, ms) {
4236
+ const entry = inflight.get(id);
4237
+ if (entry) entry.steps[name] = Math.round(ms);
4238
+ },
4239
+ reply(message) {
4240
+ if (!message || typeof message !== "object" || !("id" in message)) return;
4241
+ const { id, error } = message;
4242
+ const entry = inflight.get(id);
4243
+ if (entry) inflight.delete(id);
4244
+ if (!entry && !error) return;
4245
+ const code = error ? String(error.code ?? "error").slice(0, 64) : void 0;
4246
+ const method = entry?.method ?? "unknown";
4247
+ const ms = entry ? now() - entry.start : void 0;
4248
+ const fields = {
4249
+ method,
4250
+ client: client.id ?? void 0,
4251
+ run: client.runId ?? void 0,
4252
+ ms,
4253
+ ...ms !== void 0 && ms >= SLOW_MS ? { slow: true } : {},
4254
+ ...entry?.steps,
4255
+ error: code
4256
+ };
4257
+ if (debug.enabled()) {
4258
+ service(`stim-server: debug request ${text(fields)}`);
4259
+ const { run, ...rest } = fields;
4260
+ debug.log("request", {
4261
+ ...rest,
4262
+ runId: run
4263
+ });
4264
+ } else if (code !== void 0 && errorDue(`${client.id}|${method}|${code}`)) service(`stim-server: request failed ${text(fields)}`);
4265
+ }
4266
+ };
4267
+ },
4268
+ host(event, fields, failed = false) {
4269
+ if (debug.enabled()) {
4270
+ service(`stim-server: debug ${event} ${text(fields)}`);
4271
+ debug.log(event, fields);
4272
+ } else if (failed && errorDue(`host|${event}|${String(fields.host)}`)) service(`stim-server: ${event} ${text(fields)}`);
4273
+ }
4274
+ };
4275
+ }
4276
+ //#endregion
4128
4277
  //#region src/agent-driver.ts
4129
4278
  /** The driver cannot run or cannot scope an app; the message is safe to show a client. */
4130
4279
  var AgentDriverUnavailable = class extends Error {};
@@ -8780,6 +8929,7 @@ var LatestFrames = class {
8780
8929
  //#endregion
8781
8930
  //#region src/hosted-relay.ts
8782
8931
  const TIMEOUT_MS = 1e4;
8932
+ const REQUEST_TIMEOUT_MS = 2e4;
8783
8933
  const KEYFRAME_RETRY_MS = 1e3;
8784
8934
  const CONGESTION_NOTICE_MS = 250;
8785
8935
  function routeOf(message) {
@@ -8802,6 +8952,8 @@ var Upstream = class {
8802
8952
  users = 0;
8803
8953
  features = [];
8804
8954
  onClose = null;
8955
+ connectMs = 0;
8956
+ helloMs = 0;
8805
8957
  token;
8806
8958
  constructor(endpoint, token) {
8807
8959
  this.token = token;
@@ -8868,6 +9020,7 @@ var Upstream = class {
8868
9020
  }
8869
9021
  /** Connects and says hello with the token; the host must grant `capability` to this Mac. */
8870
9022
  async open(version, capability = "device-host") {
9023
+ const started = Date.now();
8871
9024
  await new Promise((resolve, reject) => {
8872
9025
  const done = (error) => {
8873
9026
  clearTimeout(timer);
@@ -8888,6 +9041,7 @@ var Upstream = class {
8888
9041
  this.socket.once("close", closed);
8889
9042
  this.socket.once("error", failed);
8890
9043
  });
9044
+ this.connectMs = Date.now() - started;
8891
9045
  const reply = await this.request("hello", {
8892
9046
  protocol: 1,
8893
9047
  client: {
@@ -8896,6 +9050,7 @@ var Upstream = class {
8896
9050
  },
8897
9051
  auth: { deviceToken: this.token }
8898
9052
  });
9053
+ this.helloMs = Date.now() - started - this.connectMs;
8899
9054
  if ("error" in reply) throw new Error(reply.error.message);
8900
9055
  if (!isJsonObject(reply.result) || !Array.isArray(reply.result.capabilities) || !reply.result.capabilities.includes(capability)) throw new Error(`the host did not grant ${capability} access`);
8901
9056
  if (Array.isArray(reply.result.features)) this.features = reply.result.features.filter((feature) => typeof feature === "string");
@@ -8904,7 +9059,7 @@ var Upstream = class {
8904
9059
  if (this.ended) return Promise.reject(this.ended);
8905
9060
  return new Promise((resolve, reject) => {
8906
9061
  const id = this.nextId++;
8907
- const timer = setTimeout(() => this.fail(`the host did not answer ${method} in time`), TIMEOUT_MS);
9062
+ const timer = setTimeout(() => this.fail(`the host was busy or slow: it did not answer ${method} within ${REQUEST_TIMEOUT_MS / 1e3} seconds`), REQUEST_TIMEOUT_MS);
8908
9063
  this.pending.set(id, {
8909
9064
  resolve: (reply) => {
8910
9065
  clearTimeout(timer);
@@ -8977,6 +9132,7 @@ var HostConnections = class {
8977
9132
  /** Checks the credential and pinned node on every call, then joins or opens the connection to that endpoint. */
8978
9133
  async acquire(host) {
8979
9134
  let token = "";
9135
+ const started = Date.now();
8980
9136
  try {
8981
9137
  const credential = readDeviceHostMachines().find((entry) => entry.machine === host.machine);
8982
9138
  if (!credential || credential.state !== "approved") throw new Error("no approved device-host credential");
@@ -8992,6 +9148,7 @@ var HostConnections = class {
8992
9148
  endpoint.host
8993
9149
  ]);
8994
9150
  let entry = this.open.get(key);
9151
+ const created = !entry;
8995
9152
  if (!entry) {
8996
9153
  const connection = new Upstream(endpoint, token);
8997
9154
  const opened = {
@@ -9012,10 +9169,24 @@ var HostConnections = class {
9012
9169
  lease.release();
9013
9170
  throw cause;
9014
9171
  }
9172
+ this.options.log?.("host_connect", {
9173
+ host: host.machine,
9174
+ ms: Date.now() - started,
9175
+ ...created ? {
9176
+ connectMs: entry.connection.connectMs,
9177
+ helloMs: entry.connection.helloMs
9178
+ } : { reused: true }
9179
+ });
9015
9180
  return lease;
9016
9181
  } catch (cause) {
9017
9182
  const reason = cause.message;
9018
- throw new Error(token ? reason.replaceAll(token, "[redacted]") : reason, { cause });
9183
+ const safe = token ? reason.replaceAll(token, "[redacted]") : reason;
9184
+ this.options.log?.("host_connect", {
9185
+ host: host.machine,
9186
+ ms: Date.now() - started,
9187
+ error: safe.slice(0, 200)
9188
+ }, true);
9189
+ throw new Error(safe, { cause });
9019
9190
  }
9020
9191
  }
9021
9192
  close() {
@@ -13009,7 +13180,9 @@ function take(bucket, cost, perSecond, capacity) {
13009
13180
  bucket.tokens -= cost;
13010
13181
  return true;
13011
13182
  }
13183
+ const trackers = /* @__PURE__ */ new WeakMap();
13012
13184
  function send(socket, message) {
13185
+ if (typeof message !== "string") trackers.get(socket)?.reply(message);
13013
13186
  if (socket.readyState === socket.OPEN) socket.send(typeof message === "string" ? message : JSON.stringify(message));
13014
13187
  }
13015
13188
  function buildAllowed(session) {
@@ -13111,6 +13284,7 @@ async function startServer(options) {
13111
13284
  }
13112
13285
  return hostProbe;
13113
13286
  };
13287
+ const requestLog = options.requestLog ?? createRequestLog();
13114
13288
  const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
13115
13289
  const authTimeoutMs = options.authTimeoutMs ?? 5e3;
13116
13290
  const feeds = new FeedPool(options.stimCli, options.env);
@@ -13425,7 +13599,10 @@ async function startServer(options) {
13425
13599
  limits: options.pushLimits
13426
13600
  });
13427
13601
  const relays = /* @__PURE__ */ new Map();
13428
- const hostConnections = new HostConnections(options.hostedRelay ?? { status: () => readRawTailscaleStatus(tailscaleNow().binary, options.env) }, options.serverVersion);
13602
+ const hostConnections = new HostConnections({
13603
+ ...options.hostedRelay ?? { status: () => readRawTailscaleStatus(tailscaleNow().binary, options.env) },
13604
+ log: requestLog.host
13605
+ }, options.serverVersion);
13429
13606
  let revocationCheck = null;
13430
13607
  let checkedRegistry = null;
13431
13608
  const checkRevocations = () => {
@@ -13489,6 +13666,9 @@ async function startServer(options) {
13489
13666
  const commands = /* @__PURE__ */ new Set();
13490
13667
  let nextSubscription = 1;
13491
13668
  let device = null;
13669
+ const tracker = requestLog.track();
13670
+ trackers.set(socket, tracker);
13671
+ let runId = null;
13492
13672
  let buildSession = null;
13493
13673
  let queue = Promise.resolve();
13494
13674
  let archiveReads = Promise.resolve();
@@ -13529,7 +13709,15 @@ async function startServer(options) {
13529
13709
  if (!isJsonObject(params) || !auth || !isJsonObject(params.client)) return refuse(id, "bad-request", "hello needs params.client and params.auth.", CLOSE_BAD_REQUEST);
13530
13710
  if (params.protocol !== protocol_exports.PROTOCOL_VERSION) return refuse(id, "protocol-unsupported", `This server speaks protocol ${protocol_exports.PROTOCOL_VERSION}.`, CLOSE_BAD_REQUEST);
13531
13711
  clearTimeout(timer);
13712
+ runId = validRunId(params.client.runId);
13713
+ tracker.identify({
13714
+ id: null,
13715
+ runId
13716
+ });
13717
+ const probing = hostHealth();
13718
+ const whoisStarted = Date.now();
13532
13719
  const identity = await identify();
13720
+ tracker.step(id, "whois", Date.now() - whoisStarted);
13533
13721
  if (socket.readyState !== socket.OPEN) return;
13534
13722
  if (!identity) return refuse(id, "identity-unavailable", `tailscale whois did not identify ${peer}.`, CLOSE_UNAUTHORIZED);
13535
13723
  let outcome;
@@ -13583,8 +13771,15 @@ async function startServer(options) {
13583
13771
  return;
13584
13772
  }
13585
13773
  device = outcome.device;
13774
+ tracker.identify({
13775
+ id: device.id,
13776
+ runId
13777
+ });
13586
13778
  sessions.set(socket, device);
13587
13779
  sampler.start();
13780
+ const probeWaited = Date.now();
13781
+ const host = options.host ? await probing : void 0;
13782
+ if (options.host) tracker.step(id, "probe", Date.now() - probeWaited);
13588
13783
  send(socket, {
13589
13784
  id,
13590
13785
  result: {
@@ -13595,7 +13790,7 @@ async function startServer(options) {
13595
13790
  stim: options.stimVersion,
13596
13791
  home: homedir()
13597
13792
  },
13598
- ...options.host ? { host: await hostHealth() } : {},
13793
+ ...options.host ? { host } : {},
13599
13794
  capabilities: device.capabilities,
13600
13795
  features: [...protocol_exports.FEATURES],
13601
13796
  actions: device.capabilities.includes("control") ? [...protocol_exports.ACTIONS] : [],
@@ -14743,6 +14938,7 @@ async function startServer(options) {
14743
14938
  });
14744
14939
  return refuse(id, "bad-request", "Expected {id, method, params}.", CLOSE_BAD_REQUEST);
14745
14940
  }
14941
+ tracker.begin(id, message.method);
14746
14942
  if (message.method === "hello") return hello(id, message.params);
14747
14943
  if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_UNAUTHORIZED);
14748
14944
  if (protocol_exports.DEVICE_HOST_METHODS.includes(message.method)) {
@@ -14939,6 +15135,10 @@ async function startServer(options) {
14939
15135
  return;
14940
15136
  }
14941
15137
  const peer = peerAddress(request);
15138
+ if (options.loopbackOnly && peer !== null) {
15139
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
15140
+ return;
15141
+ }
14942
15142
  if (limiter.blocked(peer ?? "local")) {
14943
15143
  socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
14944
15144
  return;
@@ -14980,7 +15180,8 @@ async function startServer(options) {
14980
15180
  },
14981
15181
  tailscale: healthTailscale(tailscale),
14982
15182
  route,
14983
- nativeViewerOpened
15183
+ nativeViewerOpened,
15184
+ ...options.loopbackOnly ? { loopbackOnly: true } : {}
14984
15185
  };
14985
15186
  response.writeHead(startup.state === "ready" ? 200 : 503, { "content-type": "application/json" }).end(JSON.stringify(body));
14986
15187
  };
@@ -15027,6 +15228,10 @@ async function startServer(options) {
15027
15228
  };
15028
15229
  async function listenOn(host) {
15029
15230
  const server = createServer$1((request, response) => {
15231
+ if (options.loopbackOnly && peerAddress(request) !== null) {
15232
+ response.writeHead(403, { "content-type": "text/plain" }).end("This stim-server serves this Mac only.\n");
15233
+ return;
15234
+ }
15030
15235
  if (localHealthRequest(request)) {
15031
15236
  answerHealth(response);
15032
15237
  return;
@@ -15095,7 +15300,7 @@ async function startServer(options) {
15095
15300
  const monitor = options.tailscaleMonitor;
15096
15301
  const listenFailures = /* @__PURE__ */ new Set();
15097
15302
  const reconcile = async (snapshot) => {
15098
- const tailnet = snapshot.state.state === "running" ? snapshot.state.ips : [];
15303
+ const tailnet = snapshot.state.state === "running" && !options.loopbackOnly ? snapshot.state.ips : [];
15099
15304
  const wanted = /* @__PURE__ */ new Set([...options.hosts, ...tailnet]);
15100
15305
  for (const [host, listener] of servers) {
15101
15306
  if (wanted.has(host)) continue;
@@ -15247,8 +15452,9 @@ function watchTailscale(options) {
15247
15452
  //#endregion
15248
15453
  //#region bin/stim-server.ts
15249
15454
  const USAGE = `Usage:
15250
- stim-server [--port <n>] [--env KEY=VALUE]... [--path-prepend <dir>]...
15455
+ stim-server [--port <n>] [--loopback-only] [--env KEY=VALUE]... [--path-prepend <dir>]...
15251
15456
  serve paired clients (default port ${DEFAULT_PORT});
15457
+ --loopback-only serves this Mac only (127.0.0.1, no remote peers);
15252
15458
  --env and --path-prepend apply after the login shell's environment
15253
15459
  stim-server setup --client <node-id> --ticket <t> --expires <iso>
15254
15460
  [--build] [--device-host] [--port <n>] [--label <name>]
@@ -15298,7 +15504,7 @@ function routeNote(route, port) {
15298
15504
  function macName(tailscale) {
15299
15505
  return tailscale.state === "running" && tailscale.hostName || hostname();
15300
15506
  }
15301
- async function serve(port, extraEnv, pathPrepend) {
15507
+ async function serve(port, extraEnv, pathPrepend, loopbackOnly) {
15302
15508
  const captureHost = hostFromExecutable(process.env.STIM_HOST_EXECUTABLE);
15303
15509
  const login = loginShellEnvironment();
15304
15510
  if (!login) console.error("stim-server: could not read the login shell environment; using this process environment.");
@@ -15309,7 +15515,7 @@ async function serve(port, extraEnv, pathPrepend) {
15309
15515
  if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
15310
15516
  const launchdLabel = process.env.XPC_SERVICE_NAME;
15311
15517
  const tailscaleBinary = findTailscale(env);
15312
- const tailscale = tailscaleStatus(tailscaleBinary, env);
15518
+ const tailscale = await readTailscaleStatus(tailscaleBinary, env);
15313
15519
  const stim = bundledStim();
15314
15520
  const monitor = watchTailscale({
15315
15521
  env,
@@ -15324,6 +15530,7 @@ async function serve(port, extraEnv, pathPrepend) {
15324
15530
  name: macName(tailscale),
15325
15531
  host: captureHost,
15326
15532
  hosts: ["127.0.0.1"],
15533
+ ...loopbackOnly ? { loopbackOnly: true } : {},
15327
15534
  port,
15328
15535
  stimCli: stim.cli,
15329
15536
  stimVersion: stim.version,
@@ -15354,13 +15561,13 @@ async function serve(port, extraEnv, pathPrepend) {
15354
15561
  }
15355
15562
  if (note) console.error(note);
15356
15563
  };
15357
- await announce(tailscaleBinary, tailscale);
15358
- monitor.onChange((snapshot, previous) => {
15564
+ if (!loopbackOnly) monitor.onChange((snapshot, previous) => {
15359
15565
  const { state } = snapshot;
15360
15566
  if (state.state === "running") console.log("Tailscale is running.");
15361
15567
  else if (previous.state.state === "running") console.error("Tailscale stopped.");
15362
15568
  if (state.state === "running" || previous.state.state === "running") announce(snapshot.binary, state);
15363
15569
  });
15570
+ if (!loopbackOnly) await announce(tailscaleBinary, tailscale);
15364
15571
  const shutdown = () => {
15365
15572
  monitor.stop();
15366
15573
  server.close().catch((error) => {
@@ -15384,7 +15591,7 @@ const SCOPE_TEXT = {
15384
15591
  };
15385
15592
  async function pair(port, json, control) {
15386
15593
  const binary = findTailscale(process.env);
15387
- const tailscale = tailscaleStatus(binary, process.env);
15594
+ const tailscale = await readTailscaleStatus(binary, process.env);
15388
15595
  let endpoint = `ws://127.0.0.1:${port}`;
15389
15596
  let note = tailscaleNote(tailscale);
15390
15597
  if (note) note = `${note} The endpoint above only works on this Mac.`;
@@ -15491,6 +15698,7 @@ async function main() {
15491
15698
  allowPositionals: true,
15492
15699
  options: {
15493
15700
  port: { type: "string" },
15701
+ "loopback-only": { type: "boolean" },
15494
15702
  json: { type: "boolean" },
15495
15703
  control: { type: "boolean" },
15496
15704
  read: { type: "boolean" },
@@ -15533,7 +15741,8 @@ async function main() {
15533
15741
  }
15534
15742
  if ((values.label !== void 0 || values.serve) && !service) fail(`--label and --serve apply only to \`service\`.\n${USAGE}`);
15535
15743
  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);
15744
+ if (values["loopback-only"] && command !== void 0) fail(`--loopback-only applies only to serving.\n${USAGE}`);
15745
+ if (command === void 0) return serve(port, extraEnv, pathPrepend, values["loopback-only"] === true);
15537
15746
  const grant = command === "devices" && sub === "grant";
15538
15747
  if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
15539
15748
  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-BR9FPA63.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.18.0",
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.18.0",
32
+ "stim": "1.18.0",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {