veodl 1.11.0 → 1.12.0

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.md CHANGED
@@ -586,6 +586,10 @@ veo doctor --offline # skip the network checks
586
586
  veo stats # persistent download totals; --json for scripting
587
587
  veo history # the last 5 downloads; --json for scripting
588
588
  veo history --failed --limit 20 # recent failed/cancelled attempts
589
+ veo changes # released versions, newest first; the installed one is marked
590
+ veo changes 1.10.1 # notes of one release (also: --version 1.10.1)
591
+ veo changes --since 1.10.1 # everything newer than a version
592
+ veo changes --latest --json # newest release as JSON
589
593
  veo retry --last # retry the newest failed or unfinished job
590
594
  veo runs # active runs with their id; veo runs <id> for details
591
595
  veo runs --json # machine-readable active run metadata
@@ -689,6 +693,33 @@ directory keeps one file per attempt and contains URLs, titles and local file pa
689
693
  delete it to remove those records. `veo flush` keeps the list (only `veo flush --stats`
690
694
  resets statistics, not history).
691
695
 
696
+ ### `veo changes`
697
+
698
+ Lists every **released version**, newest first, and marks the installed one. `veo changelog`
699
+ is an alias. The notes are parsed from the `CHANGELOG.md` that ships inside the package, so
700
+ they always belong to the installed code and the command needs no network access.
701
+
702
+ ```bash
703
+ veo changes # all versions with their Added/Changed/Fixed counts
704
+ veo changes 1.10.1 # the notes of one release
705
+ veo changes --version 1.10.1 # the same, spelled out
706
+ veo changes 1.10 # newest release starting with 1.10
707
+ veo changes --latest # only the newest release
708
+ veo changes --since 1.10.0 # everything newer than 1.10.0, up to the newest release
709
+ veo changes --range 1.9.0..1.11.0 # a closed range, newest first
710
+ veo changes --json # machine-readable
711
+ ```
712
+
713
+ An unknown version fails with the known versions listed. Long entries wrap to the terminal
714
+ width, and `--json` returns the complete text. Because the list already names the released
715
+ versions, `veo changes` prints no automatic update hint.
716
+
717
+ `veo changes --json` prints `{"current":…,"count":N,"versions":[…]}`, where each entry has
718
+ `version`, `date` (currently `null`, since the changelog headings carry no date), `installed`,
719
+ `counts` and `summary`. One version adds `"version"`, `"installed"` and `"sections"`; a range
720
+ adds `"from"` and `"to"` and keeps the per-release `sections`. Sections are
721
+ `{"type":"Added","items":[{"text":"…","items":[…]}]}`, and nested items are preserved.
722
+
692
723
  ### `veo runs` and `veo stop`
693
724
 
694
725
  Every run registers itself while it works, under a **6-character id**, and removes that
@@ -66,6 +66,28 @@ veo retry --last --json
66
66
 
67
67
  `runs` reports **active** runs, including their ID, state, options, job path and per-item progress. Completed runs disappear from this list. Download JSON and `history` retain the `runId`. `history` reports finished attempts with their outcome and saved file paths. Use `veo --retry-failed <runId>` to retry a failed or unfinished run; a saved `job` path is also accepted as `veo --retry-failed "<job-path>"`. The original retry job must still be present, and retrying an active run by ID is rejected. `veo retry --last` finds the newest retryable job automatically. Running jobs are excluded from `retry --last`.
68
68
 
69
+ ## Release notes
70
+
71
+ ```sh
72
+ veo changes --json
73
+ veo changes 1.10.1 --json
74
+ veo changes --since 1.9.0 --json
75
+ ```
76
+
77
+ `veo changes` (alias `veo changelog`) parses the `CHANGELOG.md` shipped inside the installed
78
+ package, so it never touches the network and never prints an update hint. Bare, it lists every
79
+ released version, newest first, with the installed one marked as `installed`; a version argument
80
+ (or `--version`, also a partial version such as `1.10`) selects one release, `--latest` the newest,
81
+ and `--since <version>` with `--to <version>` or `--range <from>..<to>` everything between two
82
+ versions, newest first. An unknown version exits nonzero and lists the known ones.
83
+
84
+ The list form returns `{"current":…,"count":N,"versions":[{"version","date","installed","counts","summary"}]}`.
85
+ One version returns `{"current","version","date","installed","sections"}`, a range
86
+ `{"current","from","to","count","versions":[…]}`. `sections` is
87
+ `[{"type":"Added","items":[{"text":"…","items":[…]}]}]`; nested items are preserved and the text
88
+ is complete, so do not re-wrap it yourself. `date` is currently `null` because the changelog
89
+ headings carry no release date.
90
+
69
91
  ## Inspect saved media and audio
