@stim-cli/server 1.17.0 → 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
 
@@ -57,7 +65,7 @@ app (checked at most every 5 seconds), or `null` when the check fails. Stim
57
65
  Desktop uses it to find a running server and show its route. A request through `tailscale serve` or on a Tailscale address
58
66
  without an `Origin` or `Sec-Fetch-Site` header, which a web page's request
59
67
  carries, gets only `{ "server": "stim-server", "version", "protocol" }`, which Stim
60
- Desktop's Build machines list uses to find stim-server on the other Macs of the
68
+ Desktop's Remote Macs list uses to find stim-server on the other Macs of the
61
69
  tailnet. A request from this Mac with a `Host` other than `127.0.0.1` or
62
70
  `localhost` gets HTTP 426, like any other plain HTTP request.
63
71
 
@@ -91,7 +99,7 @@ A person on the worker runs the command generated for their client Mac:
91
99
  ```bash
92
100
  stim-server setup --client <node-id> --ticket <43-base64url-characters> --expires <ISO-time> \
93
101
  --build --device-host [--port <n>] [--label <name>] \
94
- [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json]
102
+ [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json] [--verbose]
95
103
  ```
96
104
 
97
105
  If the server is not installed globally, use `npx --yes --package @stim-cli/server@<version> stim-server`
@@ -144,8 +152,17 @@ unreadable routes refuse with a remedy. Tool checks print fixes for Xcode,
144
152
  simulator runtimes, CocoaPods, JDK, Android SDK and the Stim build; they install
145
153
  nothing. Android and CocoaPods checks are not needed for hosting alone.
146
154
 
147
- Plain progress goes to stdout; errors use `stim-server: <message>` on stderr.
148
- `--json` sends progress to stderr and prints one final payload with `ok`,
155
+ In a terminal, setup prints a banner, then one line per step with a green check,
156
+ a yellow arrow while a step runs, or a red cross; `NO_COLOR`, `TERM=dumb` and a
157
+ non-terminal give plain `[ok]`, `[failed]` and `[pending]` marks, no colors and no
158
+ in-place line. The y/N questions stay, one line each. The run ends with a
159
+ headline such as "janics-mac-mini is ready to build for Janic's MacBook Pro", one
160
+ line per approved capability, a `Fix` line for each skipped permission or missing
161
+ tool, and one `To undo` line. `--verbose` prints every step with its detail, the
162
+ install, service and route notes, and the long summary with the undo commands, as
163
+ earlier releases did. Plain progress goes to stdout; errors use
164
+ `stim-server: <message>` on stderr (when stdout is a terminal, the refusal appears on stdout only).
165
+ `--json` sends the long (verbose) progress to stderr, without the banner, and prints one final payload with `ok`,
149
166
  `label`, `port`, `route` (state, DNS name, HTTPS port), `server` (version and
150
167
  Stim build), `managed`, separate `granted` request ids and client identities,
151
168
  `permissions`, `tools` and `warnings`. Final permission values are `granted`,
@@ -172,7 +189,7 @@ with detail `interrupted`, completes the journal, releases the setup claim,
172
189
  and exits 1. A typed N also exits 1; a question timeout follows ticket expiry
173
190
  and exits 2.
174
191
 
175
- The summary lists what is ready and the undo commands:
192
+ The `To undo` line (or the `--verbose` summary) lists the undo commands:
176
193
 
177
194
  ```bash
178
195
  stim-server devices revoke <build-request-id>
@@ -199,8 +216,9 @@ same tailnet or with the Mac shared to the phone's user.
199
216
  addresses, never on every interface. It re-reads the Tailscale state in the
200
217
  background, so a server that started before Tailscale was up, or while it did
201
218
  not answer, starts listening on the Tailscale addresses once it runs, and stops
202
- when it goes away. In Stim Desktop, choose **Set up connection** in Settings >
203
- 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
204
222
  missing or unreadable route keeps Desktop pairing unavailable; HTTPS setup may
205
223
  require Tailscale browser approval. Existing routes stay unchanged.
206
224
 
@@ -284,7 +302,7 @@ registration check. `hello` reports
284
302
  the capabilities and actions of the connection's device when it connects; a
285
303
  connection sees a new grant after it reconnects. Under Stim Host, an approved
286
304
  `hello` also carries the same `host` grants as `/health`, which `stim doctor`
287
- reports for hosting machines.
305
+ reports for remote Macs.
288
306
 
289
307
  Every method other than `hello` needs `read`. A device with only `build`
290
308
  gets `forbidden` for all of them.
@@ -335,7 +353,7 @@ bundle, Android APK or prebuilt macOS app, stream the iOS simulator, Android emu
335
353
  iOS and Android apps connect back to Metro on the client Mac. Automatic placement remains in [#2266](https://github.com/appandflow/stim/issues/2266).
336
354
 
337
355
  The Stim client can name expected hosts with
338
- `stim settings set hosting.machines '["<mac>"]'` and request access with
356
+ `stim settings set remote.machines '["<mac>"]'` and request access with
339
357
  `stim doctor --fix`. It stores a separate private credential in
340
358
  `$STIM_HOME/device-host-machines.json`, pins the worker's tailnet node, and
341
359
  reports approval under `deviceHosts` in doctor JSON. Plain doctor makes no
@@ -824,8 +842,8 @@ manifest requests are not launch proof; development remains `unverified` until
824
842
  the workspace observes the app's own bundle delivery.
825
843
  Approved clients use `stim ios|android --remote auto` for automatic device placement.
826
844
  They stay local while there is a free slot, no queued device run, normal memory
827
- pressure, no budget refusal and load below `offload.maxLoadPerCore`. Otherwise
828
- they rank compatible offers from `hosting.machines` and select a host, or wait
845
+ pressure, no budget refusal and load below `server.maxLoadPerCore`. Otherwise
846
+ they rank compatible offers from `remote.machines` and select a host, or wait
829
847
  locally if none admits. A reserve refusal after an offer fails with
830
848
  `STIM_HOSTING_REFUSED` and asks to retry; it does not re-place the built app.
831
849
  Automatic native macOS placement remains separate work.
@@ -938,7 +956,7 @@ before replacement. Automatic placement remains in
938
956
  ## Run as a service
939
957
 
940
958
  `stim-server service install` runs stim-server as a per-user LaunchAgent on
941
- macOS, so a build machine or a phone-serving Mac keeps it running without a
959
+ macOS, so a remote Mac or a phone-serving Mac keeps it running without a
942
960
  terminal:
943
961
 
944
962
  ```bash
@@ -1079,18 +1097,18 @@ and uninstall of one label run one at a time.
1079
1097
  installed and, when `install` created it, the serve route. Logs, pairings and the host app stay; other labels may use the app. Use `--label` and `--port` to run a
1080
1098
  second service beside the first, for example with another `STIM_HOME`.
1081
1099
 
1082
- To set up a build machine: install Stim and stim-server on the Mac, run
1100
+ To set up a remote Mac: install Stim and stim-server on the Mac, run
1083
1101
  `stim-server service install --serve` (with the pins its toolchain needs), set
1084
- `offload.workerRoot` there, and on each client run
1085
- `stim settings set offload.machines '["<mac>"]'` and `stim doctor --fix`.
1086
- Approve the request on the build machine with
1102
+ `server.workerRoot` there, and on each client run
1103
+ `stim settings set remote.machines '["<mac>"]'` and `stim doctor --fix`.
1104
+ Approve the request on the remote Mac with
1087
1105
  `stim-server devices grant <id> --build`.
1088
1106
 
1089
1107
  ## Remote update
1090
1108
 
1091
1109
  A Mac this one approved for builds (`--build`) or device hosting
1092
1110
  (`--device-host`) can update this Mac's `stim-server service` over its tailnet
1093
- connection, so the build machine keeps up with the client without ssh. A
1111
+ connection, so the remote Mac keeps up with the client without ssh. A
1094
1112
  server that offers it lists `server-update` in its `hello` features. A paired
1095
1113
  phone, a `read` or `control` device and a connection from this Mac cannot. The
1096
1114
  methods need that approval, not `read`:
@@ -1126,8 +1144,8 @@ of an existing hosted session still answers during the drain.
1126
1144
 
1127
1145
  Stim Desktop starts these updates through its own local stim-server with two
1128
1146
  methods that only the local Desktop control connection may call:
