Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ The workflow:
6. Publishes `@tashian/tsm@<version>` second
7. Both npm publishes carry provenance attestations linking back to this exact workflow run

On every invocation, the `@tashian/tsm` wrapper (`shim.js`) SIGTERMs any `tsmd` running from a different install location than the one this `tsm` is about to use, so upgrades pick up the new daemon on the next command. Doing it in the shim instead of a `postinstall` script means it works under bun, which silently skips lifecycle scripts by default. No-op on non-darwin.
After an upgrade, the running `tsmd` keeps serving the old code until it idle-quits (all sessions past TTL + 30 minutes without an RPC), at which point the next `tsm` command spawns a fresh daemon from the new binary. Users who want the upgrade applied immediately can `tsm daemon stop`.

## Local dry-run

Expand Down
2 changes: 1 addition & 1 deletion npm/wrapper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pnpm add -g @tashian/tsm

This package is a thin shim. The actual binaries are pulled in via `optionalDependencies` based on your platform — currently only `@tashian/tsm-darwin-arm64` (Apple Silicon Macs).

On upgrade, the next `tsm` invocation SIGTERMs any `tsmd` running from a different install location than the one this `tsm` is about to use, then the daemon respawns on demand — so you'll Touch ID re-unlock once. This runs in the wrapper itself rather than a `postinstall` script so it works under bun (which skips lifecycle scripts by default) and any other manager that does the same.
After an upgrade, the running `tsmd` keeps serving until your sessions all hit their TTL and the daemon has been idle for 30 minutes — at which point it exits and the next `tsm` command spawns a fresh daemon from the new binary. If you want the new daemon immediately, run `tsm daemon stop`.

## Usage

Expand Down
180 changes: 0 additions & 180 deletions npm/wrapper/scripts/shim.test.js

This file was deleted.

163 changes: 33 additions & 130 deletions npm/wrapper/shim.js
Original file line number Diff line number Diff line change
@@ -1,148 +1,51 @@
"use strict";

const path = require("node:path");
const { spawnSync, execFileSync } = require("node:child_process");
const { spawnSync } = require("node:child_process");

const platforms = {
"darwin-arm64": "@tashian/tsm-darwin-arm64",
};

// pgrep -u $USER -f '^.*tsmd( |$)' — every tsmd process owned by the current
// user. Empty list on no-match, ENOENT, or any other failure: the shim must
// never block tsm.
function pgrepUserTsmds({ execFileSync: exec = execFileSync } = {}) {
const user = process.env.USER || "nobody";
let out;
try {
out = exec("pgrep", ["-u", user, "-f", "^.*tsmd( |$)"], {
stdio: ["ignore", "pipe", "ignore"],
});
} catch {
return [];
}
return String(out)
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n) && n > 0);
}

// First whitespace-delimited token of `ps -p PID -o args=` — argv[0], i.e.
// the path the kernel exec'd. Returns null if ps fails or produces no
// output (e.g. the process exited between pgrep and ps).
function getPidArgv0(pid, { execFileSync: exec = execFileSync } = {}) {
let out;
try {
out = exec("ps", ["-p", String(pid), "-o", "args="], {
stdio: ["ignore", "pipe", "ignore"],
});
} catch {
return null;
}
const line = String(out).trim();
if (!line) {
return null;
}
return line.split(/\s+/)[0];
}
const key = `${process.platform}-${process.arch}`;
const pkg = platforms[key];

