@mailo037/veo 1.0.0 → 1.0.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.md CHANGED
@@ -40,6 +40,8 @@ veo <url> [options]
40
40
 
41
41
  -q, --quality <quality> best, 2160p, 1440p, 1080p, 720p, 480p, 360p
42
42
  -o, --output <path> Output directory (default: current working directory)
43
+ -r, --rename <name> Custom filename without extension (also used in tab title)
44
+ --open Open the saved file with your default app
43
45
  --audio Audio only (MP3 by default)
44
46
  --format <format> Video: mp4, mkv, webm, mov
45
47
  Audio: mp3, m4a, aac, opus, flac, wav
@@ -53,6 +55,8 @@ veo "https://x.com/USER/status/STATUS_ID" -q best
53
55
  veo "https://example.com/video.mp4" --audio
54
56
  veo "https://example.com/video.mp4" --audio --format flac
55
57
  veo "https://example.com/video.mp4" --format webm -o ./videos
58
+ veo "https://example.com/video.mp4" -r "Mein Video" --format mp4 --open
59
+ veo "https://example.com/video.mp4" --audio -r "Meine Musik"
56
60
  npx @mailo037/veo "https://example.com/video.mp4" --output ./downloads
57
61
  ```
58
62
 
@@ -60,11 +64,26 @@ npx @mailo037/veo "https://example.com/video.mp4" --output ./downloads
60
64
  - Numeric quality selects the nearest **available video height**, not just a maximum. Ties choose the lower height. Unknown heights fall back to best. `--quality` does not apply to audio.
61
65
  - MP4-compatible codecs are preferred at the selected resolution; merged video prefers MP4 with MKV fallback. A single-file source may retain its original container. Use `--format mp4` to explicitly require MP4 (conversion may be slow or lossy).
62
66
  - `--format` on video enables conversion when necessary. Audio formats require `--audio`.
63
- - Original titles are preserved, with invalid filename characters sanitized and overly long names shortened. Existing files are never intentionally overwritten: duplicates get ` (1)`, ` (2)`, etc.
67
+ - Original titles are preserved by default. Use `-r "My Video"` or `--rename "My Video"` to choose a filename for video or audio. Supply the name **without an extension**; the actual media extension is appended automatically. Use `-o` for the directory. Invalid filename characters are sanitized and overly long names shortened. Existing files are never intentionally overwritten: duplicates get ` (1)`, ` (2)`, etc.
68
+ - During a download, the terminal/tab title shows `veo | 50% | My Video` (original title or your `-r` name), plus setup/processing status. It ends with `Done`, `Failed`, or `Cancelled`; the shell may replace it at the next prompt. This uses the native console title on Windows (including PowerShell/Windows Terminal) and OSC title sequences on compatible Linux/macOS terminals. Titles are not changed when stderr is redirected or `TERM=dumb`. Terminal settings that enforce a fixed tab title can override this feature.
64
69
  - Downloads and conversion use a private temporary directory inside the output directory. The completed file is copied to a collision-safe final name, so allow extra disk space. Temporary files are removed on ordinary failure or Ctrl+C; force-killing the process may leave `.veo-*` directories to remove manually. Partial downloads are not resumed between invocations.
65
70
  - Progress shows percentage, speed, downloaded/total size and ETA on stderr; unknown values appear as `?`. Separate audio/video streams each have their own progress. Non-interactive output is throttled. The final path is printed on stdout as `Saved: ...`.
71
+ - `--open` launches the completed file in your default app, including renamed files and audio. Uses `explorer.exe` on Windows, `open` on macOS, and `xdg-open` on Linux (requires a graphical desktop, xdg-utils and a file association). The CLI does not wait for the player to close. If the opener cannot be launched, a warning is printed and the successful download still exits with `0`; later errors inside the detached opener/player are not monitored.
72
+ - After each successful download, veo checks the npm registry at most once per day for a newer version and prints a one-line notice on stderr (never on failure). Disable it with `VEO_NO_UPDATE_CHECK=1`; `VEO_REGISTRY`/`npm_config_registry` are respected.
66
73
  - Exit status is `0` on success, `1` on errors, and `130` on cancellation.
67
74
 
75
+ ## Updating veo
76
+
77
+ ```bash
78
+ veo update # install the latest version with npm (global)
79
+ veo update --check # only check for a newer version
80
+ veo upgrade # alias for veo update
81
+ veo check update # alias for veo update --check
82
+ veo update --help # update usage
83
+ ```
84
+
85
+ `veo update` runs `npm install -g @mailo037/veo@latest` and then removes yt-dlp backend caches from older pinned releases. It never uses a shell on Linux/macOS, passes fixed arguments only, and prints the manual npm command on any failure. The registry can be overridden with `VEO_REGISTRY` (or npm's `npm_config_registry`) for mirrors and proxies.
86
+
68
87
  ## Supported sites and backend
69
88
 
70
89
  YouTube, X/Twitter, TikTok, Vimeo, Reddit, Instagram, and [many other yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) are supported **when publicly accessible and technically available**. Support changes with websites, regions, rate limits, and backend versions; it is not a guarantee that every URL will work. Authentication/cookie flags are not exposed in this initial version.
@@ -85,6 +104,7 @@ Automatic yt-dlp acquisition covers mainstream Windows, macOS and Linux architec
85
104
  ```bash