70
92
 
71
93
  `veo subs <URL> --json` lists manual and automatic subtitle languages and formats without saving media. Select manual subtitles with `--subs --sub-langs de,en`, generated subtitles with `--auto-subs --sub-langs de,en`, and prefer a source format with `--sub-format srt/vtt/best`. `--verify` optionally probes saved media files before recording success; it is off by default and does not decode the full track.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veodl",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "A clean, simple video downloader powered by yt-dlp.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/changes.js ADDED
@@ -0,0 +1,278 @@
1
+ import { commandOutput } from './output.js';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { terminalColumns } from './terminal-size.js';
4
+ import { wrap } from './progress.js';
5
+ import { compareVersions } from './version.js';
6
+ import { packageVersion } from './updater.js';
7
+ import { suggestOption } from './option-suggestions.js';
8
+ import { cleanText } from './utils.js';
9
+
10
+ /**
11
+ * `veo changes` reads the CHANGELOG.md that ships inside this package, so the
12
+ * notes always belong to the installed code and work without network access. The
13
+ * file is parsed into data instead of being printed: sections, bullets, nested
14
+ * bullets and wrapped lines survive, and `--json` returns the same structure.
15
+ */
16
+ const RELEASE = /^##\s+v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)(?:\s+[-–—]\s+(\d{4}-\d{2}-\d{2}))?\s*$/;
17
+ const SECTION = /^###\s+(.+?)\s*$/;
18
+ const BULLET = /^(\s*)-\s+(.*)$/;
19
+ const VERSION = /^\d+(\.\d+)*$/;
20
+ const MARKER = '(installed)';
21
+ const SHOWN = 5;
22
+ const USAGE = 'Usage: veo changes [<version>] [--json] [--latest] [--since <version>] [--to <version>] [--range <from>..<to>]';
23
+
24
+ export const CHANGES_HELP = `veo changes [<version>] [--json] [--latest] [--since <version>]
25
+ [--to <version>] [--range <from>..<to>]
26
+
27
+ List every released version, newest first, and mark the installed one. With a version
28
+ (or --version) show that release's notes; a partial version such as 1.10 selects the
29
+ newest match. --since shows everything newer than a version, --to and --range set both
30
+ ends of a range, and --latest shows only the newest release. Notes come from the
31
+ CHANGELOG.md shipped with this veo version, so the command needs no network access and
32
+ prints no automatic update hint. Use --json for scripting.
33
+ `;
34
+
35
+ /**
36
+ * Keep a Changelog style document becomes releases, newest first. A heading of
37
+ * second level that is not a version ends the notes, so an appended file list or
38
+ * a second document cannot leak into the last release.
39
+ */
40
+ export function parseChangelog(markdown) {
41
+ const releases = [];
42
+ let release = null;
43
+ let section = null;
44
+ let item = null; // Current top-level bullet and parent of any nested ones.
45
+ let last = null; // Most recent bullet, which a wrapped line continues.
46
+ for (const raw of String(markdown).split(/\r?\n/)) {
47
+ const line = raw.replace(/\s+$/, '');
48
+ if (!line.trim()) { last = null; continue; }
49
+ const heading = RELEASE.exec(line);
50
+ if (heading) {
51
+ release = { version: heading[1], date: heading[2] || null, sections: [] };
52
+ releases.push(release);
53
+ section = null; item = null; last = null;
54
+ continue;
55
+ }
56
+ // A second-level heading that is not a version ends the notes; the document
57
+ // title above the first release is preamble and ignored.
58
+ if (/^##\s/.test(line)) break;
59
+ if (!release) continue;
60
+ const type = SECTION.exec(line);
61
+ if (type) {
62
+ section = { type: cleanText(type[1]), items: [] };
63
+ release.sections.push(section);
64
+ item = null; last = null;
65
+ continue;
66
+ }
67
+ if (!section) continue;
68
+ const bullet = BULLET.exec(line);
69
+ if (bullet) {
70
+ const text = cleanText(bullet[2]);
71
+ if (!text) continue;
72
+ if (!bullet[1].length || !item) {
73
+ item = { text, items: [] };
74
+ section.items.push(item);
75
+ last = item;
76
+ } else {
77
+ last = { text, items: [] };
78
+ item.items.push(last);
79
+ }
80
+ continue;
81
+ }
82
+ // Without a bullet the line continues the previous one; the changelog wraps prose.
83
+ if (last) last.text = cleanText(`${last.text} ${line.trim()}`);
84
+ }
85
+ return releases.sort((left, right) => compareVersions(right.version, left.version));
86
+ }
87
+
88
+ /** Entry counts per section type, in the order the changelog lists them. */
89
+ export function releaseCounts(release) {
90
+ return Object.fromEntries(release.sections.filter(section => section.items.length)
91
+ .map(section => [section.type, section.items.length]));
92
+ }
93
+
94
+ const summary = counts => Object.entries(counts).map(([type, count]) => `${type} ${count}`).join(', ');
95
+
96
+ /** Exact match first, then the newest release starting with the given prefix. */
97
+ export function resolveVersion(releases, requested, label = 'version') {
98
+ const wanted = String(requested ?? '').trim().replace(/^v/i, '');
99
+ if (!VERSION.test(wanted)) throw new Error(`${label} must be a version number such as 1.11.0.`);
100
+ const exact = releases.find(release => release.version === wanted);
101
+ if (exact) return exact;
102
+ const partial = releases.filter(release => release.version.startsWith(`${wanted}.`) || release.version.startsWith(`${wanted}-`));
103
+ if (partial.length) return partial.reduce((best, release) => compareVersions(release.version, best.version) > 0 ? release : best);
104
+ const known = releases.map(release => release.version);
105
+ if (!known.length) throw new Error(`${label} "${cleanText(requested)}" cannot be resolved: the changelog lists no releases.`);
106
+ const guess = suggestOption(wanted, known);
107
+ const list = known.slice(0, SHOWN).join(', ');
108
+ throw new Error(`Unknown ${label} "${cleanText(requested)}".${guess ? ` Did you mean "${guess}"?` : ''} Known versions: ${list}${known.length > SHOWN ? ', …' : ''}.`);
109
+ }
110
+
111
+ /** Which releases a request selects, and under which heading they are shown. */
112
+ export function selectReleases(releases, { version = null, latest = false, since = null, to = null } = {}) {
113
+ const sorted = [...releases].sort((left, right) => compareVersions(right.version, left.version));
114
+ const newest = sorted[0]?.version ?? null;
115
+ if (version) {
116
+ const release = resolveVersion(sorted, version);
117
+ return { mode: 'version', from: release.version, to: release.version, versions: [release] };
118
+ }
119
+ if (latest) return { mode: 'latest', from: newest, to: newest, versions: sorted.slice(0, 1) };
120
+ if (since || to) {
121
+ const from = since ? resolveVersion(sorted, since, '--since').version : null;
122
+ const upper = to ? resolveVersion(sorted, to, '--to').version : null;
123
+ if (from && upper && compareVersions(upper, from) < 0) throw new Error(`--to ${upper} is older than --since ${from}.`);
124
+ const versions = sorted.filter(release => (!from || compareVersions(release.version, from) > 0)
125
+ && (!upper || compareVersions(release.version, upper) <= 0));
126
+ if (!versions.length) {
127
+ if (from && !upper) throw new Error(`This veo is already at ${from}; no newer changes are listed.`);
128
+ throw new Error(`No changes listed between ${from ?? 'the first release'} and ${upper ?? newest}.`);
129
+ }
130
+ return { mode: 'range', from, to: upper ?? newest, versions };
131
+ }
132
+ return { mode: 'list', from: null, to: null, versions: sorted };
133
+ }
134
+
135
+ /** The shipped changelog, or a clear error when an installation lost the file. */
136
+ export async function readChangelog(file = new URL('../CHANGELOG.md', import.meta.url)) {
137
+ let markdown;
138
+ try {
139
+ markdown = await readFile(file, 'utf8');
140
+ } catch (error) {
141
+ if (error.code === 'ENOENT' || error.code === 'EISDIR') throw new Error(`No CHANGELOG.md found at ${file}.`);
142
+ throw error;
143
+ }
144
+ return parseChangelog(markdown);
145
+ }
146
+
147
+ function bulletLines(entry, stream, depth) {
148
+ const prefix = `${' '.repeat(depth)}- `;
149
+ const hanging = ' '.repeat(prefix.length);
150
+ // Notes are wrapped instead of shortened: a release note that loses its tail
151
+ // because the terminal is narrow would be worse than useless.
152
+ const columns = stream?.isTTY ? Math.max(24, terminalColumns(stream) - prefix.length - 1) : Infinity;
153
+ return wrap(entry.text, columns).split('\n').map((line, index) => (index ? hanging : prefix) + line);
154
+ }
155
+
156
+ function releaseLines(release, stream, installed, header) {
157
+ const lines = [];
158
+ if (header) lines.push(release.version + (installed ? ` ${MARKER}` : ''));
159
+ for (const section of release.sections) {
160
+ if (!section.items.length) continue;
161
+ lines.push(section.type);
162
+ for (const entry of section.items) {
163
+ lines.push(...bulletLines(entry, stream, 1));
164
+ for (const nested of entry.items) lines.push(...bulletLines(nested, stream, 2));
165
+ }
166
+ lines.push('');
167
+ }
168
+ if (lines.at(-1) === '') lines.pop();
169
+ return lines;
170
+ }
171
+
172
+ const plural = count => `${count} version${count === 1 ? '' : 's'}`;
173
+
174
+ export function formatChanges(selection, { current, stream } = {}) {
175
+ const { mode, versions } = selection;
176
+ if (!versions.length) return 'veo changes\n\nNo release notes found in CHANGELOG.md.\n';
177
+ if (mode === 'list') {
178
+ const width = Math.max(...versions.map(release => release.version.length));
179
+ const lines = [`veo changes (${plural(versions.length)}, newest first)`, ''];
180
+ for (const release of versions) {
181
+ const installed = release.version === current ? MARKER : '';
182
+ const row = ` ${release.version.padEnd(width)} ${installed.padEnd(MARKER.length)}`;
183
+ const counts = summary(releaseCounts(release));
184
+ // A release without entries must not leave trailing spaces in the column.
185
+ lines.push(counts ? `${row} ${counts}` : row);
186
+ }
187
+ lines.push('');
188
+ lines.push('Use veo changes <version> for one version, veo changes --since <version> for');
189
+ lines.push('everything newer than a given version, or --json for scripting.');
190
+ return `${lines.join('\n')}\n`;
191
+ }
192
+ const title = mode === 'range'
193
+ ? `veo changes ${selection.from ? `${selection.from} to ${selection.to}` : `up to ${selection.to}`} (${plural(versions.length)}, newest first)`
194
+ : `veo changes ${selection.from}`;
195
+ const lines = [title, ''];
196
+ for (const release of versions) {
197
+ lines.push(...releaseLines(release, stream, release.version === current, mode === 'range'));
198
+ lines.push('');
199
+ }
200
+ lines.pop();
201
+ return `${lines.join('\n')}\n`;
202
+ }
203
+
204
+ const sectionsJson = release => release.sections.filter(section => section.items.length)
205
+ .map(section => ({ type: section.type, items: section.items }));
206
+
207
+ function changesJson(selection, current) {
208
+ if (selection.mode === 'list') {
209
+ return {
210
+ current,
211
+ count: selection.versions.length,
212
+ versions: selection.versions.map(release => {
213
+ const counts = releaseCounts(release);
214
+ return { version: release.version, date: release.date, installed: release.version === current, counts, summary: summary(counts) };
215
+ }),
216
+ };
217
+ }
218
+ if (selection.mode === 'range') {
219
+ return {
220
+ current,
221
+ from: selection.from,
222
+ to: selection.to,
223
+ count: selection.versions.length,
224
+ versions: selection.versions.map(release => ({
225
+ version: release.version, date: release.date, installed: release.version === current, sections: sectionsJson(release),
226
+ })),
227
+ };
228
+ }
229
+ const [release] = selection.versions;
230
+ return { current, version: release.version, date: release.date, installed: release.version === current, sections: sectionsJson(release) };
231
+ }
232
+
233
+ function versionValue(value, flag) {
234
+ if (!value || value.startsWith('-')) throw new Error(`${flag} requires a version number such as 1.11.0.`);
235
+ return value;
236
+ }
237
+
238
+ export function parseChangesArgs(args) {
239
+ const options = { json: false, latest: false, version: null, since: null, to: null };
240
+ for (let index = 0; index < args.length; index++) {
241
+ let arg = args[index];
242
+ let inline = null;
243
+ const equals = arg.indexOf('=');
244
+ if (arg.startsWith('--') && equals > 2) { inline = arg.slice(equals + 1); arg = arg.slice(0, equals); }
245
+ const value = () => versionValue(inline ?? args[++index], arg);
246
+ if (arg === '--json') options.json = true;
247
+ else if (arg === '--latest') options.latest = true;
248
+ else if (arg === '--version') options.version = value();
249
+ else if (arg === '--since') options.since = value();
250
+ else if (arg === '--to') options.to = value();
251
+ else if (arg === '--range') {
252
+ const [from, to] = value().split('..');
253
+ if (!from || !to) throw new Error(`--range must look like 1.9.0..1.11.0. ${USAGE}`);
254
+ options.since ??= from;
255
+ options.to ??= to;
256
+ } else if (arg.startsWith('-')) throw new Error(`Unknown option "${arg}". ${USAGE}`);
257
+ else if (!options.version) options.version = arg;
258
+ else throw new Error(USAGE);
259
+ }
260
+ const ranged = options.since || options.to;
261
+ if (options.version && (ranged || options.latest)) throw new Error(`Use either a version or --latest/--since/--to, not both. ${USAGE}`);
262
+ if (options.latest && ranged) throw new Error(`Use --latest on its own. ${USAGE}`);
263
+ return options;
264
+ }
265
+
266
+ export async function changesMain(args = [], { stdout = process.stdout, current = null, file } = {}) {
267
+ [args, stdout] = commandOutput(args, stdout);
268
+ if (args.some(arg => ['--help', '-h'].includes(arg))) {
269
+ stdout.write(CHANGES_HELP);
270
+ return 0;
271
+ }
272
+ const options = parseChangesArgs(args);
273
+ const installed = current ?? await packageVersion();
274
+ const selection = selectReleases(await readChangelog(file), options);
275
+ if (options.json) stdout.write(`${JSON.stringify(changesJson(selection, installed))}\n`);
276
+ else stdout.write(formatChanges(selection, { current: installed, stream: stdout }));
277
+ return 0;
278
+ }
package/src/cli.js CHANGED
@@ -93,6 +93,7 @@ Commands:
93
93
  veo history Show the last 5 downloads (--json for scripting)
94
94
  veo retry --last Retry the newest failed or unfinished job
95
95
  veo history --failed --limit 20 Filter and extend download history
96
+ veo changes [<version>] List release notes; --since <version> for newer changes
96
97
  veo runs [id] List active runs; add --json for metadata and progress
97
98
  veo inspect <file> Read media metadata; --check-audio measures audio signal
98
99
  veo subs <url> List available manual and automatic subtitles
@@ -246,6 +247,7 @@ export function cliOptions(config = {}) {
246
247
 
247
248
  const COMMAND_FLAGS = {
248
249
  stats: ['--json'], history: ['--json', '--limit', '--failed'],
250
+ changes: ['--json', '--version', '--since', '--to', '--range', '--latest'],
249
251
  runs: ['--json'], stop: [], flush: ['--stats'],
250
252
  inspect: ['--json', '--check-audio', '--search'],
251
253
  doctor: ['--offline', '--output', '-o'],
@@ -256,7 +258,9 @@ const COMMAND_FLAGS = {
256
258
  config: ['--profile', '--external', '--terminal'],
257
259
  profile: [],
258
260
  };
259
- const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'subs', 'help', 'version'];
261
+ // `changelog` is an alias, so it shares the flags of the command it stands for.
262
+ const ALIASES = { changelog: 'changes' };
263
+ const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), ...Object.keys(ALIASES), 'retry', 'subs', 'help', 'version'];
260
264
 
