@modelstatus/cli 0.1.71 → 0.1.74

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.74",
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";
@@ -87,6 +88,33 @@ async function fetchJson(url) {
87
88
  return res.json();
88
89
  }
89
90
 
91
+ // Ed25519 RELEASE public key (committed: packages/cli/release-pubkey.pem). The
92
+ // manifest is signed with the matching private key; verifying that signature is
93
+ // what makes the manifest's sha256 trustworthy on every platform, independent of
94
+ // the CDN. An auto-update that can't verify this signature does not proceed.
95
+ const RELEASE_PUBKEY = `-----BEGIN PUBLIC KEY-----
96
+ MCowBQYDK2VwAyEApu4VP7vB6iwxsPcK5KaosjK7SIp9HrWMt5IvcUwjUOM=
97
+ -----END PUBLIC KEY-----`;
98
+
99
+ /** Fetch the manifest + its detached Ed25519 signature and verify before
100
+ * parsing. Returns the parsed manifest, or throws if the signature is missing or
101
+ * invalid (fail closed — never self-update against an unverifiable manifest). */
102
+ async function fetchVerifiedManifest(base) {
103
+ const [mRes, sRes] = await Promise.all([
104
+ fetch(`${base}/version.json`, { cache: "no-store" }),
105
+ fetch(`${base}/version.json.sig`, { cache: "no-store" }),
106
+ ]);
107
+ if (!mRes.ok) throw new Error(`GET version.json -> ${mRes.status}`);
108
+ if (!sRes.ok) throw new Error("manifest signature unavailable — refusing to self-update");
109
+ const manifestBytes = Buffer.from(await mRes.arrayBuffer());
110
+ const sig = Buffer.from((await sRes.text()).trim(), "base64");
111
+ const key = crypto.createPublicKey(RELEASE_PUBKEY);
112
+ if (!crypto.verify(null, manifestBytes, key, sig)) {
113
+ throw new Error("manifest signature INVALID — refusing to self-update");
114
+ }
115
+ return JSON.parse(manifestBytes.toString("utf8"));
116
+ }
117
+
90
118
  /** True if we can create/rename files in `dir` (needed for an atomic swap). */