1129
- `machines.update.start` takes `{ "machine" }`, an approved `offload.machines`
1130
- or `hosting.machines` entry. It connects to that machine's pinned node with
1147
+ `machines.update.start` takes `{ "machine" }`, an approved `remote.machines`
1148
+ entry. It connects to that machine's pinned node with
1131
1149
  the stored credential and asks it for this server's npm release. When this
1132
1150
  server runs from a Stim checkout, it instead packs the checkout's workspace
1133
1151
  packages as `npm pack` would and sends them. `machines.update.status` takes
@@ -1160,7 +1178,7 @@ closes its connections and cancels its builds.
1160
1178
  average per CPU), `builds` (this Mac's own Stim runs in prebuild, pods or
1161
1179
  compile that it did not offload, plus the offloaded builds it runs), `maxBuilds` (its
1162
1180
  `concurrency.maxBuilds`, 0 when unlimited), `maxLoadPerCore` (its
1163
- `offload.maxLoadPerCore`, default 2), and `declined`: why it would refuse a
1181
+ `server.maxLoadPerCore`, default 2), and `declined`: why it would refuse a
1164
1182
  build now, or null. It declines while it runs its limit of offloaded builds,
1165
1183
  while the worker root's volume has less than 10 GiB free, while `builds`
1166
1184
  reaches a non-zero `maxBuilds`, or while `loadPerCore` is at or above
@@ -1197,7 +1215,7 @@ closes its connections and cancels its builds.
1197
1215
  `assemble<variant>` for `abi` with ccache under the area's Stim home. A
1198
1216
  Gradle daemon leaves the build's process group, so no claim or slot tracks
1199
1217
  it. An Android build keeps the client's daemon warm, with Gradle's idle