261
265
  function mistypedCommandOption(args) {
262
266
  const command = args[0];
@@ -264,8 +268,10 @@ function mistypedCommandOption(args) {
264
268
  const suggestion = suggestOption(command, COMMAND_NAMES);
265
269
  if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
266
270
  }
267
- const options = Object.hasOwn(COMMAND_FLAGS, command)
268
- ? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
271
+ const flags = Object.hasOwn(COMMAND_FLAGS, command) ? COMMAND_FLAGS[command]
272
+ : ALIASES[command] ? COMMAND_FLAGS[ALIASES[command]] : null;
273
+ const options = flags
274
+ ? [...flags, '--help', '-h', '--no-color', '--color', '--profile']
269
275
  : command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
270
276
  if (!options) return null; // Download flags are checked by parseCli itself.
271
277
  const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
@@ -421,7 +427,7 @@ export async function main(args = process.argv.slice(2), { config } = {}) {
421
427
  }
422
428
  // The command's own validation reports configuration errors.
423
429
  }
424
- if (['stats', 'history', 'flush', 'runs', 'inspect', 'stop', 'update', 'up', 'upgrade', 'check', 'doctor', 'backend', 'alias', 'uninstall'].includes(args[0])) args = display.remaining;
430
+ if (['stats', 'history', 'changes', 'changelog', 'flush', 'runs', 'inspect', 'stop', 'update', 'up', 'upgrade', 'check', 'doctor', 'backend', 'alias', 'uninstall'].includes(args[0])) args = display.remaining;
425
431
  const code = await withOutputSettings(color, () => runMain(args, { config }));
