@modelstatus/cli 0.1.85 → 0.1.87
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 +7 -2
- package/src/api.js +41 -11
- package/src/changelog-data.js +23 -0
- package/src/ci.js +8 -4
- package/src/detect/core.js +17 -6
- package/src/fix.js +132 -46
- package/src/index.js +172 -38
- package/src/integrations.js +18 -3
- package/src/registry/fetch.js +16 -1
- package/src/sources/aws-lambda.js +9 -2
- package/src/sources/aws.js +7 -1
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +9 -2
- package/src/sources/helm.js +9 -3
- package/src/sources/index.js +45 -3
- package/src/sources/k8s.js +6 -2
- package/src/sources/shell.js +126 -14
- package/src/sources/sql.js +6 -2
- package/src/sources/supabase-edge.js +19 -5
- package/src/sources/vercel.js +64 -6
- package/src/telemetry.js +21 -0
- package/src/tui/app.js +55 -13
- package/src/tui/game/launch.js +14 -2
- package/src/tui/signin.js +43 -7
- package/src/tui/views/account.js +19 -2
- package/src/tui/views/alerts.js +24 -9
- package/src/tui/views/whatsnew.js +82 -28
- package/src/updater.js +160 -39
- package/src/upgrade.js +37 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.87",
|
|
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",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
],
|
|
23
23
|
"homepage": "https://llmstatus.ai",
|
|
24
24
|
"bugs": {
|
|
25
|
-
"
|
|
25
|
+
"url": "https://github.com/randomartifact/llm-status/issues",
|
|
26
|
+
"email": "hi@llmstatus.ai"
|
|
26
27
|
},
|
|
27
28
|
"author": "LLM Status <it@llmstatus.ai>",
|
|
28
29
|
"license": "MIT",
|
|
@@ -51,5 +52,9 @@
|
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"ink-testing-library": "^4.0.0"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/randomartifact/llm-status.git"
|
|
54
59
|
}
|
|
55
60
|
}
|
package/src/api.js
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
/** Thin client for the LLM Status / Model Manager public API (/api/v1). */
|
|
2
|
+
|
|
3
|
+
// Per-request cap. Without one, a connection that stalls after the TCP accept
|
|
4
|
+
// (captive portal, blackholing proxy) holds a command for undici's ~5-minute
|
|
5
|
+
// default — or forever if the server dribbles bytes. Uploads get a longer
|
|
6
|
+
// leash (big inventories on slow links). Overridable for tests.
|
|
7
|
+
const timeoutMs = () => Number(process.env.MM_API_TIMEOUT_MS) || 15_000;
|
|
8
|
+
const uploadTimeoutMs = () => Number(process.env.MM_API_UPLOAD_TIMEOUT_MS) || Number(process.env.MM_API_TIMEOUT_MS) || 60_000;
|
|
9
|
+
|
|
10
|
+
const isTimeout = (e) => e?.name === "TimeoutError" || e?.name === "AbortError" || e?.code === "ABORT_ERR" || e?.cause?.name === "TimeoutError";
|
|
11
|
+
|
|
2
12
|
export function createClient({ apiBase, apiKey }) {
|
|
3
|
-
async function req(method, pathname, body) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
async function req(method, pathname, body, { timeout = timeoutMs() } = {}) {
|
|
14
|
+
let res;
|
|
15
|
+
try {
|
|
16
|
+
res = await fetch(`${apiBase}/api/v1${pathname}`, {
|
|
17
|
+
method,
|
|
18
|
+
headers: {
|
|
19
|
+
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
20
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
21
|
+
},
|
|
22
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
23
|
+
signal: AbortSignal.timeout(timeout),
|
|
24
|
+
});
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (isTimeout(e)) {
|
|
27
|
+
const err = new Error(`request timed out — check your network (${method} ${pathname} @ ${apiBase})`);
|
|
28
|
+
err.code = "ETIMEDOUT";
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
12
33
|
const text = await res.text();
|
|
13
34
|
let data;
|
|
14
35
|
try {
|
|
@@ -18,7 +39,16 @@ export function createClient({ apiBase, apiKey }) {
|
|
|
18
39
|
}
|
|
19
40
|
if (!res.ok) {
|
|
20
41
|
const msg = data?.error?.message || data?.message || `HTTP ${res.status}`;
|
|
21
|
-
|
|
42
|
+
// Auth failures get a re-login hint instead of a raw endpoint error —
|
|
43
|
+
// a revoked/rotated key is the most common cloud failure. 403 (valid key,
|
|
44
|
+
// insufficient scope/permissions) keeps the server's explanation.
|
|
45
|
+
const err = new Error(
|
|
46
|
+
res.status === 401
|
|
47
|
+
? "API key invalid or expired — run `mm login` (or pass --key)"
|
|
48
|
+
: res.status === 403
|
|
49
|
+
? `${msg} — run \`mm login\` to sign in with a different key`
|
|
50
|
+
: `${method} ${pathname} → ${msg}`,
|
|
51
|
+
);
|
|
22
52
|
err.status = res.status;
|
|
23
53
|
err.code = data?.error?.code;
|
|
24
54
|
err.body = data;
|
|
@@ -62,7 +92,7 @@ export function createClient({ apiBase, apiKey }) {
|
|
|
62
92
|
patchUsage: (id, body) => req("PATCH", `/usages/${id}`, body),
|
|
63
93
|
deleteUsage: (id) => req("DELETE", `/usages/${id}`),
|
|
64
94
|
linkUsage: (id, modelId) => req("POST", `/usages/${id}/link`, { model_id: modelId }),
|
|
65
|
-
bulkUpload: (projectId, usages) => req("POST", "/usages/bulk", { project_id: projectId, usages }),
|
|
95
|
+
bulkUpload: (projectId, usages) => req("POST", "/usages/bulk", { project_id: projectId, usages }, { timeout: uploadTimeoutMs() }),
|
|
66
96
|
// Clear inventory: all usages, or `{ all: true }` to also wipe projects + rules + feed.
|
|
67
97
|
clearUsages: ({ all = false, project } = {}) => req("DELETE", `/usages${qs({ all: all ? "true" : undefined, project_id: project })}`),
|
|
68
98
|
|
package/src/changelog-data.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
/* GENERATED by scripts/gen-changelog.mjs from apps/web/lib/changelog.json — do not edit.
|
|
2
2
|
* Release notes baked into the binary (in-TUI + the on-load what's-new card). */
|
|
3
3
|
export const CHANGELOG = [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.87",
|
|
6
|
+
"date": "2026-07-07",
|
|
7
|
+
"title": "Hardening pass: 40 fixes from an adversarial audit",
|
|
8
|
+
"items": [
|
|
9
|
+
"`mm fix` now writes the provider’s real model id (`gpt-5.5`), not the registry’s dash-form slug (`gpt-5-5`) — 59 of 85 replacement targets were affected. It also fixes everything the scanner detects (Bedrock ARNs, dated variants, mixed case), preserves your file’s line endings, and refuses to touch files that aren’t valid UTF-8.",
|
|
10
|
+
"The CI gate can’t pass by accident anymore: `--fail-on=retiring` (equals-style flags) now parses, a typo’d `--sources` or a path that doesn’t exist is a hard error instead of a silent green build, and withdrawn models fail the gate like retired ones. Unknown flags error out instead of being ignored.",
|
|
11
|
+
"`mm scan --dry-run` is fully local — no account needed, nothing sent anywhere.",
|
|
12
|
+
"Every network call has a timeout: a stalled CDN or API can’t hang a command for minutes. Failed self-updates back off instead of re-downloading each run, and self-update works on Windows.",
|
|
13
|
+
"Vendor CLI failures (aws, vercel, supabase…) warn instead of silently reporting a clean scan. `.github/workflows` files are scanned now, and `--vercel-project` verifies the linked project instead of relabeling output.",
|
|
14
|
+
"Telemetry no longer includes your working directory path — anonymous event names only, as documented.",
|
|
15
|
+
"TUI: long alerts lists scroll properly (keys can’t act on an off-screen rule, delete asks first), the arcade game can’t strand your terminal or undo a sign-in, and checkout polling stops when you quit."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.1.86",
|
|
20
|
+
"date": "2026-06-16",
|
|
21
|
+
"title": "Search the What's New feed",
|
|
22
|
+
"items": [
|
|
23
|
+
"The What's New tab now has `/` search, like Scan and Inventory. Filter the registry feed, alerts, fixes, and releases by typing — e.g. `deprecated` to see just the deprecations, or a model or provider name to jump to a specific change.",
|
|
24
|
+
"Press `/` to start filtering, `esc` to clear; the count of matches shows as you type."
|
|
25
|
+
]
|
|
26
|
+
},
|
|
4
27
|
{
|
|
5
28
|
"version": "0.1.85",
|
|
6
29
|
"date": "2026-06-14",
|
package/src/ci.js
CHANGED
|
@@ -9,10 +9,14 @@ import { getRegistry } from "./registry/fetch.js";
|
|
|
9
9
|
import { resolveLocal, computeHealth } from "./registry/local.js";
|
|
10
10
|
import { collectFrom } from "./sources/index.js";
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// "withdrawn" (the provider already pulled the model) is as dead as retired —
|
|
13
|
+
// same rank, mirroring tui/scan-stream.js — so it fails every threshold except
|
|
14
|
+
// "none". Omitting it here once meant `undefined >= threshold` and withdrawn
|
|
15
|
+
// models could NEVER fail the build.
|
|
16
|
+
export const HEALTH_RANK = { ok: 0, deprecating: 1, retiring: 2, retired: 3, withdrawn: 3 };
|
|
13
17
|
// `--fail-on` threshold → the minimum health rank that fails the build.
|
|
14
18
|
export const FAIL_THRESHOLD = { none: 99, deprecating: 1, retiring: 2, retired: 3 };
|
|
15
|
-
const BADGE = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴" };
|
|
19
|
+
const BADGE = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔" };
|
|
16
20
|
|
|
17
21
|
/** Evaluate `dir` and return { findings, failing, threshold, failOn, counts, snapshot }.
|
|
18
22
|
* `findings` are per-(model, location) entries with health worse than ok. */
|
|
@@ -54,7 +58,7 @@ export async function evaluateCi({ dir, sources = ["filesystem"], explicit = new
|
|
|
54
58
|
}
|
|
55
59
|
findings.sort((a, b) => HEALTH_RANK[b.health] - HEALTH_RANK[a.health] || String(a.retires || "9999").localeCompare(String(b.retires || "9999")));
|
|
56
60
|
|
|
57
|
-
const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0 };
|
|
61
|
+
const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, withdrawn: 0 };
|
|
58
62
|
for (const f of findings) counts[f.health]++;
|
|
59
63
|
const failing = findings.filter((f) => HEALTH_RANK[f.health] >= threshold);
|
|
60
64
|
return { snapshot, candidates, findings, failing, threshold, failOn, counts };
|
|
@@ -126,7 +130,7 @@ export function annotationLines(findings, threshold) {
|
|
|
126
130
|
export function summaryMarkdown(findings, { failing, failOn } = {}) {
|
|
127
131
|
const lines = ["## LLM Status — model lifecycle check", ""];
|
|
128
132
|
if (!findings.length) {
|
|
129
|
-
lines.push("✅ No deprecated, retiring, or
|
|
133
|
+
lines.push("✅ No deprecated, retiring, retired, or withdrawn AI models found.");
|
|
130
134
|
return lines.join("\n") + "\n";
|
|
131
135
|
}
|
|
132
136
|
const fc = failing ? failing.length : 0;
|
package/src/detect/core.js
CHANGED
|
@@ -16,21 +16,22 @@ function cleanGeneric(s) {
|
|
|
16
16
|
|
|
17
17
|
/** A char is part of a model token if it's alnum or one of - _ . / : (slug-ish).
|
|
18
18
|
* Mirrors the server PR scanner (apps/web/lib/github/scan-pr.ts) so the CLI and
|
|
19
|
-
* the GitHub check agree on what counts as a boundary.
|
|
20
|
-
|
|
19
|
+
* the GitHub check agree on what counts as a boundary. Exported so fix.js keeps
|
|
20
|
+
* its rewrite boundaries in lockstep with detection. */
|
|
21
|
+
export function isTokenChar(ch) {
|
|
21
22
|
return /[A-Za-z0-9._/:-]/.test(ch);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
// Provider prefixes legitimately precede an id ("anthropic.claude-…", "ft:gpt-…",
|
|
25
26
|
// "us.anthropic.…", "openrouter/…"), so '.' ':' '/' on the LEFT is still a boundary.
|
|
26
|
-
function isPrefixSep(ch) {
|
|
27
|
+
export function isPrefixSep(ch) {
|
|
27
28
|
return ch === "." || ch === ":" || ch === "/";
|
|
28
29
|
}
|
|
29
30
|
// Known model-id SUFFIXES seen in real configs: Bedrock ':0'/'-v1', dated
|
|
30
31
|
// '-20250514' snapshots, '@version'. The remainder starting with one is still a
|
|
31
32
|
// boundary — so "claude-opus-4-20250514" resolves inside a Bedrock ARN, while
|
|
32
33
|
// "gpt-4" still does NOT match inside "gpt-4o". Kept identical to scan-pr.ts.
|
|
33
|
-
const MODEL_SUFFIX = /^(:|-v[0-9]|-[0-9]{6,}|@)/;
|
|
34
|
+
export const MODEL_SUFFIX = /^(:|-v[0-9]|-[0-9]{6,}|@)/;
|
|
34
35
|
|
|
35
36
|
/** True when `term` occurs in `haystack` at a model-id boundary — tolerating
|
|
36
37
|
* provider prefixes + known version/region/snapshot suffixes, but NOT a plain
|
|
@@ -135,8 +136,18 @@ export function detectInLine(line, compiled) {
|
|
|
135
136
|
re.lastIndex = 0;
|
|
136
137
|
let m;
|
|
137
138
|
while ((m = re.exec(line))) {
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
// Left boundary — same rule as matchesAtBoundary. A token char right
|
|
140
|
+
// before the match means we're inside a longer identifier ("ChatGPT-4",
|
|
141
|
+
// "ngrok-2.3.40", "my-gpt-4-deployment"), not a model id; provider
|
|
142
|
+
// prefixes ('.' ':' '/') stay legal ("openai/gpt-4", "ft:gpt-4",
|
|
143
|
+
// "anthropic.claude-…"). Hyphen/underscore-joined prefixes are treated
|
|
144
|
+
// as compound identifiers (resource/deployment names), matching the
|
|
145
|
+
// exact-string matcher. (scan-pr.ts should mirror this — see web repo.)
|
|
146
|
+
const before = m.index > 0 ? line[m.index - 1] : "";
|
|
147
|
+
if (!before || !isTokenChar(before) || isPrefixSep(before)) {
|
|
148
|
+
const cand = cleanGeneric(m[0].toLowerCase());
|
|
149
|
+
if (looksLikeModel(cand)) found.add(cand);
|
|
150
|
+
}
|
|
140
151
|
if (re.lastIndex === m.index) re.lastIndex++;
|
|
141
152
|
}
|
|
142
153
|
}
|
package/src/fix.js
CHANGED
|
@@ -9,15 +9,23 @@
|
|
|
9
9
|
* - Replace ONLY on the recorded line. If the string is no longer there (file
|
|
10
10
|
* changed since the scan), the ref is reported "stale" and skipped — never
|
|
11
11
|
* guess. A rescan refreshes the locations.
|
|
12
|
-
* - Boundary-aware matching
|
|
13
|
-
* "gpt-4
|
|
14
|
-
*
|
|
12
|
+
* - Boundary-aware matching in LOCKSTEP with detect/core.js (same predicates,
|
|
13
|
+
* same case-insensitivity): "gpt-4" must never rewrite inside "gpt-4o" /
|
|
14
|
+
* "gpt-4-turbo", but provider prefixes ("anthropic.claude-…", "openai/…")
|
|
15
|
+
* are fine on the left — exactly what the scan matched, fix can rewrite.
|
|
16
|
+
* - Version-pinned occurrences ("claude-3-sonnet@20240229", Bedrock "-v1:0",
|
|
17
|
+
* ft ":acme", template "-${ver}") are NEVER base-swapped — the old model's
|
|
18
|
+
* pin glued to the new id is a nonexistent id. They skip with an explicit
|
|
19
|
+
* "pinned variant — review manually" note.
|
|
20
|
+
* - Never corrupt bytes: non-UTF-8 files are skipped loudly, and every line
|
|
21
|
+
* keeps its own \n / \r\n ending (one stray CRLF never converts the file).
|
|
15
22
|
* - Style-preserving replacement: if the code says "openai/gpt-4" the new id
|
|
16
23
|
* keeps the provider prefix; if it says "gpt-4" it stays bare.
|
|
17
24
|
*/
|
|
18
25
|
import fs from "node:fs";
|
|
19
26
|
import os from "node:os";
|
|
20
27
|
import path from "node:path";
|
|
28
|
+
import { isTokenChar, isPrefixSep, MODEL_SUFFIX } from "./detect/core.js";
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
31
|
* Follow a replacement chain to the first CURRENT model. Replacements can
|
|
@@ -25,6 +33,12 @@ import path from "node:path";
|
|
|
25
33
|
* rewriting to a dying model just means fixing twice — land on the live one.
|
|
26
34
|
* `getModel(slug)` → model|null; `isCurrent(model)` → bool. Cycle-guarded;
|
|
27
35
|
* unknown slugs end the walk (best known answer wins).
|
|
36
|
+
*
|
|
37
|
+
* Returns the WRITE-FORM of the target — "provider/canonical_id" when the
|
|
38
|
+
* model is known (e.g. "openai/gpt-5.5"), else the registry slug. Registry
|
|
39
|
+
* slugs dash-encode dots ("openai/gpt-5-5"), and providers reject the slug
|
|
40
|
+
* form ("gpt-5-5" 404s where the API id is "gpt-5.5") — canonical_id is the
|
|
41
|
+
* provider's real API id. styleReplacement() derives both styles from it.
|
|
28
42
|
*/
|
|
29
43
|
export function terminalReplacement(startSlug, getModel, isCurrent) {
|
|
30
44
|
let cur = startSlug;
|
|
@@ -35,7 +49,14 @@ export function terminalReplacement(startSlug, getModel, isCurrent) {
|
|
|
35
49
|
if (!m || isCurrent(m) || !m.replacement_slug) break;
|
|
36
50
|
cur = m.replacement_slug;
|
|
37
51
|
}
|
|
38
|
-
|
|
52
|
+
const slug = cur || startSlug;
|
|
53
|
+
const m = getModel(slug);
|
|
54
|
+
if (m?.canonical_id) {
|
|
55
|
+
const provider = m.provider_slug
|
|
56
|
+
|| (typeof m.slug === "string" && m.slug.includes("/") ? m.slug.slice(0, m.slug.indexOf("/")) : "");
|
|
57
|
+
return provider ? `${provider}/${m.canonical_id}` : m.canonical_id;
|
|
58
|
+
}
|
|
59
|
+
return slug; // no canonical known — the slug is the best answer we have
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
/* ------------------------------------------------------------ fix history */
|
|
@@ -73,43 +94,94 @@ export function readFixes() {
|
|
|
73
94
|
}
|
|
74
95
|
}
|
|
75
96
|
|
|
76
|
-
/** The string to write into the file
|
|
77
|
-
*
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
/** The string to write into the file, styled to match how the old id was
|
|
98
|
+
* written (provider-prefixed vs bare). `replacement` is terminalReplacement's
|
|
99
|
+
* write-form ("provider/canonical_id", e.g. "openai/gpt-5.5") — bare originals
|
|
100
|
+
* get the canonical id after the first "/", prefixed originals the whole thing.
|
|
101
|
+
* (A plain registry slug still styles correctly, just without canonical ids.) */
|
|
102
|
+
export function styleReplacement(oldStr, replacement) {
|
|
103
|
+
if (!replacement) return null;
|
|
104
|
+
const i = replacement.indexOf("/");
|
|
105
|
+
const bare = i >= 0 ? replacement.slice(i + 1) : replacement;
|
|
106
|
+
return oldStr.includes("/") ? replacement : bare;
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
*
|
|
109
|
+
/** Boundary-safe replace of every occurrence of `from` in one line of text,
|
|
110
|
+
* with the SAME boundary semantics (and case-insensitivity) as detection —
|
|
111
|
+
* whatever the scan matched, this can find. Occurrences whose right context is
|
|
112
|
+
* a version pin of the OLD model ("@20240229", "-v1:0", ":acme", "-${ver}")
|
|
113
|
+
* are never base-swapped (the old pin glued to the new id is a nonexistent
|
|
114
|
+
* id); they're returned in `pinned` so callers can report them honestly.
|
|
115
|
+
* Returns { out, n, pinned } — the rewritten line, how many occurrences
|
|
116
|
+
* changed, and the pinned occurrences (with a bit of right context). */
|
|
90
117
|
export function replaceOnLine(lineText, from, to) {
|
|
91
|
-
const
|
|
118
|
+
const lower = lineText.toLowerCase();
|
|
119
|
+
const f = String(from ?? "").toLowerCase();
|
|
120
|
+
let out = "";
|
|
121
|
+
let last = 0;
|
|
92
122
|
let n = 0;
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
123
|
+
const pinned = [];
|
|
124
|
+
let at = 0;
|
|
125
|
+
while (f && (at = lower.indexOf(f, at)) >= 0) {
|
|
126
|
+
const before = at > 0 ? lower[at - 1] : "";
|
|
127
|
+
if (before && isTokenChar(before) && !isPrefixSep(before)) {
|
|
128
|
+
at += 1; // inside a longer identifier — a later occurrence may be clean
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const rest = lower.slice(at + f.length);
|
|
132
|
+
const after = rest[0] ?? "";
|
|
133
|
+
// Trailing separators the generic detector trims ("claude-3-5-sonnet-${ver}",
|
|
134
|
+
// "gpt-4."): a [-._]+ run NOT followed by more token chars.
|
|
135
|
+
const trimmedTail = rest.replace(/^[-._]+/, "");
|
|
136
|
+
const isPinned =
|
|
137
|
+
(after === "@" && /^@[a-z0-9]/.test(rest)) || // Vertex-style @-pin
|
|
138
|
+
(isTokenChar(after) && MODEL_SUFFIX.test(rest)) || // ':0' / '-v1' / '-20240229'
|
|
139
|
+
(/^[-._]/.test(rest) && !(trimmedTail && isTokenChar(trimmedTail[0])));
|
|
140
|
+
if (isPinned) {
|
|
141
|
+
const ctx = /^[^\s"'`]{0,16}/.exec(lineText.slice(at + f.length))?.[0] ?? "";
|
|
142
|
+
pinned.push(lineText.slice(at, at + f.length) + ctx);
|
|
143
|
+
at += f.length;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (after === "" || !isTokenChar(after)) {
|
|
147
|
+
out += lineText.slice(last, at) + to;
|
|
148
|
+
last = at + f.length;
|
|
149
|
+
n += 1;
|
|
150
|
+
at += f.length;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
at += 1; // embedded in a longer id (gpt-4 in gpt-4o) — keep looking
|
|
154
|
+
}
|
|
155
|
+
return { out: out + lineText.slice(last), n, pinned };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The skip reason for a line where nothing was rewritten. */
|
|
159
|
+
function skipError(pinned) {
|
|
160
|
+
return pinned.length
|
|
161
|
+
? `pinned variant "${pinned[0]}" — review manually`
|
|
162
|
+
: "string not on that line anymore — rescan";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Decode a buffer as UTF-8 only if it round-trips byte-for-byte — otherwise
|
|
166
|
+
* null (writing a lossy decode back would corrupt the file: 0xE9 → U+FFFD). */
|
|
167
|
+
function decodeUtf8Strict(buf) {
|
|
168
|
+
const text = buf.toString("utf8");
|
|
169
|
+
return Buffer.from(text, "utf8").equals(buf) ? text : null;
|
|
98
170
|
}
|
|
99
171
|
|
|
100
172
|
/**
|
|
101
173
|
* Build fix plans from scan refs. `refs` = [{ model_string, source_path,
|
|
102
|
-
* source_line }] (the shape the scanner emits); `
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* filesystem refs are rewritable.
|
|
174
|
+
* source_line }] (the shape the scanner emits); `replacement` is
|
|
175
|
+
* terminalReplacement's write-form ("provider/canonical_id") for the model
|
|
176
|
+
* those refs belong to. Refs without a real file path (integration scheme
|
|
177
|
+
* labels like vercel://) are skipped — only filesystem refs are rewritable.
|
|
106
178
|
*/
|
|
107
|
-
export function planFixes(refs,
|
|
179
|
+
export function planFixes(refs, replacement) {
|
|
108
180
|
const plans = [];
|
|
109
181
|
const seen = new Set();
|
|
110
182
|
for (const r of refs || []) {
|
|
111
183
|
if (!r?.source_path || !r.source_line || !r.model_string) continue;
|
|
112
|
-
const to = styleReplacement(r.model_string,
|
|
184
|
+
const to = styleReplacement(r.model_string, replacement);
|
|
113
185
|
if (!to || to === r.model_string) continue;
|
|
114
186
|
const key = `${r.source_path}:${r.source_line}:${r.model_string}`;
|
|
115
187
|
if (seen.has(key)) continue;
|
|
@@ -133,24 +205,29 @@ export function planFixes(refs, replacementSlug) {
|
|
|
133
205
|
export function previewFixes(dir, plans) {
|
|
134
206
|
const previews = [];
|
|
135
207
|
const stale = [];
|
|
136
|
-
const cache = new Map(); // file -> lines
|
|
208
|
+
const cache = new Map(); // file -> { lines } | { error } | {}
|
|
137
209
|
for (const p of plans) {
|
|
138
210
|
if (!cache.has(p.file)) {
|
|
211
|
+
let entry = {}; // unreadable → "file or line missing" below
|
|
139
212
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
213
|
+
const text = decodeUtf8Strict(fs.readFileSync(path.resolve(dir, p.file)));
|
|
214
|
+
entry = text == null ? { error: "not valid UTF-8 — skipped" } : { lines: text.split(/\r?\n/) };
|
|
215
|
+
} catch { /* keep {} */ }
|
|
216
|
+
cache.set(p.file, entry);
|
|
144
217
|
}
|
|
145
|
-
const
|
|
146
|
-
|
|
218
|
+
const ent = cache.get(p.file);
|
|
219
|
+
if (ent.error) {
|
|
220
|
+
stale.push({ ...p, error: ent.error });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const before = ent.lines?.[p.line - 1];
|
|
147
224
|
if (before == null) {
|
|
148
225
|
stale.push({ ...p, error: "file or line missing — rescan" });
|
|
149
226
|
continue;
|
|
150
227
|
}
|
|
151
|
-
const { out, n } = replaceOnLine(before, p.from, p.to);
|
|
228
|
+
const { out, n, pinned } = replaceOnLine(before, p.from, p.to);
|
|
152
229
|
if (n === 0) {
|
|
153
|
-
stale.push({ ...p, error:
|
|
230
|
+
stale.push({ ...p, error: skipError(pinned) });
|
|
154
231
|
continue;
|
|
155
232
|
}
|
|
156
233
|
previews.push({ ...p, before, after: out });
|
|
@@ -177,13 +254,20 @@ export function applyFixes(dir, plans) {
|
|
|
177
254
|
const abs = path.resolve(dir, file);
|
|
178
255
|
let text;
|
|
179
256
|
try {
|
|
180
|
-
text = fs.readFileSync(abs
|
|
257
|
+
text = decodeUtf8Strict(fs.readFileSync(abs));
|
|
258
|
+
if (text == null) {
|
|
259
|
+
// A lossy decode written back would corrupt every non-UTF-8 byte in
|
|
260
|
+
// the file (0xE9 → U+FFFD) — refuse loudly instead.
|
|
261
|
+
for (const p of filePlans) failed.push({ ...p, error: "not valid UTF-8 — skipped" });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
181
264
|
} catch (e) {
|
|
182
265
|
for (const p of filePlans) failed.push({ ...p, error: e.code === "ENOENT" ? "file not found" : e.message });
|
|
183
266
|
continue;
|
|
184
267
|
}
|
|
185
|
-
|
|
186
|
-
|
|
268
|
+
// Split KEEPING each line's own terminator so untouched lines round-trip
|
|
269
|
+
// byte-identical — one stray CRLF line must never convert the whole file.
|
|
270
|
+
const lines = text.split(/(?<=\n)/);
|
|
187
271
|
let dirty = false;
|
|
188
272
|
for (const p of filePlans) {
|
|
189
273
|
const idx = p.line - 1;
|
|
@@ -191,19 +275,21 @@ export function applyFixes(dir, plans) {
|
|
|
191
275
|
stale.push({ ...p, error: "line out of range — rescan" });
|
|
192
276
|
continue;
|
|
193
277
|
}
|
|
194
|
-
const
|
|
195
|
-
const
|
|
278
|
+
const raw = lines[idx];
|
|
279
|
+
const nl = /\r?\n$/.exec(raw)?.[0] ?? "";
|
|
280
|
+
const body = nl ? raw.slice(0, -nl.length) : raw;
|
|
281
|
+
const { out, n, pinned } = replaceOnLine(body, p.from, p.to);
|
|
196
282
|
if (n === 0) {
|
|
197
|
-
stale.push({ ...p, error:
|
|
283
|
+
stale.push({ ...p, error: skipError(pinned) });
|
|
198
284
|
continue;
|
|
199
285
|
}
|
|
200
|
-
lines[idx] = out;
|
|
286
|
+
lines[idx] = out + nl;
|
|
201
287
|
dirty = true;
|
|
202
|
-
applied.push({ ...p, count: n, before, after: out });
|
|
288
|
+
applied.push({ ...p, count: n, before: body, after: out });
|
|
203
289
|
}
|
|
204
290
|
if (dirty) {
|
|
205
291
|
try {
|
|
206
|
-
fs.writeFileSync(abs, lines.join(
|
|
292
|
+
fs.writeFileSync(abs, lines.join(""));
|
|
207
293
|
} catch (e) {
|
|
208
294
|
// Roll the bookkeeping back: everything in this file actually failed.
|
|
209
295
|
for (let i = applied.length - 1; i >= 0; i--) {
|