86
105
  npm install
87
106
  npm test
107
+ npm run test:open
88
108
  npm run test:smoke
89
109
  npm pack --dry-run
90
110
  npm link
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mailo037/veo",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "A clean, simple video downloader powered by yt-dlp.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "scripts": {
19
19
  "test": "node --test",
20
20
  "test:smoke": "node scripts/smoke.js",
21
+ "test:open": "node scripts/smoke-open.js",
21
22
  "prepublishOnly": "npm test"
22
23
  },
23
24
  "keywords": [
package/src/cli.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { createReporter } from './progress.js';
4
+ import { openFile } from './open-file.js';
5
+ import { maybeUpdateNotice, updateMain, UPDATE_HELP, defaultRegistry, packageVersion } from './updater.js';
4
6
  import { QUALITIES, VIDEO_FORMATS, AUDIO_FORMATS, validateUrl, readableError, cleanText } from './utils.js';
5
7
 
6
8
  export const HELP = `veo - simple video downloader
@@ -11,6 +13,8 @@ Usage:
11
13
  Options:
12
14
  -q, --quality <quality> Video quality (best, 2160p, 1440p, 1080p, 720p, 480p, 360p)
13
15
  -o, --output <path> Output directory (default: current directory)
16
+ -r, --rename <name> Custom filename without extension (also used in tab title)
17
+ --open Open the saved file with your default app
14
18
  --audio Download audio only (default: mp3)
15
19
  --format <format> Video: mp4, mkv, webm, mov; audio: mp3, m4a, aac, opus, flac, wav
16
20
  --version Show version
@@ -21,6 +25,8 @@ Examples:
21
25
  veo <url> -q 1080p
22
26
  veo <url> --audio
23
27
  veo <url> -o ./downloads
28
+ veo <url> -r "My Video" --open
29
+ veo update --check
24
30
 
25
31
  Only download content you are authorized or legally permitted to download.
26
32
  `;
@@ -29,6 +35,8 @@ export function parseCli(args) {
29
35
  const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
30
36
  quality: { type: 'string', short: 'q', default: 'best' },
31
37
  output: { type: 'string', short: 'o', default: process.cwd() },
38
+ rename: { type: 'string', short: 'r' },
39
+ open: { type: 'boolean' },
32
40
  audio: { type: 'boolean', default: false },
33
41
  format: { type: 'string' },
34
42
  help: { type: 'boolean', short: 'h' },
@@ -38,6 +46,7 @@ export function parseCli(args) {
38
46
  if (positionals.length !== 1) throw new Error('Provide exactly one video URL. Run veo --help for usage.');
39
47
  if (!QUALITIES.includes(values.quality)) throw new Error(`Invalid quality. Choose: ${QUALITIES.join(', ')}.`);
40
48
  if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
49
+ if (values.rename !== undefined && !cleanText(values.rename)) throw new Error('The custom filename cannot be empty.');
41
50
  if (values.audio && values.quality !== 'best') throw new Error('--quality is for video; omit it when using --audio.');
42
51
  const formats = values.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
43
52
  if (values.format && !formats.includes(values.format)) throw new Error(`Invalid ${values.audio ? 'audio' : 'video'} format. Choose: ${formats.join(', ')}.${!values.audio && AUDIO_FORMATS.includes(values.format) ? ' Use --audio for audio formats.' : ''}`);
@@ -45,6 +54,14 @@ export function parseCli(args) {
45
54
  }
46
55
 
47
56
  export async function main(args = process.argv.slice(2)) {
57
+ // update/upgrade/check subcommands are handled before URL validation.
58
+ if (['update', 'upgrade', 'check'].includes(args[0])) {
59
+ if (args[0] === 'check' && args[1] !== 'update') {
60
+ process.stdout.write(UPDATE_HELP);
61
+ return 0;
62
+ }
63
+ return updateMain(args, { registry: defaultRegistry() });
64
+ }
48
65
  const reporter = createReporter();
49
66
  const controller = new AbortController();
50
67
  const cancel = () => controller.abort();
@@ -58,12 +75,22 @@ export async function main(args = process.argv.slice(2)) {
58
75
  process.stdout.write(`${pkg.version}\n`);
59
76
  return 0;
60
77
  }
78
+ reporter.start(options.rename);
61
79
  const { download } = await import('./downloader.js');
62
80
  const saved = await download(options, { signal: controller.signal, reporter });
81
+ reporter.complete();
63
82
  process.stdout.write(`Saved: ${cleanText(saved)}\n`);
83
+ if (options.open) {
84
+ try { await openFile(saved); }
85
+ catch (error) {
86
+ process.stderr.write(`veo: File saved, but could not launch the default app: ${readableError(error)}\n`);
87
+ }
88
+ }
89
+ const notice = await maybeUpdateNotice({ currentVersion: await packageVersion() });
90
+ if (notice) process.stderr.write(`${notice}\n`);
64
91
  return 0;
65
92
  } catch (error) {
66
- reporter.finish();
93
+ reporter.fail(controller.signal.aborted);
67
94
  process.stderr.write(`veo: ${readableError(error)}\n`);
68
95
  return controller.signal.aborted ? 130 : 1;
69
96
  } finally {
package/src/downloader.js CHANGED
@@ -49,6 +49,8 @@ export async function download(options, { signal, reporter, backendResolver = re
49
49
  if (metadata._type === 'playlist' || metadata.entries) throw new Error('This URL is a collection. Please provide a single video URL.');
50
50
  if (metadata.is_live) throw new Error('Live streams are not supported. Please use a finished video.');
51
51
  if (metadata.has_drm) throw new Error('This content is DRM-protected.');
52
+ const title = options.rename ?? (metadata.title || metadata.id || 'video');
53
+ reporter?.name?.(title);
52
54
  const height = options.audio ? null : closestHeight(metadata.formats || [metadata], options.quality);
53
55
  if (!options.audio && options.quality !== 'best') {
54
56
  reporter?.status(height ? `Quality: ${height}p${height !== parseInt(options.quality, 10) ? ` (closest to ${options.quality})` : ''}` : 'Resolution unknown; using the best available stream.');
@@ -79,7 +81,7 @@ export async function download(options, { signal, reporter, backendResolver = re
79
81
  saved = path.resolve(saved);
80
82
  if (path.dirname(saved) !== temporary || !(await stat(saved)).isFile()) throw new Error('The backend returned an invalid saved file path.');
81
83
  signal?.throwIfAborted();
82
- return await saveUnique(saved, directory, metadata.title || metadata.id || 'video');
84
+ return await saveUnique(saved, directory, title);
83
85
  } finally {
84
86
  reporter?.finish();
85
87
  await rm(temporary, { recursive: true, force: true });
@@ -0,0 +1,14 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+
4
+ // Pass the absolute filename as one argument, never through a shell. Detach so
5
+ // a media player that stays running does not keep the CLI alive.
6
+ export function openFile(filename, { platform = process.platform, spawnProcess = spawn } = {}) {
7
+ const command = platform === 'win32' ? 'explorer.exe' : platform === 'darwin' ? 'open' : 'xdg-open';
8
+ const absolute = path.resolve(filename);
9
+ return new Promise((resolve, reject) => {
10
+ const child = spawnProcess(command, [absolute], { shell: false, detached: true, stdio: 'ignore', windowsHide: true });
11
+ child.once('error', reject);
12
+ child.once('spawn', () => { child.unref(); resolve(); });
13
+ });
14
+ }
package/src/progress.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { cleanText } from './utils.js';
2
+ import { createTerminalTitle } from './terminal-title.js';
2
3
 
3
4
  function bytes(value) {
4
5
  if (!Number.isFinite(value) || value < 0) return '?';
@@ -17,16 +18,34 @@ export function formatProgress(data) {
17
18
  return `[${'='.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent === null ? ' ?' : percent.toFixed(0).padStart(3)}% ${bytes(data.speed)}/s ${bytes(done)} / ${bytes(total)} ETA ${eta}`;
18
19
  }
19
20
 
20
- export function createReporter(stream = process.stderr) {
21
+ export function createReporter(stream = process.stderr, { setTitle = createTerminalTitle(stream) } = {}) {
21
22
  let active = false;
22
23
  let lastLog = 0;
24
+ let name = '';
25
+ let phase = 'Starting…';
26
+ let started = false;
27
+ const updateTitle = () => setTitle(`veo | ${phase}${name ? ` | ${name}` : ''}`);
23
28
  function clear() {
24
29
  if (active && stream.isTTY) stream.write('\r\x1b[2K');
25
30
  active = false;
26
31
  }
27
32
  return {
28
- status(message) { clear(); stream.write(`${cleanText(message)}\n`); },
33
+ start(title = '') { started = true; name = cleanText(title); phase = 'Starting…'; updateTitle(); },
34
+ name(title) { name = cleanText(title); if (started) updateTitle(); },
35
+ status(message) {
36
+ clear();
37
+ phase = cleanText(message);
38
+ if (started) updateTitle();
39
+ stream.write(`${phase}\n`);
40
+ },
41
+ complete() { clear(); phase = 'Done'; if (started) updateTitle(); },
42
+ fail(cancelled = false) { clear(); phase = cancelled ? 'Cancelled' : 'Failed'; if (started) updateTitle(); },
29
43
  progress(data) {
44
+ const total = data.total_bytes || data.total_bytes_estimate;
45
+ const percent = Number.isFinite(total) && total > 0 && Number.isFinite(data.downloaded_bytes)
46
+ ? Math.max(0, Math.min(100, Math.round(data.downloaded_bytes / total * 100))) : null;
47
+ phase = data.status === 'finished' ? 'Processing…' : percent === null ? 'Downloading…' : `${percent}%`;
48
+ if (started) updateTitle();
30
49
  if (stream.isTTY) {
31
50
  stream.write(`\r\x1b[2K${formatProgress(data)}`);
32
51
  active = true;
@@ -0,0 +1,19 @@
1
+ import { cleanText } from './utils.js';
2
+
3
+ // PowerShell's console title API on Windows; OSC 0 on xterm-compatible Unix
4
+ // terminals. Never put escape sequences into redirected output or dumb terminals.
5
+ export function createTerminalTitle(stream, { platform = process.platform, env = process.env, processInfo = process } = {}) {
6
+ let previous;
7
+ return title => {
8
+ if (!stream.isTTY || env.TERM === 'dumb') return;
9
+ const safe = cleanText(title).slice(0, 240);
10
+ if (safe === previous) return;
11
+ try {
12
+ if (platform === 'win32') processInfo.title = safe;
13
+ else stream.write(`\x1b]0;${safe}\x07`);
14
+ previous = safe;
15
+ } catch {
16
+ // Cosmetic only: a terminal that rejects titles must not break downloads.
17
+ }
18
+ };
19
+ }
package/src/updater.js ADDED
@@ -0,0 +1,247 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { RELEASE } from './backend.js';
6
+ import { readableError } from './utils.js';
7
+
8
+ const PACKAGE = '@mailo037/veo';
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+
11
+ export const UPDATE_HELP = `veo update - keep veo current
12
+
13
+ Usage:
14
+ veo update Install the latest version with npm
15
+ veo update --check Only check whether a newer version exists
16
+ veo upgrade Alias for veo update
17
+ veo check update Alias for veo update --check
18
+
19
+ The registry can be overridden with VEO_REGISTRY (or npm_config_registry).
20
+ Set VEO_NO_UPDATE_CHECK=1 to disable the automatic post-download check.
21
+ `;
22
+
23
+ export function defaultRegistry(env = process.env) {
24
+ const registry = env.VEO_REGISTRY || env.npm_config_registry;
25
+ if (typeof registry === 'string' && /^https?:\/\//.test(registry.trim())) return registry.trim().replace(/\/+$/, '');
26
+ return 'https://registry.npmjs.org';
27
+ }
28
+
29
+ export function compareVersions(a, b) {
30
+ const parse = value => {
31
+ const [core, pre = ''] = String(value).trim().replace(/^v/, '').split('-');
32
+ if (!/^\d+(\.\d+)*$/.test(core)) throw new Error(`Invalid version: ${value}`);
33
+ // Semver prerelease identifiers compare per segment, numerically when numeric.
34
+ return { parts: core.split('.').map(Number), pre: pre === '' ? [] : pre.split('.') };
35
+ };
36
+ const left = parse(a);
37
+ const right = parse(b);
38
+ for (let index = 0; index < Math.max(left.parts.length, right.parts.length); index++) {
39
+ const difference = (left.parts[index] || 0) - (right.parts[index] || 0);
40
+ if (difference) return Math.sign(difference);
41
+ }
42
+ // A release outranks any prerelease of the same core version.
43
+ if (!left.pre.length && !right.pre.length) return 0;
44
+ if (!left.pre.length) return 1;
45
+ if (!right.pre.length) return -1;
46
+ for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index++) {
47
+ const l = left.pre[index];
48
+ const r = right.pre[index];
49
+ if (l === undefined) return -1;
50
+ if (r === undefined) return 1;
51
+ const lNumeric = /^\d+$/.test(l);
52
+ const rNumeric = /^\d+$/.test(r);
53
+ if (lNumeric && rNumeric) {
54
+ const difference = Number(l) - Number(r);
55
+ if (difference) return Math.sign(difference);
56
+ } else if (lNumeric) return -1; // Numeric identifiers rank below alphanumerics.
57
+ else if (rNumeric) return 1;
58
+ else if (l !== r) return l < r ? -1 : 1;
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ export async function fetchLatestVersion({ registry = defaultRegistry(), fetchImpl = fetch, timeoutMs = 8000, signal } = {}) {
64
+ const timeout = AbortSignal.timeout(timeoutMs);
65
+ const url = `${registry}/${encodeURIComponent(PACKAGE)}`;
66
+ const response = await fetchImpl(url, {
67
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
68
+ headers: { accept: 'application/json' },
69
+ });
70
+ if (!response.ok) {
71
+ await response.body?.cancel();
72
+ throw new Error(`Update check failed: HTTP ${response.status}.`);
73
+ }
74
+ const data = await response.json();
75
+ // Full packument: pick the highest version instead of trusting the "latest" tag.
76
+ const versions = data?.versions ? Object.keys(data.versions) : [data?.version].filter(Boolean);
77
+ if (!versions.length) throw new Error('Update check returned no versions.');
78
+ const [latest] = versions.sort((a, b) => compareVersions(b, a));
79
+ const version = typeof latest === 'string' ? latest : '';
80
+ if (!/^\d+\.\d+\.\d+/.test(version)) throw new Error('Update check returned an invalid version.');
81
+ return version;
82
+ }
83
+
84
+ // Same OS cache base as the yt-dlp backend cache (see src/backend.js).
85
+ export function veoCacheBase({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
86
+ let base;
87
+ if (platform === 'win32') base = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
88
+ else if (platform === 'darwin') base = path.join(home, 'Library', 'Caches');
89
+ else base = env.XDG_CACHE_HOME || path.join(home, '.cache');
90
+ if (!path.isAbsolute(base)) base = path.join(home, '.cache');
91
+ return path.join(base, 'veo');
92
+ }
93
+
94
+ export async function readState(stateFile) {
95
+ try {
96
+ const state = JSON.parse(await readFile(stateFile, 'utf8'));
97
+ return Number.isFinite(state?.lastCheck) ? state : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ async function writeState(stateFile, state) {
104
+ await mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 });
105
+ await writeFile(stateFile, JSON.stringify(state), { mode: 0o600 });
106
+ }
107
+
108
+ // Throttled (once per day) post-download check. Purely informational; every
109
+ // failure is silent so downloads are never delayed or marked as failed.
110
+ export async function maybeUpdateNotice({
111
+ currentVersion,
112
+ env = process.env,
113
+ stateFile,
114
+ fetchImpl = fetch,
115
+ registry,
116
+ now = Date.now(),
117
+ ttlMs = DAY_MS,
118
+ timeoutMs = 4000,
119
+ } = {}) {
120
+ if (env.VEO_NO_UPDATE_CHECK) return null;
121
+ try {
122
+ if (!stateFile) stateFile = path.join(veoCacheBase({ env }), 'update-check.json');
123
+ const state = await readState(stateFile);
124
+ if (state && now - state.lastCheck < ttlMs) return null;
125
+ } catch {
126
+ return null; // No usable cache location: never turn a notice into noise.
127
+ }
128
+ let latest = null;
129
+ let message = null;
130
+ try {
131
+ latest = await fetchLatestVersion({ fetchImpl, registry, timeoutMs });
132
+ if (compareVersions(latest, currentVersion) > 0) {
133
+ message = `Update available: veo ${latest} (you have ${currentVersion}). Run: veo update`;
134
+ }
135
+ } catch {
136
+ latest = null; // Unreachable registry still consumes the throttle interval.
137
+ }
138
+ await writeState(stateFile, { lastCheck: now, latest }).catch(() => {});
139
+ return message;
140
+ }
141
+
142
+ // Removes yt-dlp caches from older pinned releases; only the current one stays.
143
+ export async function pruneBackendCaches({ keep, root, platform = process.platform, env = process.env } = {}) {
144
+ const base = root || path.join(veoCacheBase({ platform, env }), 'backends');
145
+ let entries;
146
+ try {
147
+ entries = await readdir(base, { withFileTypes: true });
148
+ } catch (error) {
149
+ if (error.code === 'ENOENT') return 0;
150
+ throw error;
151
+ }
152
+ let removed = 0;
153
+ for (const entry of entries) {
154
+ if (!entry.isDirectory() || entry.name === keep) continue;
155
+ await rm(path.join(base, entry.name), { recursive: true, force: true });
156
+ removed++;
157
+ }
158
+ return removed;
159
+ }
160
+
161
+ export function npmSpawnCommand({ platform = process.platform } = {}) {
162
+ // Fixed arguments only (no user input), so cmd.exe on Windows is safe here.
163
+ return {
164
+ command: 'npm',
165
+ args: ['install', '-g', '--no-fund', '--no-audit', `${PACKAGE}@latest`],
166
+ shell: platform === 'win32',
167
+ };
168
+ }
169
+
170
+ export function runNpmUpdate({ spawnImpl = spawn, platform = process.platform, signal, timeoutMs = 600_000 } = {}) {
171
+ const { command, args, shell } = npmSpawnCommand({ platform });
172
+ return new Promise((resolve, reject) => {
173
+ const child = spawnImpl(command, args, { shell, stdio: 'inherit', windowsHide: true, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
174
+ child.once('error', reject);
175
+ child.once('close', resolve);
176
+ });
177
+ }
178
+
179
+ export async function packageVersion() {
180
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
181
+ return pkg.version;
182
+ }
183
+
184
+ /**
185
+ * Implements `veo update [--check]`, `veo upgrade`, and `veo check update`.
186
+ * Returns the process exit code. Deps are injectable for tests.
187
+ */
188
+ export async function updateMain(args, {
189
+ registry = defaultRegistry(),
190
+ fetchImpl = fetch,
191
+ spawnImpl = spawn,
192
+ platform = process.platform,
193
+ stdout = process.stdout,
194
+ stderr = process.stderr,
195
+ current,
196
+ keep = RELEASE,
197
+ pruneRoot = undefined,
198
+ } = {}) {
199
+ current = current ?? await packageVersion();
200
+ const isCheckCommand = args[0] === 'check';
201
+ const rest = isCheckCommand ? args.slice(2) : args.slice(1);
202
+ if (rest.includes('-h') || rest.includes('--help')) {
203
+ stdout.write(UPDATE_HELP);
204
+ return 0;
205
+ }
206
+ const checkOnly = isCheckCommand || rest.includes('--check') || (rest.length === 1 && rest[0] === 'check');
207
+ const unknown = rest.filter(token => token !== '--check' && !(checkOnly && token === 'check'));
208
+ if (unknown.length) throw new Error(`Unknown option for veo update: ${unknown.join(' ')}. Use: veo update [--check]`);
209
+ const currentText = `veo ${current}`;
210
+ let latest;
211
+ try {
212
+ latest = await fetchLatestVersion({ registry, fetchImpl, timeoutMs: 10_000 });
213
+ } catch (error) {
214
+ stderr.write(`veo: ${readableError(error)}\nUpdate manually with: npm install -g ${PACKAGE}@latest\n`);
215
+ return 1;
216
+ }
217
+ const newer = compareVersions(latest, current) > 0;
218
+ if (checkOnly) {
219
+ stdout.write(newer ? `Update available: veo ${latest} (you have ${current}). Run: veo update\n` : `${currentText} is up to date.\n`);
220
+ return 0;
221
+ }
222
+ if (newer) {
223
+ stdout.write(`Updating ${currentText} → ${latest} with npm…\n`);
224
+ let code;
225
+ try {
226
+ code = await runNpmUpdate({ spawnImpl, platform, signal: AbortSignal.timeout(600_000) });
227
+ } catch (error) {
228
+ const reason = error.code === 'ENOENT' ? 'npm was not found. Install Node.js/npm, or update manually.' : `Could not run npm: ${readableError(error)}`;
229
+ stderr.write(`veo: ${reason}\nManual command: npm install -g ${PACKAGE}@latest\n`);
230
+ return 1;
231
+ }
232
+ if (code !== 0) {
233
+ stderr.write(`veo: npm exited with code ${code}. Update manually with: npm install -g ${PACKAGE}@latest\n`);
234
+ return 1;
235
+ }
236
+ stdout.write(`veo updated to ${latest}. The next veo call uses the new version.\n`);
237
+ } else {
238
+ stdout.write(`${currentText} is up to date.\n`);
239
+ }
240
+ try {
241
+ const removed = await pruneBackendCaches({ keep, root: pruneRoot });
242
+ if (removed) stdout.write(`Removed ${removed} old backend cache${removed === 1 ? '' : 's'}.\n`);
243
+ } catch {
244
+ // Cosmetic housekeeping must never fail the update.
245
+ }
246
+ return 0;
247
+ }