1200
- timeout set to `offload.gradleDaemonIdleMinutes` (default 30; 0 stops it
1218
+ timeout set to `server.gradleDaemonIdleMinutes` (default 30; 0 stops it
1201
1219
  when the build ends), and stops it when the build gets SIGTERM. Gradle
1202
1220
  fixes a daemon's idle timeout when the daemon starts, so a new value
1203
1221
  applies to the next daemon. Once no build of that client runs, stim-server
@@ -1269,7 +1287,7 @@ closes its connections and cancels its builds.
1269
1287
  is cancelled; one left by a server that crashed stays under
1270
1288
  `repos/<repo>/out/` until you delete it.
1271
1289
 
1272
- The worker root is `offload.workerRoot` in this Mac's Stim settings, or
1290
+ The worker root is `server.workerRoot` in this Mac's Stim settings, or
1273
1291
  `$STIM_HOME/build-worker`. Each client gets `<root>/<device id>/`, with its
1274
1292
  blobs, caches (`CP_HOME_DIR`, `CP_CACHE_DIR`, the pnpm store and
1275
1293
  `GRADLE_USER_HOME` and SwiftPM) and one area
@@ -1712,10 +1730,10 @@ Events are `{ "event", "subscription", ... }`.
1712
1730
  one result for 60 seconds, shared by every connection: a request while the
1713
1731
  commands run waits for them, and `measuredAt` says when they started. The
1714
1732
  commands fail after 150 seconds. Servers that predate it answer
1715
- `unknown-method`. It also carries `buildMachines`, the `buildMachines` list
1733
+ `unknown-method`. It also carries `buildMachines`, the `remoteMachines` list
1716
1734
  of `stim doctor --json --platform ios` (never with `--fix`), so a phone can
1717
- show whether each build machine takes builds. The server runs doctor only
1718
- when `offload.machines` names a machine, else the list is empty; it runs it
1735
+ show whether each remote Mac takes builds. The server runs doctor only
1736
+ when `remote.machines` names a machine, else the list is empty; it runs it
1719
1737
  in the registered workspace that ran doctor for iOS most recently, because
1720
1738
  doctor judges a machine against an app. Like a doctor run from a terminal,
1721
1739
  that records the run for the workspace. `buildMachines` is null with
@@ -1727,7 +1745,7 @@ Events are `{ "event", "subscription", ... }`.
1727
1745
  at a time, and sets `buildMachinesPending` while that background run has not
1728
1746
  settled a result yet, or the settled one is 60 seconds old or older. A client
1729
1747
  that wants the refreshed result asks `machine.details` again. It also
1730
- carries `buildClients`, the builds this Mac ran as a build machine, one entry
1748
+ carries `buildClients`, the builds this Mac ran for other Macs, one entry
1731
1749
  per client Mac read from the `build` records of the audit log, with `id`,
1732
1750
  `name`, `builds`, `failed`, `buildMs`, `lastAt` and the same three counts
1733
1751
  for `today`, most recent client first, with `today` on this Mac's local
@@ -1398,7 +1398,7 @@ function protocolJsonSchema() {
1398
1398
  statsError: { type: "string" },
1399
1399
  buildMachines: {
1400
1400
  type: ["array", "null"],
1401
- description: "`buildMachines` from `stim doctor --json --platform ios`.",
1401
+ description: "`remoteMachines` from `stim doctor --json --platform ios`.",
1402
1402
  items: { type: "object" }
1403
1403
  },
1404
1404
  buildMachinesError: { type: "string" },
@@ -1406,7 +1406,7 @@ function protocolJsonSchema() {
1406
1406
  buildMachinesPending: { type: "boolean" },
1407
1407
  buildClients: {
1408
1408
  type: "array",
1409
- description: "The builds this Mac ran for each client as a build machine, from the audit log.",
1409
+ description: "The builds this Mac ran for each client, from the audit log.",
1410
1410
  items: {
1411
1411
  type: "object",
1412
1412
  required: [
package/dist/protocol.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as protocolJsonSchema } from "./protocol-wBQmYsnp.mjs";
1
+ import { t as protocolJsonSchema } from "./protocol-Btu-PwQS.mjs";
2
2
  export * from "@stim-cli/core/protocol";
3
3
  export { protocolJsonSchema };
@@ -2119,7 +2119,7 @@
2119
2119
  "array",
2120
2120
  "null"
2121
2121
  ],
2122
- "description": "`buildMachines` from `stim doctor --json --platform ios`.",
2122
+ "description": "`remoteMachines` from `stim doctor --json --platform ios`.",
2123
2123
  "items": {
2124
2124
  "type": "object"
2125
2125
  }
@@ -2135,7 +2135,7 @@
2135
2135
  },
2136
2136
  "buildClients": {
2137
2137
  "type": "array",
2138
- "description": "The builds this Mac ran for each client as a build machine, from the audit log.",
2138
+ "description": "The builds this Mac ran for each client, from the audit log.",
2139
2139
  "items": {
2140
2140
  "type": "object",
2141
2141
  "required": [
@@ -1,4 +1,4 @@
1
- import { n as protocol_exports } from "./protocol-wBQmYsnp.mjs";
1
+ import { n as protocol_exports } from "./protocol-Btu-PwQS.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";
@@ -33,6 +33,8 @@ function parseEnvironment(output) {
33
33
  * The login shell's environment, as Stim Desktop captures it: a process started by launchd lacks the
34
34
  * PATH entries and variables such as ANDROID_HOME that the shell profile sets. The shell writes to a
35
35
  * file because a background process started by a profile can keep a pipe open after the shell exits.
36
+ * It runs in its own session: an interactive shell on the caller's terminal takes over the foreground process group,
37
+ * and the caller is then stopped with SIGTTOU at its next terminal write.
36
38
  */
37
39
  function loginShellEnvironment() {
38
40
  const dir = mkdtempSync(join(tmpdir(), "stim-server-env-"));
@@ -45,6 +47,7 @@ function loginShellEnvironment() {
45
47
  file
46
48
  ], {
47
49
  stdio: "ignore",
50
+ detached: true,
48
51
  timeout: 15e3,
49
52
  killSignal: "SIGKILL"
50
53
  });
@@ -845,15 +848,27 @@ function answersAs(health, expected) {
845
848
  return !health.stimBuild || !expected.stimBuild || health.stimBuild === expected.stimBuild;
846
849
  }
847
850
  const packageNames = (entries) => entries.map((entry) => isJsonObject(entry) ? `${String(entry.name)}@${String(entry.version)}` : "?").join(", ");
848
- /** Why `npm audit signatures --json` output does not vouch for every installed package, or null when it does. */
849
- 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 = "") {
850
857
  let report;
851
858
  try {
852
859
  report = JSON.parse(output);
853
860
  } catch {
854
- 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}` : ""}`;
855
871
  }
856
- if (!isJsonObject(report) || !Array.isArray(report.invalid) || !Array.isArray(report.missing)) return "npm audit signatures did not print a JSON report";
857
872
  if (report.invalid.length) return `npm found invalid registry signatures or attestations: ${packageNames(report.invalid)}`;
858
873
  if (report.missing.length) return `npm found packages without registry signatures: ${packageNames(report.missing)}`;
859
874
  return null;
@@ -1104,6 +1119,10 @@ function findTailscale(env) {
1104
1119
  for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "tailscale"))) return join(dir, "tailscale");
1105
1120
  return executable(MAC_APP_BINARY) ? MAC_APP_BINARY : null;
1106
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
+ }
1107
1126
  function parseTailscaleStatus(value) {
1108
1127
  if (!isJsonObject(value) || typeof value.BackendState !== "string") return {
1109
1128
  state: "unavailable",
@@ -1897,7 +1916,6 @@ function serverBuild(script) {
1897
1916
  }
1898
1917
  }
1899
1918
  const describeBuild = (build) => `stim-server ${build.version}${build.stimBuild ? ` (Stim build ${build.stimBuild})` : ""}`;
1900
- const lastLines = (text) => text.trim().split("\n").slice(-8).join("\n");
1901
1919
  function sameFile(a, b) {
1902
1920
  try {
1903
1921
  return realpathSync(a) === realpathSync(b);
@@ -1928,6 +1946,32 @@ function installedIntegrity(staging) {
1928
1946
  return null;
1929
1947
  }
1930
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
+ }
1931
1975
  async function installServer(versions, source, node, log) {
1932
1976
  mkdirSync(versions, { recursive: true });
1933
1977
  for (const entry of readdirSync(versions)) if (entry.startsWith(".install-")) rmSync(join(versions, entry), {
@@ -1941,6 +1985,7 @@ async function installServer(versions, source, node, log) {
1941
1985
  const registry = [`--registry=${REGISTRY}`, `--@stim-cli:registry=${REGISTRY}`];
1942
1986
  let files = [];
1943
1987
  let specs;
1988
+ let localPackages = null;
1944
1989
  if ("release" in source) specs = [`${SERVER_PACKAGE}@${source.release}`];
1945
1990
  else {
1946
1991
  const names = readdirSync(source.from).filter((name) => name.endsWith(".tgz"));
@@ -1949,9 +1994,16 @@ async function installServer(versions, source, node, log) {
1949
1994
  name,
1950
1995
  sha256: createHash("sha256").update(readFileSync(join(source.from, name))).digest("hex")
1951
1996
  }));
1952
- specs = names.map((name) => join(source.from, name));
1953
- }
1954
- 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`);
1955
2007
  log(`Installing ${"release" in source ? `${SERVER_PACKAGE}@${source.release} from ${REGISTRY}` : `${files.length} package file(s) from ${source.from}`}.`);
1956
2008
  const installed = await run$2(npm, [
1957
2009
  "install",
@@ -1966,14 +2018,15 @@ async function installServer(versions, source, node, log) {
1966
2018
  ...specs
1967
2019
  ], env, NPM_TIMEOUT_MS);
1968
2020
  if (!installed.ok) throw new ServiceError(`npm install failed:\n${lastLines(installed.stderr)}`);
1969
- const problem = signatureProblem((await run$2(npm, [
2021
+ const audit = await run$2(npm, [
1970
2022
  "audit",
1971
2023
  "signatures",
1972
2024
  "--json",
1973
2025
  "--prefix",
1974
2026
  staging,
1975
2027
  ...registry
1976
- ], env, NPM_TIMEOUT_MS)).stdout);
2028
+ ], env, NPM_TIMEOUT_MS);
2029
+ const problem = signatureProblem(audit.stdout, audit.stderr);
1977
2030
  if (problem) throw new ServiceError(`${problem}. Not switching to it.`);
1978
2031
  const script = join(staging, ...SERVER_SCRIPT);
1979
2032
  const build = existsSync(script) ? serverBuild(script) : null;
@@ -2534,8 +2587,8 @@ function nonEmptyDir(path) {
2534
2587
  }
2535
2588
  }
2536
2589
  function gradleDaemonIdleMs() {
2537
- const definition = settingDefinition("offload.gradleDaemonIdleMinutes");
2538
- const value = loadConfig()?.offload?.gradleDaemonIdleMinutes;
2590
+ const definition = settingDefinition("server.gradleDaemonIdleMinutes");
2591
+ const value = loadConfig()?.server?.gradleDaemonIdleMinutes;
2539
2592
  return (typeof value === "number" && settingValueError(definition, value) === null ? value : definition.default) * 6e4;
2540
2593
  }
2541
2594
  const GRADLE_DAEMON = "org.gradle.launcher.daemon.bootstrap.GradleDaemon";
@@ -3258,6 +3311,106 @@ var BuildSession = class {
3258
3311
  }
3259
3312
  };
3260
3313
  //#endregion
3314
+ //#region src/setup-output.ts
3315
+ function setupDisplayFor(stdoutIsTty, env, raw) {
3316
+ return {
3317
+ tty: stdoutIsTty,
3318
+ color: stdoutIsTty && !env.NO_COLOR && env.TERM !== "dumb",
3319
+ raw
3320
+ };
3321
+ }
3322
+ const BANNER = [
3323
+ " _ _",
3324
+ " ___| |_(_)_ __ ___ ___ ___ _ ____ _____ _ __",
3325
+ " / __| __| | '_ ` _ \\ ____ / __|/ _ \\ '__\\ \\ / / _ \\ '__|",
3326
+ " \\__ \\ |_| | | | | | |____|\\__ \\ __/ | \\ V / __/ |",
3327
+ " |___/\\__|_|_| |_| |_| |___/\\___|_| \\_/ \\___|_|"
3328
+ ];
3329
+ const CHECK = String.fromCharCode(10003);
3330
+ const CROSS = String.fromCharCode(10007);
3331
+ const ARROW = String.fromCharCode(8594);
3332
+ const CODES = {
3333
+ green: 32,
3334
+ yellow: 33,
3335
+ red: 31,
3336
+ dim: 2,
3337
+ bold: 1
3338
+ };
3339
+ const WAITING = /* @__PURE__ */ new Set([
3340
+ "approve",
3341
+ "permissions.screenRecording",
3342
+ "permissions.deviceControl"
3343
+ ]);
3344
+ var SetupPrinter = class {
3345
+ transient = false;
3346
+ write;
3347
+ display;
3348
+ verbose;
3349
+ constructor(write, display, verbose) {
3350
+ this.write = write;
3351
+ this.display = display;
3352
+ this.verbose = verbose;
3353
+ }
3354
+ paint(code, text) {
3355
+ return this.display.color ? `\u001b[${CODES[code]}m${text}\u001b[0m` : text;
3356
+ }
3357
+ mark(state) {
3358
+ const { color } = this.display;
3359
+ if (state === "ok") return color ? this.paint("green", CHECK) : "[ok]";
3360
+ if (state === "failed") return color ? this.paint("red", CROSS) : "[failed]";
3361
+ if (state === "running") return color ? this.paint("yellow", ARROW) : "[..]";
3362
+ return color ? this.paint("yellow", "!") : `[${state}]`;
3363
+ }
3364
+ clear() {
3365
+ if (!this.transient) return;
3366
+ this.display.raw("\r\x1B[2K");
3367
+ this.transient = false;
3368
+ }
3369
+ line(text) {
3370
+ this.clear();
3371
+ this.write(text);
3372
+ }
3373
+ banner() {
3374
+ if (!this.display.tty) return;
3375
+ this.write(this.paint("bold", BANNER.join("\n")));
3376
+ this.write("");
3377
+ }
3378
+ step(step) {
3379
+ if (this.verbose) {
3380
+ const { state, title, detail, fix } = step;
3381
+ this.line(`[${state}] ${title}${detail ? `: ${detail}` : ""}${fix ? `\nFix: ${fix}` : ""}`);
3382
+ return;
3383
+ }
3384
+ if (step.state === "running") {
3385
+ if (!step.running) return;
3386
+ if (this.display.tty && this.display.color) {
3387
+ this.clear();
3388
+ this.display.raw(`${this.mark("running")} ${step.running}`);
3389
+ this.transient = true;
3390
+ } else if (WAITING.has(step.id) || this.display.tty) this.line(`${this.mark("running")} ${step.running}`);
3391
+ return;
3392
+ }
3393
+ if (step.hidden) {
3394
+ this.clear();
3395
+ return;
3396
+ }
3397
+ const text = step.text ?? `${step.title}${step.detail ? `: ${step.detail}` : ""}`;
3398
+ this.line(`${this.mark(step.state)} ${text}`);
3399
+ }
3400
+ result(lines) {
3401
+ this.clear();
3402
+ for (const { state, text } of lines) this.write(`${this.mark(state)} ${text}`);
3403
+ }
3404
+ headline(state, text) {
3405
+ this.clear();
3406
+ this.write("");
3407
+ this.write(`${this.mark(state)} ${this.paint(state === "ok" ? "green" : state === "failed" ? "red" : "yellow", text)}`);
3408
+ }
3409
+ dim(text) {
3410
+ this.line(this.paint("dim", text));
3411
+ }
3412
+ };
3413
+ //#endregion
3261
3414
  //#region src/setup-journal.ts
3262
3415
  function writeSetupJournal(hash, journal) {
3263
3416
  const file = setupJournalFile(hash);
@@ -3324,7 +3477,8 @@ function parseSetupArgs(args, now) {
3324
3477
  multiple: true
3325
3478
  },
3326
3479
  yes: { type: "boolean" },
3327
- json: { type: "boolean" }
3480
+ json: { type: "boolean" },
3481
+ verbose: { type: "boolean" }
3328
3482
  }
3329
3483
  });
3330
3484
  if (!values.client?.trim()) throw new SetupRefusal("--client takes a tailnet node id.");
@@ -3353,7 +3507,8 @@ function parseSetupArgs(args, now) {
3353
3507
  env,
3354
3508
  pathPrepend,
3355
3509
  yes: values.yes === true,
3356
- json: values.json === true
3510
+ json: values.json === true,
3511
+ verbose: values.verbose === true
3357
3512
  };
3358
3513
  }
3359
3514
  function selectGrants(records, match) {
@@ -3394,6 +3549,11 @@ function setupVersionDecision(current, desired, managed) {
3394
3549
  if (managed && compareVersions(current, desired) < 0) return "update";
3395
3550
  return compareVersions(current.replace(/-.*/, ""), SETUP_MIN_VERSION) < 0 ? "too-old" : "reuse";
3396
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
+ }
3397
3557
  function setupExitCode(journal) {
3398
3558
  if (journal.steps.some((s) => s.state === "failed")) return 1;
3399
3559
  if (journal.granted.length === 0) return 2;
@@ -3456,6 +3616,7 @@ function defaultSetupDeps() {
3456
3616
  now: Date.now,
3457
3617
  sleep,
3458
3618
  tty: process.stdin.isTTY === true,
3619
+ display: setupDisplayFor(process.stdout.isTTY === true, process.env, (text) => void process.stdout.write(text)),
3459
3620
  stdout: (line) => console.log(line),
3460
3621
  stderr: (line) => console.error(line),
3461
3622
  confirm: (question, timeoutMs) => confirmSetup(createInterface({
@@ -3488,7 +3649,7 @@ function defaultSetupDeps() {
3488
3649
  if (process.platform !== "darwin") throw new SetupRefusal("setup runs only on macOS.");
3489
3650
  if (compareVersions(process.versions.node, "22.12.0") < 0) throw new SetupRefusal("setup needs Node 22.12 or later.");
3490
3651
  const status = await readRawTailscaleStatus(binary(), process.env);
3491
- 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)));
3492
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");
3493
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.");
3494
3655
  return { dnsName: status.Self.DNSName.replace(/\.$/, "") };
@@ -3518,7 +3679,6 @@ function defaultSetupDeps() {
3518
3679
  records: (now) => [...readBuildClients(now), ...readDeviceHostClients(now)],
3519
3680
  grant: grantDevice,
3520
3681
  panes: hostPermissionPanes,
3521
- permissions: readHostPermissions,
3522
3682
  requestPermissions: requestHostPermissions,
3523
3683
  async openPane(pane) {
3524
3684
  const result = await run$2("/usr/bin/open", [`x-apple.systempreferences:com.apple.preference.security?${pane}`]);
@@ -3552,28 +3712,30 @@ function requireApprovalMode(deps, options) {
3552
3712
  async function checkPermissions(deps, options, host, desktop, output, report) {
3553
3713
  const wait = (work) => interruptible(work, deps.signal);
3554
3714
  const panes = await wait(deps.panes());
3555
- for (const [key, field, title, pane, feature] of [[
3715
+ for (const [key, field, title, pane, feature, label] of [[
3556
3716
  "screenRecording",
3557
3717
  "screenRecording",
3558
3718
  panes.screen,
3559
3719
  "Privacy_ScreenCapture",
3560
- "viewing hosted simulators"
3720
+ "viewing hosted simulators",
3721
+ "Screen recording"
3561
3722
  ], [
3562
3723
  "deviceControl",
3563
3724
  "accessibility",
3564
3725
  panes.control,
3565
3726
  "Privacy_Accessibility",
3566
- "controlling hosted simulators"
3727
+ "controlling hosted simulators",
3728
+ "Device control"
3567
3729
  ]]) {
3568
3730
  const id = `permissions.${key}`;
3569
- const read = async () => desktop ? (await wait(deps.health(options.port)))?.host ?? null : wait(deps.permissions(host.executable));
3731
+ const read = async () => (await wait(deps.health(options.port)))?.host ?? null;
3570
3732
  let permissions = await read();
3571
3733
  if (permissions?.[field]) {
3572
3734
  output.permissions[key] = "granted";
3573
- report(id, "ok", title);
3735
+ report(id, "ok", title, "Already granted.", void 0, { text: `${label} permission already granted` });
3574
3736
  continue;
3575
3737
  }
3576
- report(id, "running", title, `Click Allow for ${desktop ? "the app serving this port" : host.name}. Prompts appear on this Mac's screen. Press s in a terminal to skip.`);
3738
+ report(id, "running", title, `Click Allow for ${desktop ? "the app serving this port" : host.name}. Prompts appear on this Mac's screen. Press s in a terminal to skip.`, void 0, { running: `${label} permission: click Allow for ${desktop ? "the app serving this port" : host.name} on this Mac's screen (press s to skip)` });
3577
3739
  if (!desktop) await wait(deps.requestPermissions(host.app));
3578
3740
  const start = deps.now();
3579
3741
  let opened = false, skipped = false;
@@ -3589,10 +3751,87 @@ async function checkPermissions(deps, options, host, desktop, output, report) {
3589
3751
  permissions = await read();
3590
3752
  }
3591
3753
  output.permissions[key] = permissions?.[field] ? "granted" : skipped ? "skipped" : "denied";
3592
- report(id, permissions?.[field] ? "ok" : skipped ? "skipped" : "pending", title, permissions?.[field] ? "Granted." : `${skipped ? "skipped" : "pending"}: ${feature} will not work.`, `System Settings > Privacy & Security > ${title}`);
3754
+ report(id, permissions?.[field] ? "ok" : skipped ? "skipped" : "pending", title, permissions?.[field] ? "Granted." : `${skipped ? "skipped" : "pending"}: ${feature} will not work.`, `System Settings > Privacy & Security > ${title}`, { text: permissions?.[field] ? `${label} permission granted` : `${label} permission ${skipped ? "skipped" : "not granted"}: ${feature} will not work` });
3593
3755
  }
3594
3756
  }
3595
- function summarizeSetup(output, options, log) {
3757
+ function toolChecks(tools, stimBuild) {
3758
+ return [
3759
+ [
3760
+ "Xcode",
3761
+ !!tools.xcode && !!tools.simulatorSdk,
3762
+ tools.xcode ?? "Missing Xcode or iOS SDK",
3763
+ "Install Xcode from the App Store; sudo xcodebuild -runFirstLaunch"
3764
+ ],
3765
+ [
3766
+ "iOS runtime",
3767
+ tools.runtimes.length > 0,
3768
+ tools.runtimes.join(", ") || "No iOS simulator runtime",
3769
+ "xcodebuild -downloadPlatform iOS"
3770
+ ],
3771
+ [
3772
+ "CocoaPods",
3773
+ !!tools.cocoapods,
3774
+ tools.cocoapods ?? "Missing CocoaPods",
3775
+ "brew install cocoapods; or gem install bundler"
3776
+ ],
3777
+ [
3778
+ "JDK",
3779
+ !!tools.jdk,
3780
+ tools.jdk ?? "Missing JDK",
3781
+ "brew install --cask zulu@17"
3782
+ ],
3783
+ [
3784
+ "Android SDK",
3785
+ !!tools.androidSdk?.platforms.length && !!tools.androidSdk.buildTools.length && !!tools.androidSdk.ndk.length,
3786
+ tools.androidSdk ? JSON.stringify(tools.androidSdk) : "Missing Android SDK",
3787
+ "Install Android Studio or set ANDROID_HOME"
3788
+ ],
3789
+ [
3790
+ "Stim build",
3791
+ !!stimBuild && stimBuild === tools.stimBuild,
3792
+ `${stimBuild ?? "unknown"} (setup: ${tools.stimBuild ?? "unknown"})`,
3793
+ "Install This Mac's Build from Stim Desktop > Settings > Remote Macs"
3794
+ ]
3795
+ ];
3796
+ }
3797
+ const capabilityNoun = (capability) => capability === "build" ? "builds" : "hosted simulators";
3798
+ function approvalQuestion(concise, name, capability, nodeId) {
3799
+ if (!concise) {
3800
+ const verb = capability === "build" ? "build on this Mac" : "host simulators on this Mac";
3801
+ return `${name} (node ${nodeId.slice(0, 4)}...) asks to ${verb}. Approve? [y/N] `;
3802
+ }
3803
+ return `Approve ${name} to ${capability === "build" ? "build here (runs its project code" : "host simulators here (runs its app code"} on this Mac)? [y/N] `;
3804
+ }
3805
+ function serverText(desktop, decision, version) {
3806
+ if (desktop) return `using the app's stim-server ${version}`;
3807
+ return `stim-server ${version} ${decision === "reuse" ? "already installed" : "installed"}`;
3808
+ }
3809
+ function approvalView(complete) {
3810
+ return complete ? { hidden: true } : { text: "no approval before the ticket expired: builds or hosted simulators will not work" };
3811
+ }
3812
+ function printSuccess(output, options, concise) {
3813
+ const { printer, gaps, exit } = concise;
3814
+ const machine = output.route.dnsName?.split(".")[0] ?? output.label;
3815
+ const client = output.granted[0]?.client.name ?? options.nodeId;
3816
+ const names = {
3817
+ build: "build",
3818
+ "device-host": "host simulators"
3819
+ };
3820
+ const what = options.capabilities.map((c) => names[c]).join(" and ");
3821
+ if (exit === 0) printer.headline("ok", `${machine} is ready to ${what} for ${client}`);
3822
+ else if (exit === 1) printer.headline("failed", "Setup did not finish");
3823
+ else if (exit === 2) printer.headline("pending", "No request was approved before the ticket expired");
3824
+ else printer.headline("pending", `${machine} is set up, with gaps`);
3825
+ printer.result(output.granted.map((g) => ({
3826
+ state: "ok",
3827
+ text: `${capabilityNoun(g.capability)}: approved for ${g.client.name} (request ${g.id})`
3828
+ })));
3829
+ for (const gap of gaps) printer.line(` Fix (${gap.subject}): ${gap.fix}`);
3830
+ const undo = [...output.granted.map((g) => `stim-server devices revoke ${g.id}`), ...output.managed && output.granted.length ? [`stim-server service uninstall --label ${output.label}`] : []];
3831
+ if (undo.length) printer.dim(`To undo: ${undo.join("; ")}`);
3832
+ }
3833
+ function summarizeSetup(output, options, log, concise) {
3834
+ if (concise) return printSuccess(output, options, concise);
3596
3835
  for (const capability of options.capabilities) if (!output.granted.some((g) => g.capability === capability)) output.warnings.push(`${capability === "build" ? "Builds" : "Hosted simulators"} have no approval.`);
3597
3836
  if (options.capabilities.includes("device-host")) {
3598
3837
  for (const [key, feature] of [["screenRecording", "Viewing hosted simulators"], ["deviceControl", "Controlling hosted simulators"]]) if (output.permissions[key] !== "granted") output.warnings.push(`${feature} requires a macOS permission grant.`);
@@ -3644,6 +3883,17 @@ async function runSetup(args, version, deps) {
3644
3883
  warnings: []
3645
3884
  };
3646
3885
  const log = (line) => (json ? deps.stderr : deps.stdout)(line);
3886
+ const verbose = args.includes("--verbose");
3887
+ const display = deps.display ?? {
3888
+ tty: false,
3889
+ color: false,
3890
+ raw: () => {}
3891
+ };
3892
+ const printer = new SetupPrinter(log, display, json || verbose);
3893
+ const concise = !json && !verbose;
3894
+ const chatter = concise ? () => {} : log;
3895
+ const gaps = [];
3896
+ if (!json) printer.banner();
3647
3897
  const wait = (work) => interruptible(work, deps.signal);
3648
3898
  let release;
3649
3899
  let active = "args";
@@ -3656,7 +3906,7 @@ async function runSetup(args, version, deps) {
3656
3906
  throw error;
3657
3907
  }
3658
3908
  };
3659
- const step = (id, state, title, detail, fix) => {
3909
+ const step = (id, state, title, detail, fix, view = {}) => {
3660
3910
  if (deps.signal?.aborted && state !== "failed") throw new SetupRefusal("interrupted");
3661
3911
  active = id;
3662
3912
  const value = {
@@ -3670,7 +3920,14 @@ async function runSetup(args, version, deps) {
3670
3920
  if (index < 0) journal.steps.push(value);
3671
3921
  else journal.steps[index] = value;
3672
3922
  save();
3673
- log(`${state === "ok" ? "[ok]" : state === "failed" ? "[failed]" : `[${state}]`} ${title}${detail ? `: ${detail}` : ""}${fix ? `\nFix: ${fix}` : ""}`);
3923
+ if (fix && state !== "ok" && state !== "running") gaps.push({
3924
+ subject: title,
3925
+ fix
3926
+ });
3927
+ printer.step({
3928
+ ...value,
3929
+ ...view
3930
+ });
3674
3931
  };
3675
3932
  try {
3676
3933
  options = parseSetupArgs(args, deps.now());
@@ -3693,14 +3950,14 @@ async function runSetup(args, version, deps) {
3693
3950
  requireApprovalMode(deps, options);
3694
3951
  release = deps.claim();
3695
3952
  deps.prune(deps.now());
3696
- step("preflight", "ok", "Preflight");
3953
+ step("preflight", "ok", "Preflight", void 0, void 0, { hidden: true });
3697
3954
  const opts = options;
3698
3955
  let installed = null;
3699
3956
  let host;
3700
3957
  let script = "";
3701
3958
  let desktop = false;
3702
3959
  await deps.withInstallClaim(opts.label, async () => {
3703
- step("server", "running", "stim-server");
3960
+ step("server", "running", "stim-server", void 0, void 0, { running: "Checking stim-server" });
3704
3961
  installed = await wait(deps.installed(opts.label));
3705
3962
  if (installed && installed.port !== opts.port) throw new SetupRefusal(`${opts.label} uses port ${installed.port}; rerun with that --port or another --label.`);
3706
3963
  const health = await wait(deps.health(opts.port));
@@ -3718,7 +3975,7 @@ async function runSetup(args, version, deps) {
3718
3975
  script: installed.script,
3719
3976
  node: installed.node,
3720
3977
  port: installed.port
3721
- }, { release: version }, log))) log(note);
3978
+ }, { release: version }, chatter))) chatter(note);
3722
3979
  installed = await wait(deps.installed(opts.label));
3723
3980
  const updated = await wait(deps.health(opts.port));
3724
3981
  if (!updated) throw new SetupRefusal("The updated server is not answering.");
@@ -3728,7 +3985,7 @@ async function runSetup(args, version, deps) {
3728
3985
  };
3729
3986
  script = installed?.script ?? "";
3730
3987
  } else if (decision === "install") {
3731
- const target = await wait(deps.install(deps.versions(opts.label), { release: version }, deps.node, log));
3988
+ const target = await wait(deps.install(deps.versions(opts.label), { release: version }, deps.node, chatter));
3732
3989
  script = target.script;
3733
3990
  output.server = target.build;
3734
3991
  } else {
@@ -3738,10 +3995,10 @@ async function runSetup(args, version, deps) {
3738
3995
  };
3739
3996
  script = installed?.script ?? "";
3740
3997
  }
3741
- step("server", "ok", `stim-server ${output.server.version}`, desktop ? "Using the existing server; permissions are that app's." : decision === "reuse" ? "Already installed; never downgrades." : "Installed exact release.");
3742
- step("host", "running", "Stim Host");
3998
+ step("server", "ok", `stim-server ${output.server.version}`, desktop ? "Using the existing server; permissions are that app's." : decision === "reuse" ? "Already installed; never downgrades." : "Installed exact release.", void 0, { text: serverText(desktop, decision, output.server.version) });
3999
+ step("host", "running", "Stim Host", void 0, void 0, { running: "Installing Stim Host" });
3743
4000
  host = await wait(deps.installHost());
3744
- step("host", "ok", "Stim Host", host.app);
4001
+ step("host", "ok", "Stim Host", host.app, void 0, { text: "Stim Host installed" });
3745
4002
  if (installed?.host) {
3746
4003
  const app = dirname(dirname(dirname(installed.host)));
3747
4004
  host = {
@@ -3751,7 +4008,7 @@ async function runSetup(args, version, deps) {
3751
4008
  name: basename(app, ".app")
3752
4009
  };
3753
4010
  }
3754
- step("service", "running", "Service");
4011
+ step("service", "running", "Service", void 0, void 0, { running: "Starting the service" });
3755
4012
  if (!desktop) {
3756
4013
  for (const note of await wait(deps.installJob({
3757
4014
  ...opts,
@@ -3760,14 +4017,14 @@ async function runSetup(args, version, deps) {
3760
4017
  script,
3761
4018
  host,
3762
4019
  requestPermissions: false
3763
- }))) log(note);
4020
+ }))) chatter(note);
3764
4021
  const ready = await wait(deps.health(opts.port));
3765
4022
  if (!ready || ready.startup && ready.startup.state !== "ready") throw new SetupRefusal("The installed server is not ready; check service status.");
3766
4023
  }
3767
- step("service", "ok", "Service", desktop ? "Reusing app server; no LaunchAgent installed." : `${opts.label} running.`);
4024
+ step("service", "ok", "Service", desktop ? "Reusing app server; no LaunchAgent installed." : `${opts.label} running.`, void 0, { text: desktop ? "reusing the app server (no LaunchAgent)" : `LaunchAgent ${opts.label} running` });
3768
4025
  });
3769
4026
  const previous = desktop ? null : await wait(deps.installed(opts.label));
3770
- step("route", "running", "Tailnet route");
4027
+ step("route", "running", "Tailnet route", void 0, void 0, { running: "Checking the tailnet route" });
3771
4028
  const route = await wait(deps.route(opts.port));
3772
4029
  output.route.state = route.state;
3773
4030
  output.route.port = route.port;
@@ -3800,8 +4057,8 @@ async function runSetup(args, version, deps) {
3800
4057
  }
3801
4058
  throw error;
3802
4059
  }
3803
- step("route", "ok", "Tailnet route", `${preflight.dnsName}: https port ${verified.port}`);
3804
- step("approve", "running", "Access approval", `Waiting until ${opts.expiresAt}.`);
4060
+ step("route", "ok", "Tailnet route", `${preflight.dnsName}: https port ${verified.port}`, void 0, { text: `tailnet route https ${verified.port} (no Funnel)` });
4061
+ step("approve", "running", "Access approval", `Waiting until ${opts.expiresAt}.`, void 0, { running: `Waiting for approval until ${opts.expiresAt}` });
3805
4062
  const declined = /* @__PURE__ */ new Set();
3806
4063
  requireApprovalMode(deps, opts);
3807
4064
  while (deps.now() < Date.parse(opts.expiresAt)) {
@@ -3813,15 +4070,17 @@ async function runSetup(args, version, deps) {
3813
4070
  now: deps.now()
3814
4071
  });
3815
4072
  for (const { capability, record, approved } of grants) {
3816
- const verb = capability === "build" ? "build on this Mac" : "host simulators on this Mac";
3817
4073
  if (!approved && !opts.yes && !deps.tty) throw new SetupRefusal("To approve requests, rerun with --yes or in a terminal.");
3818
4074
  if (!approved && !opts.yes) {
3819
- log(capability === "build" ? "This runs its project code on this Mac to build." : "This runs its native app code in session-owned simulators on this Mac.");
3820
- const yes = await wait(deps.confirm(`${record.name} (node ${opts.nodeId.slice(0, 4)}...) asks to ${verb}. Approve? [y/N] `, Math.max(1, Date.parse(opts.expiresAt) - deps.now())));
4075
+ printer.clear();
4076
+ if (!concise) log(capability === "build" ? "This runs its project code on this Mac to build." : "This runs its native app code in session-owned simulators on this Mac.");
4077
+ const question = approvalQuestion(concise, record.name, capability, opts.nodeId);
4078
+ const yes = await wait(deps.confirm(question, Math.max(1, Date.parse(opts.expiresAt) - deps.now())));
3821
4079
  if (deps.now() >= Date.parse(opts.expiresAt)) break;
3822
4080
  if (!yes) {
3823
4081
  declined.add(capability);
3824
- deps.stderr(`stim-server: ${capability} approval refused.`);
4082
+ printer.clear();
4083
+ if (!(concise && display.tty)) deps.stderr(`stim-server: ${capability} approval refused.`);
3825
4084
  step(`approve.${capability}`, "failed", `${capability} refused`, "No approval given.");
3826
4085
  continue;
3827
4086
  }
@@ -3840,55 +4099,18 @@ async function runSetup(args, version, deps) {
3840
4099
  name: record.name
3841
4100
  }
3842
4101
  });
3843
- step(`approve.${capability}`, "ok", `${approved ? "Already approved" : "Approved"} ${record.name} (${opts.nodeId}) for ${capability === "build" ? "builds" : "hosted simulators"}`, `request ${record.id}`);
4102
+ step(`approve.${capability}`, "ok", `${approved ? "Already approved" : "Approved"} ${record.name} (${opts.nodeId}) for ${capability === "build" ? "builds" : "hosted simulators"}`, `request ${record.id}`, void 0, { text: `${approved ? "already approved" : "approved"} ${record.name} for ${capabilityNoun(capability)}` });
3844
4103
  }
3845
4104
  if (opts.capabilities.every((c) => declined.has(c) || journal.granted.some((g) => g.capability === c))) break;
3846
4105
  await wait(deps.sleep(Math.min(1e3, Math.max(0, Date.parse(opts.expiresAt) - deps.now()))));
3847
4106
  }
3848
- step("approve", journal.granted.length === opts.capabilities.length ? "ok" : "pending", "Access approval", journal.granted.length === opts.capabilities.length ? "Chosen capabilities approved." : "Missing approval: builds or hosted simulators will not work. Generate a new command after expiry.");
4107
+ step("approve", journal.granted.length === opts.capabilities.length ? "ok" : "pending", "Access approval", journal.granted.length === opts.capabilities.length ? "Chosen capabilities approved." : "Missing approval: builds or hosted simulators will not work. Generate a new command after expiry.", void 0, approvalView(journal.granted.length === opts.capabilities.length));
3849
4108
  if (journal.granted.length) {
3850
4109
  if (opts.capabilities.includes("device-host")) await checkPermissions(deps, opts, host, desktop, output, step);
3851
- step("tools", "running", "Tools");
4110
+ step("tools", "running", "Tools", void 0, void 0, { running: "Checking build tools" });
3852
4111
  const tools = await wait(deps.toolchain(opts));
3853
4112
  if (!tools) throw new SetupRefusal("Could not read the local build toolchain.");
3854
- const checks = [
3855
- [
3856
- "Xcode",
3857
- !!tools.xcode && !!tools.simulatorSdk,
3858
- tools.xcode ?? "Missing Xcode or iOS SDK",
3859
- "Install Xcode from the App Store; sudo xcodebuild -runFirstLaunch"
3860
- ],
3861
- [
3862
- "iOS runtime",
3863
- tools.runtimes.length > 0,
3864
- tools.runtimes.join(", ") || "No iOS simulator runtime",
3865
- "xcodebuild -downloadPlatform iOS"
3866
- ],
3867
- [
3868
- "CocoaPods",
3869
- !!tools.cocoapods,
3870
- tools.cocoapods ?? "Missing CocoaPods",
3871
- "brew install cocoapods; or gem install bundler"
3872
- ],
3873
- [
3874
- "JDK",
3875
- !!tools.jdk,
3876
- tools.jdk ?? "Missing JDK",
3877
- "brew install --cask zulu@17"
3878
- ],
3879
- [
3880
- "Android SDK",
3881
- !!tools.androidSdk?.platforms.length && !!tools.androidSdk.buildTools.length && !!tools.androidSdk.ndk.length,
3882
- tools.androidSdk ? JSON.stringify(tools.androidSdk) : "Missing Android SDK",
3883
- "Install Android Studio or set ANDROID_HOME"
3884
- ],
3885
- [
3886
- "Stim build",
3887
- !!output.server.stimBuild && output.server.stimBuild === tools.stimBuild,
3888
- `${output.server.stimBuild ?? "unknown"} (setup: ${tools.stimBuild ?? "unknown"})`,
3889
- "Install This Mac's Build from Stim Desktop > Settings > Build machines"
3890
- ]
3891
- ];
4113
+ const checks = toolChecks(tools, output.server.stimBuild);
3892
4114
  for (const [tool, present, detail, fix] of checks) {
3893
4115
  const needed = opts.capabilities.includes("build") || tool === "Xcode" || tool === "iOS runtime" || tool === "Stim build";
3894
4116
  output.tools.push({
@@ -3897,9 +4119,12 @@ async function runSetup(args, version, deps) {
3897
4119
  detail,
3898
4120
  fix
3899
4121
  });
3900
- step(`tools.${tool}`, present || !needed ? "ok" : "pending", tool, !needed ? "Not needed for hosted simulators." : present ? detail : `${detail}; ${opts.capabilities.includes("build") ? "builds requiring this tool" : "hosted simulators"} will not work.`, present || !needed ? void 0 : fix);
4122
+ step(`tools.${tool}`, present || !needed ? "ok" : "pending", tool, !needed ? "Not needed for hosted simulators." : present ? detail : `${detail}; ${opts.capabilities.includes("build") ? "builds requiring this tool" : "hosted simulators"} will not work.`, present || !needed ? void 0 : fix, { hidden: present || !needed });
3901
4123
  }
3902
- step("tools", "ok", "Tools", "Checked; installs nothing.");
4124
+ step("tools", "ok", "Tools", "Checked; installs nothing.", void 0, {
4125
+ text: "build tools ready",
4126
+ hidden: output.tools.some((t) => t.state === "missing")
4127
+ });
3903
4128
  }
3904
4129
  } catch (error) {
3905
4130
  const message = deps.signal?.aborted ? "interrupted" : error instanceof Error ? error.message : String(error);
@@ -3908,11 +4133,13 @@ async function runSetup(args, version, deps) {
3908
4133
  step(active, expired ? "pending" : "failed", expired ? "Ticket expired" : "Setup refused", message, error instanceof SetupRefusal ? error.fix : void 0);
3909
4134
  } catch (writeError) {
3910
4135
  const detail = `Could not write the setup journal: ${writeError.message}`;
4136
+ printer.clear();
3911
4137
  deps.stderr(`stim-server: ${detail}`);
3912
4138
  output.warnings.push(detail);
3913
4139
  }
3914
4140
  const code = error.code;
3915
- deps.stderr(`stim-server: ${code ? `${code}: ` : ""}${message}`);
4141
+ printer.clear();
4142
+ if (!(concise && display.tty)) deps.stderr(`stim-server: ${code ? `${code}: ` : ""}${message}`);
3916
4143
  output.warnings.push(message);
3917
4144
  } finally {
3918
4145
  try {
@@ -3935,11 +4162,16 @@ async function runSetup(args, version, deps) {
3935
4162
  detail: message
3936
4163
  });
3937
4164
  journal.exit = setupExitCode(journal);
4165
+ printer.clear();
3938
4166
  deps.stderr(`stim-server: ${message}`);
3939
4167
  output.warnings.push(message);
3940
4168
  }
3941
4169
  output.ok = journal.exit === 0;
3942
- if (options) summarizeSetup(output, options, log);
4170
+ if (options) summarizeSetup(output, options, log, concise ? {
4171
+ printer,
4172
+ gaps,
4173
+ exit: journal.exit
4174
+ } : void 0);
3943
4175
  if (json) deps.stdout(JSON.stringify(output));
3944
4176
  } finally {
3945
4177
  release?.();
@@ -5179,11 +5411,14 @@ var AgentDeviceDriver = class {
5179
5411
  listening(proxy) {
5180
5412
  return new Promise((resolve, reject) => {
5181
5413
  let output = "";
5414
+ let errors = "";
5182
5415
  const timer = setTimeout(() => finish(/* @__PURE__ */ new Error("The agent-device proxy did not report a listening address.")), this.options.startTimeoutMs);
5183
5416
  const finish = (result) => {
5184
5417
  clearTimeout(timer);
5185
5418
  proxy.stdout?.off("data", onData);
5186
- proxy.off("exit", onExit);
5419
+ proxy.stderr?.off("data", onError);
5420
+ proxy.stderr?.resume();
5421
+ proxy.off("close", onExit);
5187
5422
  proxy.off("error", finish);
5188
5423
  if (result instanceof Error) reject(result);
5189
5424
  else resolve(result);
@@ -5193,10 +5428,13 @@ var AgentDeviceDriver = class {
5193
5428
  const match = LISTENING.exec(output);
5194
5429
  if (match) finish(new URL(match[1]));
5195
5430
  };
5196
- const onExit = (code) => finish(/* @__PURE__ */ new Error(`The agent-device proxy exited with ${String(code)} before it was ready.`));
5431
+ const onExit = (code) => finish(/* @__PURE__ */ new Error(`The agent-device proxy exited with ${String(code)} before it was ready.${errors.trim() ? ` ${errors.trim()}` : ""}`));
5432
+ const onError = (chunk) => {
5433
+ errors = (errors + chunk.toString()).slice(-400);
5434
+ };
5197
5435
  proxy.stdout?.on("data", onData);
5198
- proxy.stderr?.resume();
5199
- proxy.once("exit", onExit);
5436
+ proxy.stderr?.on("data", onError);
5437
+ proxy.once("close", onExit);
5200
5438
  proxy.once("error", finish);
5201
5439
  });
5202
5440
  }
@@ -11000,12 +11238,12 @@ function part(outcome, label) {
11000
11238
  }
11001
11239
  const iosDoctorRanAt = ([, record]) => Date.parse(record.doctorRuns?.ios?.at ?? "") || 0;
11002
11240
  /**
11003
- * Where `machine.details` runs `stim doctor` for the build machines: null when `offload.machines` names none, else
11241
+ * Where `machine.details` runs `stim doctor` for the remote Macs: null when `remote.machines` names none, else
11004
11242
  * the registered workspace that still exists and ran doctor for iOS most recently, or the first one; `cwd` is null
11005
11243
  * when no registered workspace exists. Doctor refuses outside a project, and judges a machine against that app.
11006
11244
  */
11007
11245
  function doctorWorkspace(config, exists) {
11008
- const machines = config?.offload?.machines;
11246
+ const machines = config?.remote?.machines;
11009
11247
  if (!Array.isArray(machines) || machines.length === 0) return null;
11010
11248
  return { cwd: Object.entries(config?.projects ?? {}).filter(([path]) => exists(path)).reduce((best, entry) => best === null || iosDoctorRanAt(entry) > iosDoctorRanAt(best) ? entry : best, null)?.[0] ?? null };
11011
11249
  }
@@ -11015,10 +11253,10 @@ async function runDoctor(run, cwd) {
11015
11253
  buildMachines: null,
11016
11254
  buildMachinesError: error
11017
11255
  };
11018
- const machines = payload.buildMachines;
11256
+ const machines = payload.remoteMachines;
11019
11257
  if (!Array.isArray(machines)) return {
11020
11258
  buildMachines: null,
11021
- buildMachinesError: "This stim does not report build machines; update it."
11259
+ buildMachinesError: "This stim does not report remote Macs; update it."
11022
11260
  };
11023
11261
  return { buildMachines: machines.filter((entry) => isObject(entry) && typeof entry.machine === "string" && typeof entry.state === "string") };
11024
11262
  }
@@ -11029,7 +11267,7 @@ const isObject = (value) => value !== null && typeof value === "object" && !Arra
11029
11267
  * and the rest of `machine.details` has nothing to do with it. `snapshot` never awaits doctor. It returns the last
11030
11268
  * settled result immediately (with `buildMachinesPending: true` alongside it once that result is `ttlMs` old or
11031
11269
  * older), starting at most one background run to refresh it; while no result has ever settled, it returns
11032
- * `buildMachines: null` with `buildMachinesPending: true`. `offload.machines` naming no machine, or `doctor` being
11270
+ * `buildMachines: null` with `buildMachinesPending: true`. `remote.machines` naming no machine, or `doctor` being
11033
11271
  * an error or an unresolved workspace, answers synchronously and never starts a run.
11034
11272
  */
11035
11273
  var BuildMachinesCache = class {
@@ -11127,7 +11365,7 @@ async function loadMachineDetails(run, audit, readStats) {
11127
11365
  /**
11128
11366
  * The gc/stats part of one `machine.details` result shared by every connection: a request while a load runs joins
11129
11367
  * it, and a finished load answers for `ttlMs` after it settled, so phones cannot make the Mac run `stim gc` back
11130
- * to back. Build machines are cached and refreshed separately by `BuildMachinesCache`, never joined into this
11368
+ * to back. Remote Macs are cached and refreshed separately by `BuildMachinesCache`, never joined into this
11131
11369
  * promise, so a re-poll observes its progress instead of the gc/stats snapshot from whenever this last loaded.
11132
11370
  */
11133
11371
  var MachineDetailsCache = class {
@@ -12425,7 +12663,7 @@ const failed = (code, message) => ({ error: {
12425
12663
  message
12426
12664
  } });
12427
12665
  /**
12428
- * Asks an approved build machine or device host to update its stim-server to what this Mac runs: the same npm
12666
+ * Asks an approved remote Mac or device host to update its stim-server to what this Mac runs: the same npm
12429
12667
  * release, or this checkout's own packed build. It connects with the credential `stim doctor --fix` stored for that
12430
12668
  * machine and sends the token only to the node it pinned.
12431
12669
  */
@@ -14756,6 +14994,10 @@ async function startServer(options) {
14756
14994
  return;
14757
14995
  }
14758
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
+ }
14759
15001
  if (limiter.blocked(peer ?? "local")) {
14760
15002
  socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
14761
15003
  return;
@@ -14797,7 +15039,8 @@ async function startServer(options) {
14797
15039
  },
14798
15040
  tailscale: healthTailscale(tailscale),
14799
15041
  route,
14800
- nativeViewerOpened
15042
+ nativeViewerOpened,
15043
+ ...options.loopbackOnly ? { loopbackOnly: true } : {}
14801
15044
  };
14802
15045
  response.writeHead(startup.state === "ready" ? 200 : 503, { "content-type": "application/json" }).end(JSON.stringify(body));
14803
15046
  };
@@ -14844,6 +15087,10 @@ async function startServer(options) {
14844
15087
  };
14845
15088
  async function listenOn(host) {
14846
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
+ }
14847
15094
  if (localHealthRequest(request)) {
14848
15095
  answerHealth(response);
14849
15096
  return;
@@ -14912,7 +15159,7 @@ async function startServer(options) {
14912
15159
  const monitor = options.tailscaleMonitor;
14913
15160
  const listenFailures = /* @__PURE__ */ new Set();
14914
15161
  const reconcile = async (snapshot) => {
14915
- const tailnet = snapshot.state.state === "running" ? snapshot.state.ips : [];
15162
+ const tailnet = snapshot.state.state === "running" && !options.loopbackOnly ? snapshot.state.ips : [];
14916
15163
  const wanted = /* @__PURE__ */ new Set([...options.hosts, ...tailnet]);
14917
15164
  for (const [host, listener] of servers) {
14918
15165
  if (wanted.has(host)) continue;
@@ -15064,12 +15311,13 @@ function watchTailscale(options) {
15064
15311
  //#endregion
15065
15312
  //#region bin/stim-server.ts
15066
15313
  const USAGE = `Usage:
15067
- stim-server [--port <n>] [--env KEY=VALUE]... [--path-prepend <dir>]...
15314
+ stim-server [--port <n>] [--loopback-only] [--env KEY=VALUE]... [--path-prepend <dir>]...
15068
15315
  serve paired clients (default port ${DEFAULT_PORT});
15316
+ --loopback-only serves this Mac only (127.0.0.1, no remote peers);
15069
15317
  --env and --path-prepend apply after the login shell's environment
15070
15318
  stim-server setup --client <node-id> --ticket <t> --expires <iso>
15071
15319
  [--build] [--device-host] [--port <n>] [--label <name>]
15072
- [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json]
15320
+ [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json] [--verbose]
15073
15321
  worker-side approval for this node and ticket until expiry
15074
15322
  stim-server service install [--port <n>] [--label <name>] [--serve]
15075
15323
  [--env KEY=VALUE]... [--path-prepend <dir>]...
@@ -15115,7 +15363,7 @@ function routeNote(route, port) {
15115
15363
  function macName(tailscale) {
15116
15364
  return tailscale.state === "running" && tailscale.hostName || hostname();
15117
15365
  }
15118
- async function serve(port, extraEnv, pathPrepend) {
15366
+ async function serve(port, extraEnv, pathPrepend, loopbackOnly) {
15119
15367
  const captureHost = hostFromExecutable(process.env.STIM_HOST_EXECUTABLE);
15120
15368
  const login = loginShellEnvironment();
15121
15369
  if (!login) console.error("stim-server: could not read the login shell environment; using this process environment.");
@@ -15141,6 +15389,7 @@ async function serve(port, extraEnv, pathPrepend) {
15141
15389
  name: macName(tailscale),
15142
15390
  host: captureHost,
15143
15391
  hosts: ["127.0.0.1"],
15392
+ ...loopbackOnly ? { loopbackOnly: true } : {},
15144
15393
  port,
15145
15394
  stimCli: stim.cli,
15146
15395
  stimVersion: stim.version,
@@ -15171,13 +15420,13 @@ async function serve(port, extraEnv, pathPrepend) {
15171
15420
  }
15172
15421
  if (note) console.error(note);
15173
15422
  };
15174
- await announce(tailscaleBinary, tailscale);
15175
- monitor.onChange((snapshot, previous) => {
15423
+ if (!loopbackOnly) monitor.onChange((snapshot, previous) => {
15176
15424
  const { state } = snapshot;
15177
15425
  if (state.state === "running") console.log("Tailscale is running.");
15178
15426
  else if (previous.state.state === "running") console.error("Tailscale stopped.");
15179
15427
  if (state.state === "running" || previous.state.state === "running") announce(snapshot.binary, state);
15180
15428
  });
15429
+ if (!loopbackOnly) await announce(tailscaleBinary, tailscale);
15181
15430
  const shutdown = () => {
15182
15431
  monitor.stop();
15183
15432
  server.close().catch((error) => {
@@ -15308,6 +15557,7 @@ async function main() {
15308
15557
  allowPositionals: true,
15309
15558
  options: {
15310
15559
  port: { type: "string" },
15560
+ "loopback-only": { type: "boolean" },
15311
15561
  json: { type: "boolean" },
15312
15562
  control: { type: "boolean" },
15313
15563
  read: { type: "boolean" },
@@ -15350,7 +15600,8 @@ async function main() {
15350
15600
  }
15351
15601
  if ((values.label !== void 0 || values.serve) && !service) fail(`--label and --serve apply only to \`service\`.\n${USAGE}`);
15352
15602
  if ((values.release !== void 0 || values.from !== void 0) && !service) fail(`--release and --from apply only to \`service update\`.\n${USAGE}`);
15353
- 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);
15354
15605
  const grant = command === "devices" && sub === "grant";
15355
15606
  if (values.read && !grant) fail(`--read applies only to \`devices grant\`.\n${USAGE}`);
15356
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-CP50F1Y_.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.0",
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.0",
32
- "stim": "1.17.0",
31
+ "@stim-cli/core": "^1.17.2",
32
+ "stim": "1.17.2",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {