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