@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 +1 -1
- package/src/index.js +1 -0
- package/src/updater.js +71 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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
|
|
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
|
|