@stim-cli/server 1.23.1 → 1.25.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
@@ -49,8 +49,8 @@ and [device-host clients](#device-host-approval). The plain listing shows
49
49
  [Actions](#actions).
50
50
 
51
51
  `GET http://127.0.0.1:7787/health` answers requests from this machine with the
52
- server's name, versions, `stimBuild` (the digest of the bundled Stim's build,
53
- which offload compares between machines), protocol, `stimHome`, `startup`, `busy`
52
+ server's name, versions, `stimBuild` (the digest of the bundled Stim's build),
53
+ protocol, `stimHome`, `startup`, `busy`
54
54
  (the offloaded `builds` and `hostedSessions` running now) and the current
55
55
  Tailscale state. It answers HTTP 200 once `startup.state` is `ready`; while the
56
56
  state is `pending` or `degraded` (see the LaunchAgent notes below) it answers
@@ -547,8 +547,11 @@ are not carried over: phones pair again, and remote machines are approved
547
547
  again with a new setup ticket.
548
548
 
549
549
  Stim Desktop and other clients of the server's own user on the same machine do
550
- not pair. They connect to the Unix socket `$STIM_HOME/server/local.sock`, mode
551
- 0600 in a 0700 directory, which serves only this user and needs no handshake.
550
+ not pair. They connect to the Unix socket `$STIM_HOME/server/local.sock`. If
551
+ that path exceeds the platform's limit (103 UTF-8 bytes on macOS, 107 on Linux), the socket instead lives at
552
+ `/tmp/stim-sockets-<uid>/<sha256-of-normalized-socket-path>.sock`, so long hosted
553
+ session homes remain usable and distinct homes keep distinct sockets. The
554
+ socket is mode 0600 in a mode 0700 directory owned by this user and needs no handshake.
552
555
  That connection gets read and control. The Desktop-only methods (`route.setup`,
553
556
  `machines.update.*`, `device-host.sessions` and `device-host.sessions.stop`)
554
557
  work only there; on a server Desktop started, only after the private Desktop