91
119
  function dirWritable(dir) {
92
120
  try {
@@ -97,6 +125,14 @@ function dirWritable(dir) {
97
125
  }
98
126
  }
99
127
 
128
+ /** True when the running binary lives under a Homebrew prefix. Brew owns the
129
+ * binary's lifecycle (`brew upgrade`), and silently swapping it out from under
130
+ * brew would break its integrity tracking — so we never self-update there. */
131
+ function isBrewManaged() {
132
+ const p = process.execPath;
133
+ return p.includes("/Cellar/") || p.includes("/homebrew/") || p.includes("/linuxbrew/");
134
+ }
135
+
100
136
  /** Replace the executable via the atomic tmp+rename pattern ONLY (a new inode).
101
137
  * We must NOT writeFileSync over the live executable's inode: truncating +
102
138
  * rewriting a running, memory-mapped Mach-O can make macOS SIGKILL the running
@@ -114,6 +150,24 @@ function replaceBinary(exe, buf) {
114
150
  }
115
151
  }
116
152
 
153
+ // Our Developer ID team. A self-update download must be signed by us before we
154
+ // swap it in — otherwise a CDN compromise would auto-propagate to every
155
+ // installed macOS user (worse than a one-time install compromise). The sha256
156
+ // is from the same CDN as the binary, so it can't prove provenance; the code
157
+ // signature is the real anchor.
158
+ const TEAM_ID = "9H8U98V2UU";
159
+
160
+ /** macOS only: verify a downloaded binary file carries a valid Developer ID
161
+ * signature under OUR team, anchored to Apple. Throws if codesign is missing or
162
+ * the check fails (fail closed). No-op on Linux/Windows (no equivalent). */
163
+ function verifyMacSignature(file) {
164
+ if (process.platform !== "darwin") return;
165
+ const req = `anchor apple generic and certificate leaf[subject.OU] = "${TEAM_ID}"`;
166
+ const r = spawnSync("codesign", ["--verify", "--strict", `-R=${req}`, file], { stdio: "ignore" });
167
+ if (r.error) throw new Error("codesign unavailable — refusing to self-update");
168
+ if (r.status !== 0) throw new Error(`downloaded binary failed Developer ID verification (team ${TEAM_ID})`);
169
+ }
170
+
117
171
  async function downloadAndReplace(version, key, expectedSha) {
118
172
  const exe = process.execPath;
119
173
  const ext = process.platform === "win32" ? ".exe" : "";
@@ -125,6 +179,17 @@ async function downloadAndReplace(version, key, expectedSha) {
125
179
  if (actualSha !== expectedSha) {
126
180
  throw new Error(`sha256 mismatch: expected ${expectedSha.slice(0, 12)}…, got ${actualSha.slice(0, 12)}…`);
127
181
  }
182
+ // On macOS, verify the SIGNATURE before trusting these bytes as our binary.
183
+ // Stage to a temp file, codesign-verify it, then swap — never swap unverified.
184
+ if (process.platform === "darwin") {
185
+ const staged = path.join(path.dirname(exe), `.${path.basename(exe)}.verify.${process.pid}`);
186
+ try {
187
+ fs.writeFileSync(staged, buf, { mode: 0o755 });
188
+ verifyMacSignature(staged);
189
+ } finally {
190
+ try { fs.unlinkSync(staged); } catch { /* best effort */ }
191
+ }
192
+ }
128
193
  replaceBinary(exe, buf);
129
194
  }
130
195
 
@@ -151,13 +216,16 @@ export async function forceUpdate() {
151
216
  const key = platformKey();
152
217
  if (!key) return { status: "unsupported" };
153
218
 
154
- const manifest = await fetchJson(`${CDN}/cli/${CHANNEL_PATH}/version.json`);
219
+ const manifest = await fetchVerifiedManifest(`${CDN}/cli/${CHANNEL_PATH}`);
155
220
  writeCache({ ...(readCache() || {}), last_check: Date.now(), latest_known: manifest.version });
156
221
  if (!manifest.version) return { status: "error", message: "manifest has no version" };
157
222
  if (compareVer(manifest.version, BUILD_VERSION) <= 0) return { status: "latest", version: dispVer(BUILD_VERSION) };
158
223
 
159
224
  const expectedSha = manifest.sha256?.[key];
160
225
  if (!expectedSha) return { status: "error", message: `no sha256 for ${key}` };
226
+ if (isBrewManaged()) {
227
+ return { status: "brew", from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
228
+ }
161
229
  if (!dirWritable(path.dirname(process.execPath))) {
162
230
  return { status: "manual", from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
163
231
  }
@@ -173,6 +241,7 @@ export async function maybeCheckForUpdate(flags = {}) {
173
241
  try {
174
242
  if (!IS_SHELL_INSTALL) return null;
175
243
  if (process.env.MM_NO_AUTO_UPDATE) return null;
244
+ if (isBrewManaged()) return null; // brew upgrade owns the lifecycle
176
245
  if (flags.json || flags.ci) return null;
177
246
 
178
247
  const cache = readCache() || {};
@@ -181,7 +250,7 @@ export async function maybeCheckForUpdate(flags = {}) {
181
250
  const key = platformKey();
182
251
  if (!key) return null;
183
252
 
184
- const manifest = await fetchJson(`${CDN}/cli/${CHANNEL_PATH}/version.json`);
253
+ const manifest = await fetchVerifiedManifest(`${CDN}/cli/${CHANNEL_PATH}`);
185
254
  // Always update the cache so we don't re-check for 24 h.
186
255
  writeCache({ ...cache, last_check: Date.now(), latest_known: manifest.version });
187
256