// Tsmd pids whose argv[0] is anything other than expectedPath. "Stale" =
// launched from a different prefix — e.g. ~/.local/bin/tsmd left over from
// a manual install before this user upgraded via npm/bun.
function findStaleTsmdPids(expectedPath, deps = {}) {
const pids = pgrepUserTsmds(deps);
const stale = [];
for (const pid of pids) {
const argv0 = getPidArgv0(pid, deps);
if (argv0 && argv0 !== expectedPath) {
stale.push(pid);
}
}
return stale;
if (!pkg) {
console.error(
`tsm: unsupported platform ${key}. ` +
`tsm currently ships binaries for: ${Object.keys(platforms).join(", ")}.`,
);
process.exit(1);
}

// SIGTERM each pid. Swallow ESRCH (race) and EPERM (cross-user daemon —
// possible after a stray `sudo npm install`); silently swallow anything
// else. The daemon's own SIGTERM handler closes the socket and zeros the
// master key on exit; the next `tsm` command will EnsureRunning a fresh
// daemon at TSM_TSMD_BIN.
function killStalePids(pids, { kill = process.kill.bind(process) } = {}) {
for (const pid of pids) {
try {
kill(pid, "SIGTERM");
} catch {
// best effort
}
}
let pkgRoot;
try {
pkgRoot = path.dirname(require.resolve(`${pkg}/package.json`));
} catch {
console.error(
`tsm: prebuilt binary package "${pkg}" was not installed.\n` +
`npm or bun likely skipped it because of an OS/arch mismatch, or you used --no-optional.\n` +
`Reinstall without --no-optional, or build from source: https://github.com/tashian/tsm`,
);
process.exit(1);
}

function main(argv = process.argv) {
const key = `${process.platform}-${process.arch}`;
const pkg = platforms[key];

if (!pkg) {
console.error(
`tsm: unsupported platform ${key}. ` +
`tsm currently ships binaries for: ${Object.keys(platforms).join(", ")}.`,
);
return 1;
}

let pkgRoot;
try {
pkgRoot = path.dirname(require.resolve(`${pkg}/package.json`));
} catch {
console.error(
`tsm: prebuilt binary package "${pkg}" was not installed.\n` +
`npm or bun likely skipped it because of an OS/arch mismatch, or you used --no-optional.\n` +
`Reinstall without --no-optional, or build from source: https://github.com/tashian/tsm`,
);
return 1;
}

const binDir = path.join(pkgRoot, "bin");
const tsm = path.join(binDir, "tsm");
const tsmd = path.join(binDir, "tsmd");
const binDir = path.join(pkgRoot, "bin");
const tsm = path.join(binDir, "tsm");
const tsmd = path.join(binDir, "tsmd");

// tsm finds tsmd next to its own executable (internal/paths/paths.go), but
// global installs symlink the wrapper, and os.Executable() on Darwin can
// return the symlink path. Pinning TSM_TSMD_BIN sidesteps that entirely.
if (!process.env.TSM_TSMD_BIN) {
process.env.TSM_TSMD_BIN = tsmd;
}

// SIGTERM any tsmd whose argv[0] doesn't match TSM_TSMD_BIN — stale from
// a previous install at a different prefix. Bun skips lifecycle scripts
// by default and there's no consumer package.json on a global install
// for `trustedDependencies` to apply, so the shim is the reliable place
// to do this. Cheap (one pgrep + one ps per stale process), no-op when
// the running daemon already matches.
try {
const stale = findStaleTsmdPids(process.env.TSM_TSMD_BIN);
killStalePids(stale);
} catch {
// never block tsm
}

const result = spawnSync(tsm, argv.slice(2), { stdio: "inherit" });

if (result.error) {
console.error(`tsm: failed to exec ${tsm}: ${result.error.message}`);
return 1;
}

return result.status ?? 1;
// tsm finds tsmd next to its own executable (internal/paths/paths.go), but
// global installs symlink the wrapper, and os.Executable() on Darwin can
// return the symlink path. Pinning TSM_TSMD_BIN sidesteps that entirely.
if (!process.env.TSM_TSMD_BIN) {
process.env.TSM_TSMD_BIN = tsmd;
}

module.exports = {
pgrepUserTsmds,
getPidArgv0,
findStaleTsmdPids,
killStalePids,
main,
};
const result = spawnSync(tsm, process.argv.slice(2), { stdio: "inherit" });

if (require.main === module) {
process.exit(main());
if (result.error) {
console.error(`tsm: failed to exec ${tsm}: ${result.error.message}`);
process.exit(1);
}

process.exit(result.status ?? 1);
Loading