@@ -556,8 +559,13 @@ bootstrap proof (see [Private Desktop bootstrap](#private-desktop-bootstrap)).
556
559
  Windows has no socket.
557
560
 
558
561
  The network address, `tailscale whois` and the `X-Forwarded-For` header that
559
- `tailscale serve` adds never authenticate. They only label a record (`from` in
560
- `stim-server devices`) and key the rate limits below.
562
+ `tailscale serve` adds never authenticate. They label a record (`from` in
563
+ `stim-server devices`) and key the rate limits below. Tailscale rewrites the
564
+ header to the real tailnet source, so remote clients cannot forge it. A local
565
+ process on the host can connect to the loopback port and forge it to lock one
566
+ named peer, or the shared local bucket, out for the lockout window. It
567
+ authenticates nothing and reads nothing. This is accepted because it needs
568
+ local code execution on the host.
561
569
 
562
570
  A connection must finish the handshake and send `hello` within 5 seconds of
563
571
  each step. Five failed attempts from the same peer address within a minute
@@ -1143,6 +1151,12 @@ stream over WireGuard with socket backpressure; no public tunnel, Funnel or
1143
1151
  Tailscale serve configuration change is needed. The 64 KiB server message
1144
1152
  limit remains unchanged.
1145
1153
 
1154
+ An approved client calls `device-host.metro.ports` with `{ports}`, at most 256
1155
+ of them, before it starts Metro for an iOS Expo dev client, and the host
1156
+ answers `{busy}`: the listed ports that already accept loopback connections,
1157
+ apart from the client's own dev-client bridges. The feature
1158
+ `hosted-metro-ports` advertises it.
1159
+
1146
1160
  Replaying the same open request keeps the port. Client disconnection leaves
1147
1161
  the bridge available for the same session while its server owner lives. Call
1148
1162
  `device-host.metro.close` to replace a gateway, then reopen on the same port;
@@ -1601,6 +1615,8 @@ connected since stim-server started keeps its directory when revoked.
1601
1615
  same Ruby selection and UTF-8 locale defaults as `pod install`; omitting
1602
1616
  `rubyVersion` uses the worker's default Ruby. It
1603
1617
  returns `toolchain` (`stimBuild`, a digest of the bundled Stim's built code;
1618
+ `compat`, the bundled Stim's build compatibility version, which a client
1619
+ compares instead of `stimBuild` when it is present;
1604
1620
  `os`, the OS stim-server runs on as Node names it (`darwin`, `linux`, `win32`);
1605
1621
  `arch`; `xcode`; `simulatorSdk`; `macosSdk` (from
1606
1622
  `xcrun --sdk macosx --show-sdk-version`); `cocoapods`; `runtimes`, the simulator
@@ -1638,8 +1654,10 @@ connected since stim-server started keeps its directory when revoked.
1638
1654
  architecture and target options. These are exact lookups, not a full inventory.
1639
1655
  With `react-native-peer-cache`, a React Native/Expo development offer can send
1640
1656
  `reactNativeArtifact: { project, fingerprint, isExpo, platform, toolchain, ios | android }`.
1641
- The query includes the caller's compatible toolchain and normalized native
1642
- build choices; the worker constructs its own receipt around compilation.
1657
+ The query includes the caller's compatible toolchain, whose `toolchain.compat`
1658
+ takes the place of `stimBuild` with `build-compat`, and normalized native
1659
+ build choices; a caller sends React Native and SwiftPM queries only to a
1660
+ server with `build-compat`; the worker constructs its own receipt around compilation.
1643
1661
  The returned identity is opaque, not an alias for the caller's local cache key.
1644
1662
  The completed receipt binds the worker-verified post-mutation fingerprint
1645
1663
  and native options to the toolchain checked before and after compilation.
@@ -1647,7 +1665,7 @@ connected since stim-server started keeps its directory when revoked.
1647
1665
  does not discard a matching archive. Older workers receive no new query
1648
1666
  parameters and continue through their normal build path.
1649
1667
  With `swiftpm-peer-cache`, `swiftpmArtifact` carries `project`, `sourceDigest`,
1650
- `stimBuild`, complete `inputs` (`ignored`, `outputs`, `localFiles`,
1668
+ `stimBuild` or, with `build-compat`, `compat` in its place, complete `inputs` (`ignored`, `outputs`, `localFiles`,
1651
1669
  `environment`) and `build` (`product`, `infoPlist`, `resources`, `assetCatalog`).
1652
1670
  Resource and catalog paths are repository-relative; the plist is
1653
1671
  project-relative. The worker verifies actual transferred files and full
@@ -1697,14 +1715,25 @@ connected since stim-server started keeps its directory when revoked.
1697
1715
  simulator runtime identifier, required for `ios`, null for `android`),
1698
1716
  `fingerprint`, `packageName`, `isExpo`, `optimizations`, `android` (for
1699
1717
  `android`: `variant`, `abi`, `gradleBuildCache`, `pch` and `compilerCache`,
1700
- `ccache` or `none`) and `stimBuild`, after the whole manifest of `repo` was
1718
+ `ccache` or `none`), `stimBuild` and, from a caller that saw `build-compat`, `compat`, after the whole manifest of `repo` was
1701
1719
  synced on this connection. A peer-cache-capable React Native caller can set
1702
1720
  `cacheArtifact: true` to retain a completed development archive when its cache
1703
- write and remote policies allow it. It returns `{ "job" }`. It fails with
1721
+ write and remote policies allow it. With the `fingerprint-sources` feature, a
1722
+ caller can send `fingerprintSources`, the `id` and `hash` of each source of
1723
+ `fingerprint`, and a `fingerprint-mismatch` failure names the sources that
1724
+ differ and the synced files prebuild or `pod install` rewrote. With the
1725
+ `fingerprint-ignored` feature, it can send `fingerprintIgnored`, the paths
1726
+ relative to `project` that its git ignores for `fingerprint` through
1727
+ `.git/info/exclude` or a global excludes file, which the machine's synced
1728
+ checkout has no copy of. With the `fingerprint-tracked` feature, it can
1729
+ send `fingerprintTracked`, the top-level entry names of `project` that its
1730
+ git tracks although an ignore pattern matches them, which the machine's
1731
+ empty index would skip. It returns `{ "job" }`. It fails with
1704
1732
  `build-busy` while the offer would be declined, naming the reason, or while
1705
1733
  another build of the same client and repository runs, or while every one
1706
1734
  of this machine's `concurrency.maxBuilds` slots is held, and with
1707
- `build-refused` when `stimBuild` differs. The build runs `offload-worker.mjs` of the bundled Stim with
1735
+ `build-refused` when `compat` differs from this machine's, or, when either
1736
+ side has no `compat`, when `stimBuild` differs. The build runs `offload-worker.mjs` of the bundled Stim with
1708
1737
  `STIM_HOME` set to the repository's area. It makes the area's checkout
1709
1738
  hold exactly the manifest (a file git lists as untracked and not ignored,
1710
1739
  and not in the manifest, is deleted; ignored dependencies and generated
@@ -2497,7 +2526,6 @@ silent only when every notification it sums up is. An older server ignores
2497
2526
  `levels` and alerts for every event but `started`.
2498
2527
 
2499
2528
  The server and phone import the same pure rules from `@stim-cli/core/oversight`.
2500
- The phone formats home attention messages locally from the facts the shared rule selects.
2501
2529
  `__tests__/oversight-agreement.test.ts` checks their categories, workspace names
2502
2530
  and needs-attention vectors. Notification delivery remains local to each.
2503
2531
 
@@ -290,15 +290,21 @@ const swiftpmArtifactInputs = {
290
290
  }
291
291
  }
292
292
  };
293
+ const buildCompat = {
294
+ type: "integer",
295
+ minimum: 1,
296
+ description: "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
297
+ };
298
+ const stimIdentity = [{ required: ["stimBuild"] }, { required: ["compat"] }];
293
299
  const swiftpmArtifactQuery = {
294
300
  type: "object",
295
301
  required: [
296
302
  "project",
297
303
  "sourceDigest",
298
- "stimBuild",
299
304
  "inputs",
300
305
  "build"
301
306
  ],
307
+ oneOf: stimIdentity,
302
308
  additionalProperties: false,
303
309
  properties: {
304
310
  project: { type: "string" },
@@ -308,6 +314,7 @@ const swiftpmArtifactQuery = {
308
314
  minLength: 1,
309
315
  maxLength: 256
310
316
  },
317
+ compat: buildCompat,
311
318
  inputs: swiftpmArtifactInputs,
312
319
  build: {
313
320
  type: "object",
@@ -379,13 +386,14 @@ const reactNativeArtifactQuery = {
379
386
  platform: { enum: ["ios", "android"] },
380
387
  toolchain: {
381
388
  type: "object",
382
- required: ["stimBuild"],
389
+ oneOf: stimIdentity,
383
390
  additionalProperties: false,
384
391
  properties: {
385
392
  stimBuild: {
386
393
  type: "string",
387
394
  minLength: 1
388
395
  },
396
+ compat: buildCompat,
389
397
  arch: {
390
398
  type: "string",
391
399
  minLength: 1
@@ -2275,6 +2283,20 @@ function protocolJsonSchema() {
2275
2283
  pattern: "^[a-f0-9]{64}$"
2276
2284
  }
2277
2285
  }, ["gatewayPort", "secret"])),
2286
+ request("device-host.metro.ports", {
2287
+ type: "object",
2288
+ required: ["ports"],
2289
+ additionalProperties: false,
2290
+ properties: { ports: {
2291
+ type: "array",
2292
+ maxItems: 256,
2293
+ items: {
2294
+ type: "integer",
2295
+ minimum: 1,
2296
+ maximum: 65535
2297
+ }
2298
+ } }
2299
+ }),
2278
2300
  request("device-host.metro.close", session({})),
2279
2301
  request("device-host.frames.subscribe", session({
2280
2302
  fps: {
@@ -3106,6 +3128,48 @@ function protocolJsonSchema() {
3106
3128
  type: ["string", "null"],
3107
3129
  minLength: 1
3108
3130
  },
3131
+ fingerprintSources: {
3132
+ type: "array",
3133
+ maxItems: 2e4,
3134
+ description: "The id and hash of each source of fingerprint, to name the sources that differ.",
3135
+ items: {
3136
+ type: "object",
3137
+ required: ["id", "hash"],
3138
+ additionalProperties: false,
3139
+ properties: {
3140
+ id: {
3141
+ type: "string",
3142
+ minLength: 1,
3143
+ maxLength: 1024
3144
+ },
3145
+ hash: {
3146
+ type: ["string", "null"],
3147
+ pattern: "^[0-9a-f]{1,128}$"
3148
+ }
3149
+ }
3150
+ }
3151
+ },
3152
+ fingerprintIgnored: {
3153
+ type: "array",
3154
+ maxItems: 2e4,
3155
+ description: "Paths relative to project that git ignores for fingerprint on the caller, such as through .git/info/exclude.",
3156
+ items: {
3157
+ type: "string",
3158
+ minLength: 1,
3159
+ maxLength: 1024
3160
+ }
3161
+ },
3162
+ fingerprintTracked: {
3163
+ type: "array",
3164
+ maxItems: 2e4,
3165
+ description: "Top-level entries of project that the caller git tracks for fingerprint although an ignore pattern matches them.",
3166
+ items: {
3167
+ type: "string",
3168
+ minLength: 1,
3169
+ maxLength: 255,
3170
+ pattern: "^[^/\\u0000]+$"
3171
+ }
3172
+ },
3109
3173
  native: { oneOf: [{
3110
3174
  type: "object",
3111
3175
  required: [
@@ -3248,6 +3312,10 @@ function protocolJsonSchema() {
3248
3312
  stimBuild: {
3249
3313
  type: "string",
3250
3314
  minLength: 1
3315
+ },
3316
+ compat: {
3317
+ ...buildCompat,
3318
+ description: "Compared instead of stimBuild by a machine whose build.offer toolchain reports compat."
3251
3319
  }
3252
3320
  }
3253
3321
  }),
package/dist/protocol.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as protocolJsonSchema } from "./protocol-BKpAsa9G.mjs";
1
+ import { t as protocolJsonSchema } from "./protocol-VEZO2CyR.mjs";
2
2
  export * from "@stim-cli/core/protocol";
3
3
  export { protocolJsonSchema };
@@ -1271,11 +1271,16 @@
1271
1271
  "native-gradle-artifact-cache",
1272
1272
  "native-gradle-peer-cache",
1273
1273
  "react-native-peer-cache",
1274
+ "fingerprint-sources",
1275
+ "fingerprint-ignored",
1276
+ "fingerprint-tracked",
1274
1277
  "swiftpm-peer-cache",
1275
1278
  "build-dependency-cache",
1276
1279
  "hosted-android-data",
1277
1280
  "hosted-android-process",
1281
+ "hosted-metro-ports",
1278
1282
  "server-update",
1283
+ "build-compat",
1279
1284
  "status-views",
1280
1285
  "desktop-reports",
1281
1286
  "desktop-report-platform",
@@ -3183,6 +3188,44 @@
3183
3188
  }
3184
3189
  }
3185
3190
  },
3191
+ {
3192
+ "type": "object",
3193
+ "required": [
3194
+ "id",
3195
+ "method",
3196
+ "params"
3197
+ ],
3198
+ "additionalProperties": false,
3199
+ "properties": {
3200
+ "id": {
3201
+ "type": [
3202
+ "integer",
3203
+ "string"
3204
+ ]
3205
+ },
3206
+ "method": {
3207
+ "const": "device-host.metro.ports"
3208
+ },
3209
+ "params": {
3210
+ "type": "object",
3211
+ "required": [
3212
+ "ports"
3213
+ ],
3214
+ "additionalProperties": false,
3215
+ "properties": {
3216
+ "ports": {
3217
+ "type": "array",
3218
+ "maxItems": 256,
3219
+ "items": {
3220
+ "type": "integer",
3221
+ "minimum": 1,
3222
+ "maximum": 65535
3223
+ }
3224
+ }
3225
+ }
3226
+ }
3227
+ }
3228
+ },
3186
3229
  {
3187
3230
  "type": "object",
3188
3231
  "required": [
@@ -6843,8 +6886,17 @@
6843
6886
  },
6844
6887
  "toolchain": {
6845
6888
  "type": "object",
6846
- "required": [
6847
- "stimBuild"
6889
+ "oneOf": [
6890
+ {
6891
+ "required": [
6892
+ "stimBuild"
6893
+ ]
6894
+ },
6895
+ {
6896
+ "required": [
6897
+ "compat"
6898
+ ]
6899
+ }
6848
6900
  ],
6849
6901
  "additionalProperties": false,
6850
6902
  "properties": {
@@ -6852,6 +6904,11 @@
6852
6904
  "type": "string",
6853
6905
  "minLength": 1
6854
6906
  },
6907
+ "compat": {
6908
+ "type": "integer",
6909
+ "minimum": 1,
6910
+ "description": "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
6911
+ },
6855
6912
  "arch": {
6856
6913
  "type": "string",
6857
6914
  "minLength": 1
@@ -6988,10 +7045,21 @@
6988
7045
  "required": [
6989
7046
  "project",
6990
7047
  "sourceDigest",
6991
- "stimBuild",
6992
7048
  "inputs",
6993
7049
  "build"
6994
7050
  ],
7051
+ "oneOf": [
7052
+ {
7053
+ "required": [
7054
+ "stimBuild"
7055
+ ]
7056
+ },
7057
+ {
7058
+ "required": [
7059
+ "compat"
7060
+ ]
7061
+ }
7062
+ ],
6995
7063
  "additionalProperties": false,
6996
7064
  "properties": {
6997
7065
  "project": {
@@ -7006,6 +7074,11 @@
7006
7074
  "minLength": 1,
7007
7075
  "maxLength": 256
7008
7076
  },
7077
+ "compat": {
7078
+ "type": "integer",
7079
+ "minimum": 1,
7080
+ "description": "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
7081
+ },
7009
7082
  "inputs": {
7010
7083
  "type": "object",
7011
7084
  "required": [
@@ -7303,6 +7376,54 @@
7303
7376
  ],
7304
7377
  "minLength": 1
7305
7378
  },
7379
+ "fingerprintSources": {
7380
+ "type": "array",
7381
+ "maxItems": 20000,
7382
+ "description": "The id and hash of each source of fingerprint, to name the sources that differ.",
7383
+ "items": {
7384
+ "type": "object",
7385
+ "required": [
7386
+ "id",
7387
+ "hash"
7388
+ ],
7389
+ "additionalProperties": false,
7390
+ "properties": {
7391
+ "id": {
7392
+ "type": "string",
7393
+ "minLength": 1,
7394
+ "maxLength": 1024
7395
+ },
7396
+ "hash": {
7397
+ "type": [
7398
+ "string",
7399
+ "null"
7400
+ ],
7401
+ "pattern": "^[0-9a-f]{1,128}$"
7402
+ }
7403
+ }
7404
+ }
7405
+ },
7406
+ "fingerprintIgnored": {
7407
+ "type": "array",
7408
+ "maxItems": 20000,
7409
+ "description": "Paths relative to project that git ignores for fingerprint on the caller, such as through .git/info/exclude.",
7410
+ "items": {
7411
+ "type": "string",
7412
+ "minLength": 1,
7413
+ "maxLength": 1024
7414
+ }
7415
+ },
7416
+ "fingerprintTracked": {
7417
+ "type": "array",
7418
+ "maxItems": 20000,
7419
+ "description": "Top-level entries of project that the caller git tracks for fingerprint although an ignore pattern matches them.",
7420
+ "items": {
7421
+ "type": "string",
7422
+ "minLength": 1,
7423
+ "maxLength": 255,
7424
+ "pattern": "^[^/\\u0000]+$"
7425
+ }
7426
+ },
7306
7427
  "native": {
7307
7428
  "oneOf": [
7308
7429
  {
@@ -7562,6 +7683,11 @@
7562
7683
  "stimBuild": {
7563
7684
  "type": "string",
7564
7685
  "minLength": 1
7686
+ },
7687
+ "compat": {
7688
+ "type": "integer",
7689
+ "minimum": 1,
7690
+ "description": "Compared instead of stimBuild by a machine whose build.offer toolchain reports compat."
7565
7691
  }
7566
7692
  }
7567
7693
  }
@@ -7844,8 +7970,17 @@
7844
7970
  },
7845
7971
  "toolchain": {
7846
7972
  "type": "object",
7847
- "required": [
7848
- "stimBuild"
7973
+ "oneOf": [
7974
+ {
7975
+ "required": [
7976
+ "stimBuild"
7977
+ ]
7978
+ },
7979
+ {
7980
+ "required": [
7981
+ "compat"
7982
+ ]
7983
+ }
7849
7984
  ],
7850
7985
  "additionalProperties": false,
7851
7986
  "properties": {
@@ -7853,6 +7988,11 @@
7853
7988
  "type": "string",
7854
7989
  "minLength": 1
7855
7990
  },
7991
+ "compat": {
7992
+ "type": "integer",
7993
+ "minimum": 1,
7994
+ "description": "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
7995
+ },
7856
7996
  "arch": {
7857
7997
  "type": "string",
7858
7998
  "minLength": 1
@@ -7989,10 +8129,21 @@
7989
8129
  "required": [
7990
8130
  "project",
7991
8131
  "sourceDigest",
7992
- "stimBuild",
7993
8132
  "inputs",
7994
8133
  "build"
7995
8134
  ],
8135
+ "oneOf": [
8136
+ {
8137
+ "required": [
8138
+ "stimBuild"
8139
+ ]
8140
+ },
8141
+ {
8142
+ "required": [
8143
+ "compat"
8144
+ ]
8145
+ }
8146
+ ],
7996
8147
  "additionalProperties": false,
7997
8148
  "properties": {
7998
8149
  "project": {
@@ -8007,6 +8158,11 @@
8007
8158
  "minLength": 1,
8008
8159
  "maxLength": 256
8009
8160
  },
8161
+ "compat": {
8162
+ "type": "integer",
8163
+ "minimum": 1,
8164
+ "description": "The Stim build compatibility version, sent to a stim-server with the build-compat feature."
8165
+ },
8010
8166
  "inputs": {
8011
8167
  "type": "object",
8012
8168
  "required": [
@@ -1,4 +1,4 @@
1
- import { n as protocol_exports } from "./protocol-BKpAsa9G.mjs";
1
+ import { n as protocol_exports } from "./protocol-VEZO2CyR.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn } from "node:child_process";
4
4
  import { 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";
@@ -3226,6 +3226,32 @@ function validFile(file) {
3226
3226
  const validRepo = (repo) => typeof repo === "string" && new RegExp(protocol_exports.BUILD_REPO_PATTERN).test(repo);
3227
3227
  const gb = (bytes) => (bytes / 1024 ** 3).toFixed(1);
3228
3228
  const optional = (value) => typeof value === "string" ? value : null;
3229
+ const MAX_FINGERPRINT_SOURCES = 2e4;
3230
+ function fingerprintSources(value) {
3231
+ if (value === void 0) return void 0;
3232
+ if (!Array.isArray(value) || value.length > MAX_FINGERPRINT_SOURCES) return null;
3233
+ return value.every((source) => isJsonObject(source) && Object.keys(source).every((key) => key === "id" || key === "hash") && typeof source.id === "string" && source.id.length > 0 && source.id.length <= MAX_PATH_CHARS && (source.hash === null || typeof source.hash === "string" && /^[0-9a-f]{1,128}$/.test(source.hash))) ? value : null;
3234
+ }
3235
+ function fingerprintIgnored(value) {
3236
+ if (value === void 0) return void 0;
3237
+ if (!Array.isArray(value) || value.length > MAX_FINGERPRINT_SOURCES) return null;
3238
+ const paths = [];
3239
+ for (const path of value) {
3240
+ if (typeof path !== "string" || path.length === 0 || path.length > MAX_PATH_CHARS) return null;
3241
+ paths.push(path);
3242
+ }
3243
+ return paths;
3244
+ }
3245
+ function fingerprintTracked(value) {
3246
+ if (value === void 0) return void 0;
3247
+ if (!Array.isArray(value) || value.length > MAX_FINGERPRINT_SOURCES) return null;
3248
+ const names = [];
3249
+ for (const name of value) {
3250
+ if (!(typeof name === "string" && name.length > 0 && name.length <= 255 && name !== "." && name !== ".." && !/[/\0]/.test(name))) return null;
3251
+ names.push(name);
3252
+ }
3253
+ return names;
3254
+ }
3229
3255
  const GRADLE_NAME = /^[A-Za-z0-9_-]{1,100}$/;
3230
3256
  const GRADLE_ROOT = /^[A-Za-z0-9._/ -]{1,200}$/;
3231
3257
  const GRADLE_MODULE = /^(:(?!\.\.?(?::|$))[A-Za-z0-9._-]+){1,20}$/;
@@ -3257,7 +3283,7 @@ function macosBuildOptions(params) {
3257
3283
  const artifactQuery = options.artifactInputs === void 0 ? null : swiftpmArtifactQuery({
3258
3284
  project: params.project,
3259
3285
  sourceDigest: params.fingerprint,
3260
- stimBuild: params.stimBuild,
3286
+ ...typeof params.compat === "number" ? { compat: params.compat } : { stimBuild: params.stimBuild },
3261
3287
  inputs: options.artifactInputs,
3262
3288
  build: {
3263
3289
  product: options.product,
@@ -3522,7 +3548,7 @@ var BuildHost = class {
3522
3548
  },
3523
3549
  warm: {
3524
3550
  checkout: existsSync(join(area, "src")),
3525
- dependencies: typeof params.lockfile === "string" && dependencies !== void 0 && reusableDependencies(area, dependencies.project, dependencies.packageName, params.lockfile, this.options.env),
3551
+ dependencies: typeof params.lockfile === "string" && dependencies !== void 0 && reusableDependencies(area, dependencies.project, params.lockfile, this.options.env),
3526
3552
  build: false
3527
3553
  },
3528
3554
  ...artifact !== void 0 ? { artifact } : {}
@@ -3610,7 +3636,7 @@ var BuildHost = class {
3610
3636
  const query = swiftpmArtifactQuery({
3611
3637
  project: job.project,
3612
3638
  sourceDigest: job.expectedFingerprint,
3613
- stimBuild: job.stimBuild,
3639
+ ...typeof job.compat === "number" ? { compat: job.compat } : { stimBuild: job.stimBuild },
3614
3640
  inputs: options.artifactInputs,
3615
3641
  build: {
3616
3642
  product: options.product,
@@ -4305,7 +4331,13 @@ var BuildSession = class {
4305
4331
  native = input;
4306
4332
  }
4307
4333
  if (native?.provider !== "gradle" && (typeof params.fingerprint !== "string" || !params.fingerprint)) return refusal("bad-request", "build.start needs params.fingerprint.");
4334
+ const expectedSources = fingerprintSources(params.fingerprintSources);
4335
+ if (expectedSources === null) return refusal("bad-request", `params.fingerprintSources must be at most ${MAX_FINGERPRINT_SOURCES} sources, each an id and a hex hash or null.`);
4308
4336
  if (!native && [...this.files.values()].some((file) => file.kind === "directory")) return refusal("bad-request", "Directory entries require a native build request.");
4337
+ const expectedIgnored = fingerprintIgnored(params.fingerprintIgnored);
4338
+ if (expectedIgnored === null) return refusal("bad-request", `params.fingerprintIgnored must be at most ${MAX_FINGERPRINT_SOURCES} paths of at most ${MAX_PATH_CHARS} characters.`);
4339
+ const expectedTracked = fingerprintTracked(params.fingerprintTracked);
4340
+ if (expectedTracked === null) return refusal("bad-request", `params.fingerprintTracked must be at most ${MAX_FINGERPRINT_SOURCES} top-level entry names.`);
4309
4341
  const android = platform === "android" ? androidOptions(params.android) : null;
4310
4342
  if (platform === "android" && !android) return refusal("bad-request", "An android build needs params.android: variant and abi (string or null), gradleBuildCache, pch and compilerCache.");
4311
4343
  let macos = null;
@@ -4320,7 +4352,11 @@ var BuildSession = class {
4320
4352
  for (const file of this.files.values()) if (!existsSync(this.blobPath(file.sha256))) return refusal("bad-request", `The blob of ${file.path} is missing; sync again.`);
4321
4353
  const toolchain = await this.host.toolchain(null, native?.provider);
4322
4354
  if (this.closed) return refusal("build-refused", "The connection closed.");
4323
- if (!toolchain || toolchain.stimBuild !== params.stimBuild) return refusal("build-refused", `This machine runs Stim build ${toolchain?.stimBuild ?? "unknown"}.`);
4355
+ const compat = typeof params.compat === "number" ? params.compat : void 0;
4356
+ if (!toolchain) return refusal("build-refused", "This machine runs Stim build unknown.");
4357
+ if (compat !== void 0 && toolchain.compat !== void 0) {
4358
+ if (toolchain.compat !== compat) return refusal("build-refused", `This machine runs Stim build ${toolchain.stimBuild ?? "unknown"} with build compatibility ${toolchain.compat}, not ${compat}.`);
4359
+ } else if (toolchain.stimBuild !== params.stimBuild) return refusal("build-refused", `This machine runs Stim build ${toolchain.stimBuild ?? "unknown"}.`);
4324
4360
  const launched = this.host.launch({
4325
4361
  client: this.client,
4326
4362
  repo: params.repo,
@@ -4333,15 +4369,18 @@ var BuildSession = class {
4333
4369
  android,
4334
4370
  macos,
4335
4371
  project: params.project,
4336
- packageName: optional(params.packageName),
4337
4372
  isExpo: params.isExpo === true,
4338
4373
  cacheArtifact: params.cacheArtifact === true,
4339
4374
  stimBuild: params.stimBuild,
4375
+ ...compat !== void 0 && toolchain.compat !== void 0 ? { compat } : {},
4340
4376
  configuration: optional(params.configuration),
4341
4377
  scheme: optional(params.scheme),
4342
4378
  iosProjectPath: optional(params.iosProjectPath),
4343
4379
  runtime: optional(params.runtime),
4344
4380
  expectedFingerprint: params.fingerprint,
4381
+ ...expectedSources ? { expectedSources } : {},
4382
+ ...expectedIgnored ? { expectedIgnored } : {},
4383
+ ...expectedTracked ? { expectedTracked } : {},
4345
4384
  optimizations: isJsonObject(params.optimizations) ? params.optimizations : null
4346
4385
  }
4347
4386
  });
@@ -6823,6 +6862,28 @@ function hostedDeviceExclusions(records) {
6823
6862
  key: hostedDeviceId(record.device)
6824
6863
  }] : []);
6825
6864
  }
6865
+ /** macOS lets 127.0.0.1 bind beside a wildcard listener and takes its loopback traffic, so probe by connecting. */
6866
+ function loopbackAccepts(port) {
6867
+ const accepts = (host) => new Promise((resolve) => {
6868
+ const socket = connect({
6869
+ host,
6870
+ port
6871
+ });
6872
+ const done = (open) => {
6873
+ socket.destroy();
6874
+ resolve(open);
6875
+ };
6876
+ socket.setTimeout(1e3, () => done(true));
6877
+ socket.once("connect", () => done(true));
6878
+ socket.once("error", (error) => done(![
6879
+ "ECONNREFUSED",
6880
+ "EADDRNOTAVAIL",
6881
+ "EAFNOSUPPORT",
6882
+ "ENETUNREACH"
6883
+ ].includes(error.code ?? "")));
6884
+ });
6885
+ return Promise.all([accepts("127.0.0.1"), accepts("::1")]).then((open) => open.includes(true));
6886
+ }
6826
6887
  /** Owns hosted reservations, not build jobs or viewer connections. Native work runs in the packaged CLI child. */
6827
6888
  var DeviceHost = class {
6828
6889
  owned = /* @__PURE__ */ new Map();
@@ -7190,15 +7251,20 @@ var DeviceHost = class {
7190
7251
  await this.restoreAndroidMetro(record, owned);
7191
7252
  return { result: { port } };
7192
7253
  }
7254
+ const devClientPort = record.platform === "ios" ? params.clientMetroPort : void 0;
7193
7255
  const bridge = createMetroBridge({
7194
7256
  peer,
7195
7257
  gatewayPort: params.gatewayPort,
7196
7258
  secret: params.secret
7197
7259
  });
7198
- const ready = new Promise((resolve, reject) => {
7199
- bridge.server.once("error", reject);
7200
- bridge.server.listen(record.metroPort ?? 0, "127.0.0.1", () => resolve(bridge.server.address().port));
7201
- });
7260
+ const inUse = () => Object.assign(/* @__PURE__ */ new Error(`Port ${devClientPort} is in use on the hosting Mac, and an Expo dev client loads its bundle from the client's Metro port.`), { code: "device-busy" });
7261
+ const ready = (async () => {
7262
+ if (devClientPort !== void 0 && await loopbackAccepts(devClientPort)) throw inUse();
7263
+ return new Promise((resolve, reject) => {
7264
+ bridge.server.once("error", (error) => reject(devClientPort !== void 0 && error.code === "EADDRINUSE" ? inUse() : error));
7265
+ bridge.server.listen(devClientPort ?? record.metroPort ?? 0, "127.0.0.1", () => resolve(bridge.server.address().port));
7266
+ });
7267
+ })();
7202
7268
  const metro = {
7203
7269
  bridge,
7204
7270
  ready,
@@ -7224,7 +7290,7 @@ var DeviceHost = class {
7224
7290
  throw error;
7225
7291
  }
7226
7292
  } catch (error) {
7227
- return refused$1("action-failed", error.message);
7293
+ return refused$1(error.code === "device-busy" ? "device-busy" : "action-failed", error.message);
7228
7294
  }
7229
7295
  }
7230
7296
  async restoreAndroidMetro(record, owned) {
@@ -7253,6 +7319,24 @@ var DeviceHost = class {
7253
7319
  if (owned.data === settled) delete owned.data;
7254
7320
  }
7255
7321
  }
7322
+ /** Names the ports a new dev-client bridge could not bind, apart from this client's own dev-client bridges. */
7323
+ async metroPorts(client, params) {
7324
+ if (this.closed || !this.options.allowed(client)) return refused$1("forbidden", "Current device-host approval is required.");
7325
+ if (!isJsonObject(params) || !Array.isArray(params.ports) || params.ports.length > 256 || params.ports.some((port) => typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) return refused$1("bad-request", "Metro ports needs at most 256 TCP ports.");
7326
+ try {
7327
+ const ports = [...new Set(params.ports)];
7328
+ const sessions = new Set(readHostedSessions().flatMap((record) => record.client === client ? [record.id] : []));
7329
+ const own = new Set([...this.owned].flatMap(([id, { metro }]) => sessions.has(id) && metro?.port !== void 0 && metro.port === metro.clientMetroPort ? [metro.port] : []));
7330
+ const busy = [];
7331
+ const queue = ports.filter((port) => !own.has(port));
7332
+ await Promise.all(Array.from({ length: 16 }, async () => {
7333
+ for (let port = queue.shift(); port !== void 0; port = queue.shift()) if (await loopbackAccepts(port)) busy.push(port);
7334
+ }));
7335
+ return { result: { busy: busy.toSorted((a, b) => a - b) } };
7336
+ } catch (error) {
7337
+ return refused$1("action-failed", error.message);
7338
+ }
7339
+ }
7256
7340
  async metroClose(client, params) {
7257
7341
  if (!this.options.allowed(client)) return refused$1("forbidden", "Current device-host approval is required.");
7258
7342
  if (!isJsonObject(params) || typeof params.session !== "string") return refused$1("bad-request", "Metro close needs a session.");
@@ -16909,7 +16993,7 @@ async function startServer(options) {
16909
16993
  });
16910
16994
  }
16911
16995
  if (method.startsWith("device-host.input.")) return input(id, method.slice(12), raw, session);
16912
- const answer = method === "device-host.offer" ? await hostedDevices.offer(session.id, raw) : method === "device-host.reserve" ? await hostedDevices.reserve(session.id, raw) : method === "device-host.attach" ? hostedDevices.attach(session.id, raw) : method === "device-host.stop" ? hostedDevices.stop(session.id, raw) : method === "device-host.app.offer" ? hostedDevices.appOffer(session.id, raw) : method === "device-host.app.chunk" ? await hostedDevices.appChunk(session.id, raw) : method === "device-host.app.handoff" ? await hostedDevices.appHandoff(session.id, raw) : method === "device-host.app.launch" ? hostedDevices.appLaunch(session.id, raw) : method === "device-host.app.attach" ? hostedDevices.appAttach(session.id, raw) : method === "device-host.logs.query" ? await hostedDevices.logsQuery(session.id, raw) : method === "device-host.metro.open" ? await hostedDevices.metroOpen(session.id, raw, peer) : await hostedDevices.metroClose(session.id, raw);
16996
+ const answer = method === "device-host.offer" ? await hostedDevices.offer(session.id, raw) : method === "device-host.reserve" ? await hostedDevices.reserve(session.id, raw) : method === "device-host.attach" ? hostedDevices.attach(session.id, raw) : method === "device-host.stop" ? hostedDevices.stop(session.id, raw) : method === "device-host.app.offer" ? hostedDevices.appOffer(session.id, raw) : method === "device-host.app.chunk" ? await hostedDevices.appChunk(session.id, raw) : method === "device-host.app.handoff" ? await hostedDevices.appHandoff(session.id, raw) : method === "device-host.app.launch" ? hostedDevices.appLaunch(session.id, raw) : method === "device-host.app.attach" ? hostedDevices.appAttach(session.id, raw) : method === "device-host.logs.query" ? await hostedDevices.logsQuery(session.id, raw) : method === "device-host.metro.open" ? await hostedDevices.metroOpen(session.id, raw, peer) : method === "device-host.metro.ports" ? await hostedDevices.metroPorts(session.id, raw) : await hostedDevices.metroClose(session.id, raw);
16913
16997
  return send(socket, "error" in answer ? {
16914
16998
  id,
16915
16999
  error: answer.error
@@ -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-Z2tFBWaG.mjs");
7
+ } else import("./stim-server-DowAx-6v.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.23.1",
3
+ "version": "1.25.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,13 +28,13 @@
28
28
  "./protocol.schema.json": "./dist/protocol.schema.json"
29
29
  },
30
30
  "dependencies": {
31
- "@stim-cli/agent-driver": "^1.23.1",
32
- "@stim-cli/agent-driver-agent-device": "^1.23.1",
33
- "@stim-cli/cache": "^1.23.1",
34
- "@stim-cli/core": "^1.23.1",
35
- "@stim-cli/transport": "^1.23.1",
36
- "@stim-cli/transport-tailscale": "^1.23.1",
37
- "stim": "1.23.1",
31
+ "@stim-cli/agent-driver": "1.25.0",
32
+ "@stim-cli/agent-driver-agent-device": "1.25.0",
33
+ "@stim-cli/cache": "1.25.0",
34
+ "@stim-cli/core": "1.25.0",
35
+ "@stim-cli/transport": "1.25.0",
36
+ "@stim-cli/transport-tailscale": "1.25.0",
37
+ "stim": "1.25.0",
38
38
  "ws": "^8.21.3"
39
39
  },
40
40
  "devDependencies": {