sprag-cli 3.45.0 → 3.46.1
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/README.ko.md +1 -1
- package/README.md +2 -2
- package/bin/cli.js +6 -1
- package/examples/statusline-command.ps1 +11 -5
- package/examples/statusline-command.sh +9 -4
- package/package.json +5 -3
- package/src/changelog.js +70 -0
- package/src/cli-name.js +25 -0
- package/src/commands/install.js +1 -0
- package/src/commands/route-scan.js +66 -7
- package/src/commands/uninstall.js +5 -4
- package/src/formatters/statusline.js +72 -67
- package/src/formatters/table.js +5 -4
- package/src/glyphs.js +17 -0
- package/src/hook-manager.js +3 -2
- package/src/installer.js +132 -16
- package/src/litellm-budget.js +16 -2
- package/src/route-inject.js +10 -2
- package/src/route-scan.js +24 -1
- package/src/seed-rules.js +29 -16
- package/src/update-check.js +178 -1
package/src/update-check.js
CHANGED
|
@@ -10,7 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* State lives next to the other user-data files:
|
|
12
12
|
* { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
|
|
13
|
-
* dismissedVersion: "3.25.0"|undefined
|
|
13
|
+
* dismissedVersion: "3.25.0"|undefined,
|
|
14
|
+
* highlights: ["…", "…"]|undefined, highlightsFor: "3.25.0"|undefined }
|
|
15
|
+
*
|
|
16
|
+
* `highlights` is what the new version actually adds, read from the GitHub
|
|
17
|
+
* release for that tag. A bare "v3.24 → v3.25 is available" gives the user
|
|
18
|
+
* nothing to weigh: the answer to "should I upgrade" is in what changed, and
|
|
19
|
+
* asking them to go find that themselves is how an upgrade notice becomes
|
|
20
|
+
* something to dismiss. The release body is fetched on the same detached,
|
|
21
|
+
* once-a-day pass as the version check, and its absence never blocks the
|
|
22
|
+
* notice — a version number alone is still worth saying.
|
|
14
23
|
*
|
|
15
24
|
* Opt out with CTS_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
|
|
16
25
|
* standard env var — anyone who set it for other CLIs meant us too).
|
|
@@ -102,6 +111,11 @@ export function updateStatus(currentVersion) {
|
|
|
102
111
|
// means "ask me again in five minutes", forever.
|
|
103
112
|
dismissed: available && s.dismissedVersion === latest,
|
|
104
113
|
stale: age >= CHECK_INTERVAL_MS,
|
|
114
|
+
// Only when they describe THIS version. A cache written for an earlier
|
|
115
|
+
// release would otherwise sell the wrong upgrade. Language is not checked
|
|
116
|
+
// here: a mismatch is corrected by the next refresh, and showing the other
|
|
117
|
+
// language in the meantime beats showing nothing.
|
|
118
|
+
highlights: Array.isArray(s.highlights) && s.highlightsFor === latest ? s.highlights : [],
|
|
105
119
|
};
|
|
106
120
|
}
|
|
107
121
|
|
|
@@ -143,6 +157,139 @@ export function cliEntryPath() {
|
|
|
143
157
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
// Release notes come from the repository's own releases, because the registry
|
|
161
|
+
// does not carry them: `/latest` answers with package metadata and no changelog
|
|
162
|
+
// of any kind. Unauthenticated reads are allowed at 60/hour, and this runs once
|
|
163
|
+
// a day behind the same interval as the version check, so the budget is ample.
|
|
164
|
+
const RELEASES_API = 'https://api.github.com/repos/rootstudioyaml/sprag/releases/tags';
|
|
165
|
+
const MAX_HIGHLIGHTS = 3;
|
|
166
|
+
const MAX_HIGHLIGHT_LEN = 110;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A release body may carry both languages, English first and Korean under a
|
|
170
|
+
* heading of its own. Read only the half that matches the reader: mixing them
|
|
171
|
+
* would put the same change on two of the three lines the notice has room for,
|
|
172
|
+
* and showing English to someone who set `lang=ko` wastes the notice entirely.
|
|
173
|
+
*
|
|
174
|
+
* The Korean half starts at a heading whose text is Korean; everything before
|
|
175
|
+
* that first Korean heading is the English half. A body in one language only has
|
|
176
|
+
* no such heading, so English readers get all of it and Korean readers fall back
|
|
177
|
+
* to the same text rather than to nothing.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} body
|
|
180
|
+
* @param {string} lang - 'ko' | 'en'
|
|
181
|
+
* @returns {string} the half to read
|
|
182
|
+
*/
|
|
183
|
+
function sectionForLang(body, lang) {
|
|
184
|
+
const lines = body.split(/\r?\n/);
|
|
185
|
+
const at = lines.findIndex((l) => /^#{1,6}\s/.test(l) && /[가-힣]/.test(l));
|
|
186
|
+
if (at < 0) return body;
|
|
187
|
+
return lang === 'ko' ? lines.slice(at + 1).join('\n') : lines.slice(0, at).join('\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The headline items of a release body, as short single lines.
|
|
192
|
+
*
|
|
193
|
+
* Release notes are written for people reading a web page: headings, prose
|
|
194
|
+
* paragraphs, links, and bold runs. A session-start notice has one line per
|
|
195
|
+
* item and no room for any of that, so this keeps only what reads as an item
|
|
196
|
+
* of a list and strips it to plain text. Anything that survives is a sentence
|
|
197
|
+
* the user can judge an upgrade by; anything that does not is left behind
|
|
198
|
+
* rather than truncated into nonsense.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} body - the release body as published
|
|
201
|
+
* @param {string} [lang] - 'ko' | 'en'; picks the half of a bilingual body
|
|
202
|
+
* @returns {string[]} at most MAX_HIGHLIGHTS lines, or [] when nothing fits
|
|
203
|
+
*/
|
|
204
|
+
export function releaseHighlights(body, lang = 'en') {
|
|
205
|
+
const text = sectionForLang(String(body || ''), lang);
|
|
206
|
+
if (!text.trim()) return [];
|
|
207
|
+
const out = [];
|
|
208
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
209
|
+
const line = raw.trim();
|
|
210
|
+
// Bullets only. A heading names a section, and a prose paragraph in a
|
|
211
|
+
// release body is usually the rationale rather than the change itself.
|
|
212
|
+
const m = /^[-*+]\s+(.*)$/.exec(line);
|
|
213
|
+
if (!m) continue;
|
|
214
|
+
// Markup goes first, so the sentence split below sees the text a reader
|
|
215
|
+
// would: `**Bash writes are checked.**` is one sentence, not a bold run.
|
|
216
|
+
// One exception has to be caught before that: a stop followed straight by a
|
|
217
|
+
// code span (`…checked.\`install\` migrates…`) is a sentence boundary, and
|
|
218
|
+
// stripping the backtick first leaves a lowercase letter the split ignores.
|
|
219
|
+
// A space there is what the author meant, and it costs nothing if not.
|
|
220
|
+
let item = m[1]
|
|
221
|
+
.replace(/([.。])(`)/g, '$1 $2')
|
|
222
|
+
.replace(/`([^`]*)`/g, '$1') // code spans read fine as plain text
|
|
223
|
+
.replace(/\*\*([^*]*)\*\*/g, '$1') // bold
|
|
224
|
+
.replace(/\*([^*]*)\*/g, '$1') // italics
|
|
225
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links keep their label
|
|
226
|
+
.replace(/\s+/g, ' ')
|
|
227
|
+
.trim();
|
|
228
|
+
if (!item) continue;
|
|
229
|
+
// Release notes lead with the change and follow with the reasoning, often
|
|
230
|
+
// for several sentences. The first sentence is the change, so that is what
|
|
231
|
+
// a one-line notice keeps — cutting at a character count instead lands
|
|
232
|
+
// mid-clause and reads as a broken string rather than a short one.
|
|
233
|
+
//
|
|
234
|
+
// The break does not require a space after the stop: "…checked.The matcher…"
|
|
235
|
+
// is a typo a release body can carry, and requiring one there merged the two
|
|
236
|
+
// sentences into a single over-long line. A capital letter or a Hangul
|
|
237
|
+
// syllable directly after the stop is enough to say a new sentence started;
|
|
238
|
+
// a digit is not, so "0.5s" and "v3.45.0" stay intact.
|
|
239
|
+
//
|
|
240
|
+
// Abbreviations are excluded by name. "Fixed e.g. the matcher" would
|
|
241
|
+
// otherwise end after two words, which is worse than a long line: it reads
|
|
242
|
+
// as a complete thought and is not one. This list covers what actually turns
|
|
243
|
+
// up in release prose rather than trying to be exhaustive.
|
|
244
|
+
// `etc.` is deliberately absent: it ends a sentence as often as it continues
|
|
245
|
+
// one, so either choice is wrong half the time and the wrong one here merges
|
|
246
|
+
// two sentences rather than truncating one. Merging is the milder failure,
|
|
247
|
+
// and the length cap below bounds it.
|
|
248
|
+
const ABBREV = /\b(?:e\.g|i\.e|vs|cf|Dr|Mr|Ms|St|approx|no)\.$/i;
|
|
249
|
+
const parts = item.split(/(?<=[.。])(?:\s+|(?=[A-Z가-힣]))/);
|
|
250
|
+
let first = parts[0];
|
|
251
|
+
for (let k = 1; k < parts.length && ABBREV.test(first); k += 1) {
|
|
252
|
+
// The stop belonged to an abbreviation, so the sentence continues.
|
|
253
|
+
first = `${first} ${parts[k]}`;
|
|
254
|
+
}
|
|
255
|
+
item = first || item;
|
|
256
|
+
// Some first sentences are themselves a paragraph. Those get cut, but at a
|
|
257
|
+
// word boundary and with the ellipsis that says so.
|
|
258
|
+
if (item.length > MAX_HIGHLIGHT_LEN) {
|
|
259
|
+
const cut = item.slice(0, MAX_HIGHLIGHT_LEN);
|
|
260
|
+
const space = cut.lastIndexOf(' ');
|
|
261
|
+
item = (space > 40 ? cut.slice(0, space) : cut).replace(/[,;:—\-]$/, '').trim() + '…';
|
|
262
|
+
}
|
|
263
|
+
out.push(item);
|
|
264
|
+
if (out.length >= MAX_HIGHLIGHTS) break;
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Fetch the highlights for one version. Returns [] on any failure: the notice
|
|
271
|
+
* is worth showing with a bare version number, so a missing release, a rate
|
|
272
|
+
* limit, or an offline machine must not cost the user the notice itself.
|
|
273
|
+
*/
|
|
274
|
+
export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS, lang = 'en' } = {}) {
|
|
275
|
+
const controller = new AbortController();
|
|
276
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
277
|
+
try {
|
|
278
|
+
const res = await fetch(`${RELEASES_API}/v${version}`, {
|
|
279
|
+
signal: controller.signal,
|
|
280
|
+
headers: { accept: 'application/vnd.github+json' },
|
|
281
|
+
});
|
|
282
|
+
if (!res.ok) throw new Error(`releases responded ${res.status}`);
|
|
283
|
+
const body = await res.json();
|
|
284
|
+
return releaseHighlights(body && body.body, lang);
|
|
285
|
+
} catch (e) {
|
|
286
|
+
debug('update-check:highlights', e);
|
|
287
|
+
return [];
|
|
288
|
+
} finally {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
146
293
|
/**
|
|
147
294
|
* Actually hit the registry and persist the answer. Only the detached child
|
|
148
295
|
* and the explicit `update-check --refresh` command call this.
|
|
@@ -168,6 +315,36 @@ export async function refreshUpdateState(currentVersion) {
|
|
|
168
315
|
if (prev.dismissedVersion && isNewer(latest, prev.dismissedVersion)) {
|
|
169
316
|
delete next.dismissedVersion;
|
|
170
317
|
}
|
|
318
|
+
// What the new version adds, so the notice can answer "why would I".
|
|
319
|
+
// Only worth a second request when there is actually an upgrade to describe,
|
|
320
|
+
// and only when we do not already hold the notes for that exact version.
|
|
321
|
+
// The reader's own language: a release body carries both halves, and the
|
|
322
|
+
// notice has room for three lines in one of them.
|
|
323
|
+
let lang = 'en';
|
|
324
|
+
try {
|
|
325
|
+
const { userLanguage } = await import('./config.js');
|
|
326
|
+
lang = userLanguage();
|
|
327
|
+
} catch (e) { debug('update-check:lang', e); }
|
|
328
|
+
// Fetch when the version is new, and also when the reader switched language
|
|
329
|
+
// since these notes were cached — otherwise they keep reading the half they
|
|
330
|
+
// left behind.
|
|
331
|
+
const staleLang = prev.highlightsFor === latest && prev.highlightsLang !== lang;
|
|
332
|
+
if (isNewer(latest, currentVersion) && (prev.highlightsFor !== latest || staleLang)) {
|
|
333
|
+
const highlights = await fetchHighlights(latest, { lang });
|
|
334
|
+
if (highlights.length) {
|
|
335
|
+
next.highlights = highlights;
|
|
336
|
+
next.highlightsFor = latest;
|
|
337
|
+
// Which language these are in. Someone who switches `lang` afterwards
|
|
338
|
+
// would otherwise keep reading the notes in the language they left.
|
|
339
|
+
next.highlightsLang = lang;
|
|
340
|
+
} else {
|
|
341
|
+
// Drop notes belonging to an older version rather than showing them
|
|
342
|
+
// against this one.
|
|
343
|
+
delete next.highlights;
|
|
344
|
+
delete next.highlightsFor;
|
|
345
|
+
delete next.highlightsLang;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
171
348
|
writeUpdateState(next);
|
|
172
349
|
return { ok: true, latest };
|
|
173
350
|
} catch (e) {
|