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