426
432
  // One throttled (once per day) update hint after every successful command.
427
433
  // Failures, cancellations, help/version output and the update commands
@@ -446,7 +452,7 @@ export async function main(args = process.argv.slice(2), { config } = {}) {
446
452
  // anything else earns the daily update hint on success.
447
453
  export function shouldUpdateNotice(args = []) {
448
454
  if (args.includes('-h') || args.includes('--help') || args.includes('-v') || args.includes('--version')) return false;
449
- if (['update', 'up', 'upgrade', 'check', 'version', 'help'].includes(args[0])) return false;
455
+ if (['update', 'up', 'upgrade', 'check', 'version', 'help', 'changes', 'changelog'].includes(args[0])) return false;
450
456
  return true;
451
457
  }
452
458
 
@@ -461,6 +467,10 @@ async function runMain(args, { config }) {
461
467
  try { return await (await import('./history.js')).historyMain(args.slice(1)); }
462
468
  catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
463
469
  }
470
+ if (args[0] === 'changes' || args[0] === 'changelog') {
471
+ try { return await (await import('./changes.js')).changesMain(args.slice(1), { stdout }); }
472
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
473
+ }
464
474
  if (args[0] === 'flush') {
465
475
  try { return await (await import('./flush.js')).flushMain(args.slice(1)); }
466
476
  catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
package/src/output.js CHANGED
@@ -38,7 +38,10 @@ export function formatOutput(text, stream, enabled = settings.getStore()?.enable
38
38
  || /^\s{6,}\S/.test(line)) line = indent + terminalText(available, value);
39
39
  }
40
40
  let role = 'muted';
41
- if (/^(?:veo (?:stats|history|runs|stop|doctor|flush|update|backend|alias|uninstall)\b|veo \d[^ ]* doctor|ID\s+PID\s+STATE|Usage:|Options:|Commands:|Examples:|\d+\. )/.test(value)) role = 'title';
41
+ if (/^(?:veo (?:stats|history|changes|changelog|runs|stop|doctor|flush|update|backend|alias|uninstall)\b|veo \d[^ ]* doctor|ID\s+PID\s+STATE|Usage:|Options:|Commands:|Examples:|\d+\. )/.test(value)) role = 'title';
42
+ // A bare version line introduces one release in `veo changes` range output; the
43
+ // list rows carry a summary and stay muted.
44
+ if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?(?:\s+\(installed\))?$/.test(value)) role = 'title';
42
45
  if (/^(?:\[?OK\]?\s|Saved:|Flushed:|Stopped\b|Removed\b|Config OK:|Statistics reset|Managed tools are ready)|\bis up to date\b|\bupdated to\b|\binstalled and will be used\b/i.test(value)) role = 'success';
43
46
  if (/^(?:\[?FAIL\]?\s|Error:|veo: (?!note:))|Status:\s*(?:failed|cancelled)/i.test(value)) role = 'error';
44
47
  if (/Status:\s*saved/.test(value)) role = 'success';
package/src/progress.js CHANGED
@@ -34,7 +34,7 @@ function fit(text, width) {
34
34
  return width > 0 ? result + '…' : '';
35
35
  }
36
36
 
37
- function wrap(text, width) {
37
+ export function wrap(text, width) {
38
38
  if (!Number.isFinite(width)) return text;
39
39
  const lines = [];
40
40
  let line = '';