@modelstatus/cli 0.1.71 → 0.1.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
package/src/index.js CHANGED
@@ -824,6 +824,7 @@ async function applyUpdate(rerunArgs) {
824
824
  if (r.status === "latest") process.stderr.write(`✓ Already on the latest mm (${r.version}).\n`);
825
825
  else if (r.status === "manual")
826
826
  process.stderr.write(`✦ mm ${r.to} is available (you're on ${r.from}), but auto-update can't write ${process.execPath}. Reinstall:\n curl -fsSL https://llmstatus.ai/install.sh | bash\n`);
827
+ else if (r.status === "brew") process.stderr.write(`✦ mm ${r.to} is available (you're on ${r.from}). This is a Homebrew install — update with:\n brew upgrade modelstatus-cli\n`);
827
828
  else if (r.status === "npm") process.stderr.write(`This is an npm-managed install. Update with:\n npm update -g @modelstatus/cli\n`);
828
829
  else if (r.status === "unsupported") process.stderr.write(`Self-update isn't supported for this build. Reinstall:\n curl -fsSL https://llmstatus.ai/install.sh | bash\n`);
829
830
  else if (r.status === "error") process.stderr.write(`! Update check failed: ${r.message}\n`);
package/src/updater.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * same-run doesn't re-exec, so any in-flight state stays sane.
14
14
  */
15
15
  import crypto from "node:crypto";
16
+ import { spawnSync } from "node:child_process";
16
17
  import fs from "node:fs";
17
18
  import os from "node:os";
18
19
  import path from "node:path";
@@ -97,6 +98,14 @@ function dirWritable(dir) {
97
98
  }
98
99
  }
99
100
 
101
+ /** True when the running binary lives under a Homebrew prefix. Brew owns the
102
+ * binary's lifecycle (`brew upgrade`), and silently swapping it out from under
103
+ * brew would break its integrity tracking — so we never self-update there. */
104
+ function isBrewManaged() {
105
+ const p = process.execPath;
106
+ return p.includes("/Cellar/") || p.includes("/homebrew/") || p.includes("/linuxbrew/");
107
+ }
108
+
100
109
  /** Replace the executable via the atomic tmp+rename pattern ONLY (a new inode).
101
110
  * We must NOT writeFileSync over the live executable's inode: truncating +
102
111
  * rewriting a running, memory-mapped Mach-O can make macOS SIGKILL the running
@@ -114,6 +123,24 @@ function replaceBinary(exe, buf) {
114
123
  }
115
124
  }
116
125
 
126
+ // Our Developer ID team. A self-update download must be signed by us before we
127
+ // swap it in — otherwise a CDN compromise would auto-propagate to every
128
+ // installed macOS user (worse than a one-time install compromise). The sha256
129
+ // is from the same CDN as the binary, so it can't prove provenance; the code
130
+ // signature is the real anchor.
131
+ const TEAM_ID = "9H8U98V2UU";
132
+
133
+ /** macOS only: verify a downloaded binary file carries a valid Developer ID
134
+ * signature under OUR team, anchored to Apple. Throws if codesign is missing or
135
+ * the check fails (fail closed). No-op on Linux/Windows (no equivalent). */
136
+ function verifyMacSignature(file) {
137
+ if (process.platform !== "darwin") return;
138
+ const req = `anchor apple generic and certificate leaf[subject.OU] = "${TEAM_ID}"`;
139
+ const r = spawnSync("codesign", ["--verify", "--strict", `-R=${req}`, file], { stdio: "ignore" });
140
+ if (r.error) throw new Error("codesign unavailable — refusing to self-update");
141
+ if (r.status !== 0) throw new Error(`downloaded binary failed Developer ID verification (team ${TEAM_ID})`);
142
+ }
143
+
117
144
  async function downloadAndReplace(version, key, expectedSha) {
118
145
  const exe = process.execPath;
119
146
  const ext = process.platform === "win32" ? ".exe" : "";
@@ -125,6 +152,17 @@ async function downloadAndReplace(version, key, expectedSha) {
125
152
  if (actualSha !== expectedSha) {
126
153
  throw new Error(`sha256 mismatch: expected ${expectedSha.slice(0, 12)}…, got ${actualSha.slice(0, 12)}…`);
127
154
  }
155
+ // On macOS, verify the SIGNATURE before trusting these bytes as our binary.
156
+ // Stage to a temp file, codesign-verify it, then swap — never swap unverified.
157
+ if (process.platform === "darwin") {
158
+ const staged = path.join(path.dirname(exe), `.${path.basename(exe)}.verify.${process.pid}`);
159
+ try {
160
+ fs.writeFileSync(staged, buf, { mode: 0o755 });
161
+ verifyMacSignature(staged);
162
+ } finally {
163
+ try { fs.unlinkSync(staged); } catch { /* best effort */ }
164
+ }
165
+ }
128
166
  replaceBinary(exe, buf);
129
167
  }
130
168
 
@@ -158,6 +196,9 @@ export async function forceUpdate() {
158
196
 
159
197
  const expectedSha = manifest.sha256?.[key];
160
198
  if (!expectedSha) return { status: "error", message: `no sha256 for ${key}` };
199
+ if (isBrewManaged()) {
200
+ return { status: "brew", from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
201
+ }
161
202
  if (!dirWritable(path.dirname(process.execPath))) {
162
203
  return { status: "manual", from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
163
204
  }
@@ -173,6 +214,7 @@ export async function maybeCheckForUpdate(flags = {}) {
173
214
  try {
174
215
  if (!IS_SHELL_INSTALL) return null;
175
216
  if (process.env.MM_NO_AUTO_UPDATE) return null;
217
+ if (isBrewManaged()) return null; // brew upgrade owns the lifecycle
176
218
  if (flags.json || flags.ci) return null;
177
219
 
178
220
  const cache = readCache() || {};