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.
@@ -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) {