veodl 1.12.0 → 1.12.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to veo. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 1.12.1
6
+
7
+ ### Fixed
8
+
9
+ - Remove the optional FFmpeg npm dependency tree and its installation scripts.
10
+ - Download desktop FFmpeg and FFprobe from fixed upstream releases and verify compressed and executable SHA-256 hashes before either program runs. Preserve upstream license and build information alongside the tools.
11
+ - Verify managed media caches on reuse and reject legacy, corrupt or symlinked binaries. Preserve trusted system tools and explicit overrides.
12
+ - Prevent rejected managed-cache binaries from being accepted again through PATH or a symlink alias; continue searching for external system tools.
13
+ - Keep Smolish cookie environment variables out of media-tool probe processes.
14
+ - Retry completed local transfers without contacting the original source, including selected-source retries, while retaining cache expiry and path validation.
15
+ - Allow up to 30 seconds for Chromium's debugging connection on cold starts, while retaining the source scan's overall deadline and cancellation.
16
+
5
17
  ## 1.12.0
6
18
 
7
19
  ### Added
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 veo contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 veo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -770,12 +770,15 @@ A missing system FFmpeg is not a warning when the selected media tools work.
770
770
 
771
771
  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.
772
772
 
773
- Official standalone yt-dlp is acquired on first download and cached outside the package directory; help, version and plain doctor never download the backend. The release is pinned and SHA-256 verified against hashes shipped with this package. FFmpeg and FFprobe are normally supplied by the optional `ffmpeg-static` and `ffprobe-static` dependencies during npm installation. If these are absent and no local pair is available, veo installs `ffmpeg-static@5.3.0` and a platform-specific `@ffprobe-installer` package in a temporary cache project, checks that both programs run, and saves them in its backend cache. Media packages use npm/upstream HTTPS distribution, not the yt-dlp pinned-hash guarantee. These binaries have their own licenses; see their upstream packages. No Python installation is needed on supported standalone platforms.
773
+ Official standalone yt-dlp is acquired on first download and cached outside the package directory; help, version and plain doctor never download the backend. The release is pinned and SHA-256 verified against hashes shipped with this package. The npm package has no runtime dependencies and runs no installation scripts.
774
+
775
+ When no trusted local FFmpeg/FFprobe pair is available, veo downloads both tools directly from the upstream [ffmpeg-static binary releases](https://github.com/eugeneware/ffmpeg-static/releases/tag/b6.1.1). Release URLs, compressed and executable SHA-256 hashes, and byte counts are pinned in the package. The complete pair and its license and README must pass verification before either executable is started. Later uses verify the cached pair again; legacy or damaged cached binaries are not accepted merely because they are executable. Most targets use FFmpeg 6.1.1; Windows ia32 uses the last available pair, FFmpeg 6.0. Old `FFMPEG_BINARIES_URL`, `FFMPEG_BINARY_RELEASE` and `FFMPEG_BIN` variables do not change these downloads. Use `VEO_FFMPEG_PATH` to choose a trusted installed pair explicitly.
776
+
777
+ The downloaded binaries retain their separate upstream licenses. `FFmpeg.LICENSE` and `FFmpeg.README` are saved next to the cached tools, including upstream source and build information. The JavaScript CLI remains MIT licensed. No Python installation is needed on supported standalone platforms.
774
778
 
775
779
  The existing Node executable is explicitly enabled as yt-dlp's JavaScript runtime for YouTube. Local yt-dlp configuration and plugins are disabled for predictable execution. Arguments are passed without a shell.
776
780
 
777
- `ffmpeg-static` and `ffprobe-static` publish no Windows ARM64 binaries. veo no longer refuses
778
- that platform: it uses an FFmpeg/FFprobe pair found on `PATH`, including the usual
781
+ Windows ARM64 uses an FFmpeg/FFprobe pair found on `PATH`, including the usual
779
782
  WinGet, Chocolatey and `C:\ffmpeg\bin` locations, or automatically downloads x64
780
783
  media tools for Windows 11's x64 emulation.
781
784
 
@@ -796,8 +799,7 @@ installed as a dependency. Termux with the pacman package manager uses
796
799
  `--noconfirm` instead. This needs a working Termux repository and internet access.
797
800
  No root access is needed. Later runs reuse the installed tools.
798
801
 
799
- Static FFmpeg dependencies are optional; `--omit=optional` can be added to the
800
- npm install command to skip attempting those desktop packages entirely.
802
+ The npm installation does not pull in desktop FFmpeg packages.
801
803
  The system yt-dlp is maintained by Termux and is
802
804
  not pinned or hash-verified by veo. Explicit `VEO_YT_DLP_PATH` and
803
805
  `VEO_FFMPEG_PATH` overrides still take precedence.
@@ -880,7 +882,7 @@ finished download whose destination was unavailable is retried from the local ca
880
882
  without contacting the source again. It also confirms that real runs leave `veo history`
881
883
  entries, that finished runs clean up their record, and that `veo runs`/`veo stop` report
882
884
  nothing left behind. It needs network access once for yt-dlp acquisition; it downloads no
883
- third-party video. Unit tests require no network.
885
+ third-party video. Prepare media tools with `veo doctor fix` before `npm test` on a fresh checkout; the tests themselves use local fixtures and require no network. CI explicitly prepares and verifies the native binary pair before testing.
884
886
 
885
887
  The published tarball only includes `bin/`, `src/`, package metadata, README and LICENSE.
886
888
  The lockfile is kept for reproducible development. There is no build step. npm makes the
@@ -116,6 +116,7 @@ The run record is stored in veo's per-user cache on the device that ran the down
116
116
  - Example profile: `"experimental": { "concurrentFragments": 32, "experimentalFragments": true }`. Run with `veo <URL> --profile experimental`; use `--no-experimental-fragments` to revoke a stored opt-in.
117
117
 
118
118
  - Run `veo doctor --offline` to check the local setup without network repair. `veo doctor fix` may install missing tools.
119
+ - Desktop media downloads use fixed release URLs and shipped SHA-256 hashes; both FFmpeg and FFprobe are verified before execution and on cache reuse. A rejected legacy cache requires `veo doctor fix`. Trusted PATH tools and `VEO_FFMPEG_PATH` remain explicit alternatives. The npm package has no runtime dependencies or install scripts.
119
120
  - Use `--resume` for interrupted downloads and `--skip-existing` when repeated saves should be avoided.
120
121
  - `veo flush` removes temporary downloads and retry jobs; do not run it as routine maintenance in an agent workflow.
121
122
  - Do not call bare `veo` in an interactive terminal from automation: it opens a wizard. Use explicit URLs and flags.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veodl",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "A clean, simple video downloader powered by yt-dlp.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,10 +51,6 @@
51
51
  "publishConfig": {
52
52
  "access": "public"
53
53
  },
54
- "optionalDependencies": {
55
- "ffmpeg-static": "5.3.0",
56
- "ffprobe-static": "3.1.0"
57
- },
58
54
  "devDependencies": {
59
55
  "@xterm/headless": "6.0.0"
60
56
  }
package/src/alias.js CHANGED
@@ -1,258 +1,258 @@
1
- import path from 'node:path';
2
- import { chmod, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
- import { commandOutput } from './output.js';
4
- import { findOnPath } from './backend.js';
5
- import { cleanText, readableError } from './utils.js';
6
-
7
- export const BUILTIN_ALIASES = ['veo', 'veodl'];
8
- export const ALIAS_MARKER = 'veo-alias';
9
-
10
- export const ALIAS_HELP = `veo alias - manage extra command names for veo
11
-
12
- Usage:
13
- veo alias List command names (same as: veo alias list)
14
- veo alias list [--json] Show veo, veodl plus custom wrappers
15
- veo alias add <name> [--force] [--json]
16
- Create <name> as a wrapper that calls veo
17
- veo alias remove <name> [--json] Delete a wrapper (also: veo alias rm)
18
- veo alias --help Show this help
19
-
20
- Names use 2-31 lowercase letters, digits or hyphens and start with a letter,
21
- e.g. veo-dl. The wrapper is created next to the veo command found on PATH and
22
- simply forwards to veo, so it survives veo updates. Removing veodl only
23
- removes that shortcut; veo update recreates the shipped names. veo itself can
24
- never be removed. Use --bin-dir <path> to override the target directory.
25
- `;
26
-
27
- export function validateAliasName(input) {
28
- const raw = String(input ?? '').trim();
29
- if (!raw) throw new Error('An alias name is required. Use: veo alias add <name>');
30
- if (cleanText(raw) !== raw) throw new Error(`Invalid alias name: ${raw}. Use plain text without escapes.`);
31
- if (!/^[a-z][a-z0-9-]{1,30}$/.test(raw)) {
32
- throw new Error(`Invalid alias name "${raw}". Use 2-31 lowercase letters, digits or hyphens, starting with a letter (e.g. veo-dl).`);
33
- }
34
- return raw;
35
- }
36
-
37
- export function aliasFiles(binDir, name, platform = process.platform) {
38
- if (platform === 'win32') {
39
- return [path.join(binDir, name), path.join(binDir, `${name}.cmd`), path.join(binDir, `${name}.ps1`)];
40
- }
41
- return [path.join(binDir, name)];
42
- }
43
-
44
- function posixWrapper(name) {
45
- return `#!/bin/sh\n# ${ALIAS_MARKER}: ${name}\nexec veo "$@"\n`;
46
- }
47
-
48
- function windowsCmdWrapper(name) {
49
- return `@echo off\r\nREM ${ALIAS_MARKER}: ${name}\r\nveo %*\r\n`;
50
- }
51
-
52
- function windowsShWrapper(name) {
53
- return `#!/bin/sh\n# ${ALIAS_MARKER}: ${name}\nexec veo "$@"\n`;
54
- }
55
-
56
- function windowsPsWrapper(name) {
57
- return `# ${ALIAS_MARKER}: ${name}\nveo @args\n`;
58
- }
59
-
60
- async function exists(file, { statImpl = stat } = {}) {
61
- try {
62
- const info = await statImpl(file);
63
- return info.isFile();
64
- } catch (error) {
65
- if (error?.code === 'ENOENT') return false;
66
- throw error;
67
- }
68
- }
69
-
70
- async function isManaged(file, { readFileImpl = readFile } = {}) {
71
- try {
72
- const text = await readFileImpl(file, 'utf8');
73
- return text.slice(0, 4096).includes(ALIAS_MARKER);
74
- } catch {
75
- return false;
76
- }
77
- }
78
-
79
- export async function resolveBinDir({ binDir, platform = process.platform, env = process.env, find = findOnPath } = {}) {
80
- if (binDir) return path.resolve(binDir);
81
- const found = await find(['veo'], { platform, env });
82
- if (!found) {
83
- throw new Error('Could not locate the veo command on PATH. Install veo globally (npm install -g veodl) or pass --bin-dir <path>.');
84
- }
85
- return path.dirname(found);
86
- }
87
-
88
- export async function aliasStatus(name, { binDir, platform = process.platform, ...fs } = {}) {
89
- const files = aliasFiles(binDir, name, platform);
90
- const present = [];
91
- let managed = false;
92
- for (const file of files) {
93
- if (await exists(file, fs)) {
94
- present.push(file);
95
- if (await isManaged(file, fs)) managed = true;
96
- }
97
- }
98
- return { name, builtin: BUILTIN_ALIASES.includes(name), present: present.length > 0, managed, paths: present };
99
- }
100
-
101
- function baseNameOf(file, platform) {
102
- const base = path.basename(file);
103
- if (platform === 'win32') {
104
- if (base.toLowerCase().endsWith('.cmd')) return base.slice(0, -4);
105
- if (base.toLowerCase().endsWith('.ps1')) return base.slice(0, -4);
106
- }
107
- return base;
108
- }
109
-
110
- export async function listAliases({ binDir, platform = process.platform, builtins = BUILTIN_ALIASES, readdirImpl = readdir, readFileImpl = readFile, statImpl = stat } = {}) {
111
- const resolved = await resolveBinDir({ binDir, platform });
112
- const entries = [];
113
- for (const name of builtins) {
114
- entries.push(await aliasStatus(name, { binDir: resolved, platform, readFileImpl, statImpl }));
115
- }
116
- const known = new Set(entries.map(entry => entry.name));
117
- let files = [];
118
- try {
119
- files = await readdirImpl(resolved, { withFileTypes: true });
120
- } catch (error) {
121
- throw new Error(`Cannot list the command directory ${resolved}: ${readableError(error)}`);
122
- }
123
- for (const entry of files) {
124
- if (!entry.isFile()) continue;
125
- const full = path.join(resolved, entry.name);
126
- let text = '';
127
- try {
128
- text = String(await readFileImpl(full, 'utf8')).slice(0, 4096);
129
- } catch {
130
- continue;
131
- }
132
- if (!text.includes(ALIAS_MARKER)) continue;
133
- const base = baseNameOf(entry.name, platform);
134
- try {
135
- validateAliasName(base);
136
- } catch {
137
- continue;
138
- }
139
- if (known.has(base)) {
140
- const existing = entries.find(item => item.name === base);
141
- if (existing) existing.managed = true;
142
- continue;
143
- }
144
- known.add(base);
145
- entries.push({ name: base, builtin: false, present: true, managed: true, paths: [full] });
146
- }
147
- return { binDir: resolved, aliases: entries.sort((a, b) => a.name.localeCompare(b.name)) };
148
- }
149
-
150
- export async function addAlias(name, { binDir, platform = process.platform, force = false, writeFileImpl = writeFile, statImpl = stat, chmodImpl = chmod, mkdirImpl = mkdir } = {}) {
151
- const valid = validateAliasName(name);
152
- if (valid === 'veo') throw new Error('veo is already the main command.');
153
- const resolved = await resolveBinDir({ binDir, platform });
154
- await mkdirImpl(resolved, { recursive: true });
155
- const files = aliasFiles(resolved, valid, platform);
156
- const present = [];
157
- for (const file of files) {
158
- if (await exists(file, { statImpl })) present.push(file);
159
- }
160
- if (present.length && !force) {
161
- throw new Error(`${valid} already exists (${present.map(file => path.basename(file)).join(', ')}). Use --force to replace it.`);
162
- }
163
- for (const file of present) await rm(file, { force: true });
164
- if (platform === 'win32') {
165
- await writeFileImpl(files[0], windowsShWrapper(valid), { mode: 0o755 });
166
- await writeFileImpl(files[1], windowsCmdWrapper(valid));
167
- await writeFileImpl(files[2], windowsPsWrapper(valid));
168
- } else {
169
- await writeFileImpl(files[0], posixWrapper(valid), { mode: 0o755 });
170
- try {
171
- await chmodImpl(files[0], 0o755);
172
- } catch {
173
- // Non-POSIX filesystems may ignore modes; the shebang still works.
174
- }
175
- }
176
- return { name: valid, binDir: resolved, paths: aliasFiles(resolved, valid, platform) };
177
- }
178
-
179
- export async function removeAlias(name, { binDir, platform = process.platform, statImpl = stat, rmImpl = rm } = {}) {
180
- const valid = validateAliasName(name);
181
- if (valid === 'veo') throw new Error('Refusing to remove veo itself. Remove veodl or a custom wrapper instead.');
182
- const resolved = await resolveBinDir({ binDir, platform });
183
- const status = await aliasStatus(valid, { binDir: resolved, platform, statImpl });
184
- if (!status.present) throw new Error(`${valid} is not installed in ${resolved}.`);
185
- for (const file of status.paths) await rmImpl(file, { force: true });
186
- return { name: valid, binDir: resolved, removed: status.paths, builtin: status.builtin };
187
- }
188
-
189
- function parseAliasArgs(args) {
190
- let binDir;
191
- let force = false;
192
- let json = false;
193
- const rest = [];
194
- for (let index = 0; index < args.length; index++) {
195
- const token = args[index];
196
- if (token === '--force') force = true;
197
- else if (token === '--json') json = true;
198
- else if (token === '--bin-dir') {
199
- binDir = args[++index];
200
- if (!binDir) throw new Error('--bin-dir requires a directory. Run veo alias --help for usage.');
201
- } else if (token.startsWith('--bin-dir=')) {
202
- binDir = token.slice(10) || undefined;
203
- if (!binDir) throw new Error('--bin-dir requires a directory. Run veo alias --help for usage.');
204
- } else rest.push(token);
205
- }
206
- return { binDir, force, json, rest };
207
- }
208
-
209
- export async function aliasMain(args = [], { stdout = process.stdout, stderr = process.stderr, ...options } = {}) {
210
- [args, stdout, stderr] = commandOutput(args, stdout, stderr);
211
- const { binDir, force, json, rest } = parseAliasArgs(args);
212
- const [command, name, ...extra] = rest;
213
- if (!command || command === 'list') {
214
- if (name || extra.length) throw new Error('Usage: veo alias list [--json] (or veo alias --help)');
215
- if (command === 'list' && extra.length) throw new Error('Usage: veo alias list [--json] (or veo alias --help)');
216
- const result = await listAliases({ ...options, binDir });
217
- if (json) {
218
- stdout.write(`${JSON.stringify(result)}\n`);
219
- return 0;
220
- }
221
- stdout.write(`Command directory: ${result.binDir}\n`);
222
- for (const entry of result.aliases) {
223
- const kind = entry.builtin ? 'shipped' : 'custom';
224
- const state = entry.present ? (entry.managed ? 'wrapper -> veo' : 'installed') : 'missing';
225
- stdout.write(` ${entry.name} (${kind}, ${state})\n`);
226
- }
227
- const missing = result.aliases.filter(entry => !entry.present).map(entry => entry.name);
228
- if (missing.length) stdout.write(`Missing: ${missing.join(', ')} (veo update recreates shipped names).\n`);
229
- return 0;
230
- }
231
- if (command === '-h' || command === '--help') {
232
- stdout.write(ALIAS_HELP);
233
- return 0;
234
- }
235
- if (command === 'add') {
236
- if (!name || extra.length) throw new Error('Usage: veo alias add <name> [--force] (or veo alias --help)');
237
- const created = await addAlias(name, { ...options, binDir, force });
238
- if (json) {
239
- stdout.write(`${JSON.stringify({ status: 'added', ...created })}\n`);
240
- return 0;
241
- }
242
- stdout.write(`Alias installed: ${created.name} -> veo (${created.binDir})\n`);
243
- stdout.write(`Run "${created.name} --help" to use it. Remove it with: veo alias remove ${created.name}\n`);
244
- return 0;
245
- }
246
- if (command === 'remove' || command === 'rm' || command === 'uninstall') {
247
- if (!name || extra.length) throw new Error('Usage: veo alias remove <name> (or veo alias --help)');
248
- const removed = await removeAlias(name, { ...options, binDir });
249
- if (json) {
250
- stdout.write(`${JSON.stringify({ status: 'removed', ...removed })}\n`);
251
- return 0;
252
- }
253
- stdout.write(`Alias removed: ${removed.name} (${removed.removed.length} file(s) in ${removed.binDir})\n`);
254
- if (removed.builtin) stdout.write('Note: veo update recreates the shipped veodl name.\n');
255
- return 0;
256
- }
257
- throw new Error(`Unknown alias command: ${cleanText(command)}. Use: veo alias list | veo alias add <name> | veo alias remove <name>`);
258
- }
1
+ import path from 'node:path';
2
+ import { chmod, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { commandOutput } from './output.js';
4
+ import { findOnPath } from './backend.js';
5
+ import { cleanText, readableError } from './utils.js';
6
+
7
+ export const BUILTIN_ALIASES = ['veo', 'veodl'];
8
+ export const ALIAS_MARKER = 'veo-alias';
9
+
10
+ export const ALIAS_HELP = `veo alias - manage extra command names for veo
11
+
12
+ Usage:
13
+ veo alias List command names (same as: veo alias list)
14
+ veo alias list [--json] Show veo, veodl plus custom wrappers
15
+ veo alias add <name> [--force] [--json]
16
+ Create <name> as a wrapper that calls veo
17
+ veo alias remove <name> [--json] Delete a wrapper (also: veo alias rm)
18
+ veo alias --help Show this help
19
+
20
+ Names use 2-31 lowercase letters, digits or hyphens and start with a letter,
21
+ e.g. veo-dl. The wrapper is created next to the veo command found on PATH and
22
+ simply forwards to veo, so it survives veo updates. Removing veodl only
23
+ removes that shortcut; veo update recreates the shipped names. veo itself can
24
+ never be removed. Use --bin-dir <path> to override the target directory.
25
+ `;
26
+
27
+ export function validateAliasName(input) {
28
+ const raw = String(input ?? '').trim();
29
+ if (!raw) throw new Error('An alias name is required. Use: veo alias add <name>');
30
+ if (cleanText(raw) !== raw) throw new Error(`Invalid alias name: ${raw}. Use plain text without escapes.`);
31
+ if (!/^[a-z][a-z0-9-]{1,30}$/.test(raw)) {
32
+ throw new Error(`Invalid alias name "${raw}". Use 2-31 lowercase letters, digits or hyphens, starting with a letter (e.g. veo-dl).`);
33
+ }
34
+ return raw;
35
+ }
36
+
37
+ export function aliasFiles(binDir, name, platform = process.platform) {
38
+ if (platform === 'win32') {
39
+ return [path.join(binDir, name), path.join(binDir, `${name}.cmd`), path.join(binDir, `${name}.ps1`)];
40
+ }
41
+ return [path.join(binDir, name)];
42
+ }
43
+
44
+ function posixWrapper(name) {
45
+ return `#!/bin/sh\n# ${ALIAS_MARKER}: ${name}\nexec veo "$@"\n`;
46
+ }
47
+
48
+ function windowsCmdWrapper(name) {
49
+ return `@echo off\r\nREM ${ALIAS_MARKER}: ${name}\r\nveo %*\r\n`;
50
+ }
51
+
52
+ function windowsShWrapper(name) {
53
+ return `#!/bin/sh\n# ${ALIAS_MARKER}: ${name}\nexec veo "$@"\n`;
54
+ }
55
+
56
+ function windowsPsWrapper(name) {
57
+ return `# ${ALIAS_MARKER}: ${name}\nveo @args\n`;
58
+ }
59
+
60
+ async function exists(file, { statImpl = stat } = {}) {
61
+ try {
62
+ const info = await statImpl(file);
63
+ return info.isFile();
64
+ } catch (error) {
65
+ if (error?.code === 'ENOENT') return false;
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ async function isManaged(file, { readFileImpl = readFile } = {}) {
71
+ try {
72
+ const text = await readFileImpl(file, 'utf8');
73
+ return text.slice(0, 4096).includes(ALIAS_MARKER);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ export async function resolveBinDir({ binDir, platform = process.platform, env = process.env, find = findOnPath } = {}) {
80
+ if (binDir) return path.resolve(binDir);
81
+ const found = await find(['veo'], { platform, env });
82
+ if (!found) {
83
+ throw new Error('Could not locate the veo command on PATH. Install veo globally (npm install -g veodl) or pass --bin-dir <path>.');
84
+ }
85
+ return path.dirname(found);
86
+ }
87
+
88
+ export async function aliasStatus(name, { binDir, platform = process.platform, ...fs } = {}) {
89
+ const files = aliasFiles(binDir, name, platform);
90
+ const present = [];
91
+ let managed = false;
92
+ for (const file of files) {
93
+ if (await exists(file, fs)) {
94
+ present.push(file);
95
+ if (await isManaged(file, fs)) managed = true;
96
+ }
97
+ }
98
+ return { name, builtin: BUILTIN_ALIASES.includes(name), present: present.length > 0, managed, paths: present };
99
+ }
100
+
101
+ function baseNameOf(file, platform) {
102
+ const base = path.basename(file);
103
+ if (platform === 'win32') {
104
+ if (base.toLowerCase().endsWith('.cmd')) return base.slice(0, -4);
105
+ if (base.toLowerCase().endsWith('.ps1')) return base.slice(0, -4);
106
+ }
107
+ return base;
108
+ }
109
+
110
+ export async function listAliases({ binDir, platform = process.platform, builtins = BUILTIN_ALIASES, readdirImpl = readdir, readFileImpl = readFile, statImpl = stat } = {}) {
111
+ const resolved = await resolveBinDir({ binDir, platform });
112
+ const entries = [];
113
+ for (const name of builtins) {
114
+ entries.push(await aliasStatus(name, { binDir: resolved, platform, readFileImpl, statImpl }));
115
+ }
116
+ const known = new Set(entries.map(entry => entry.name));
117
+ let files = [];
118
+ try {
119
+ files = await readdirImpl(resolved, { withFileTypes: true });
120
+ } catch (error) {
121
+ throw new Error(`Cannot list the command directory ${resolved}: ${readableError(error)}`);
122
+ }
123
+ for (const entry of files) {
124
+ if (!entry.isFile()) continue;
125
+ const full = path.join(resolved, entry.name);
126
+ let text = '';
127
+ try {
128
+ text = String(await readFileImpl(full, 'utf8')).slice(0, 4096);
129
+ } catch {
130
+ continue;
131
+ }
132
+ if (!text.includes(ALIAS_MARKER)) continue;
133
+ const base = baseNameOf(entry.name, platform);
134
+ try {
135
+ validateAliasName(base);
136
+ } catch {
137
+ continue;
138
+ }
139
+ if (known.has(base)) {
140
+ const existing = entries.find(item => item.name === base);
141
+ if (existing) existing.managed = true;
142
+ continue;
143
+ }
144
+ known.add(base);
145
+ entries.push({ name: base, builtin: false, present: true, managed: true, paths: [full] });
146
+ }
147
+ return { binDir: resolved, aliases: entries.sort((a, b) => a.name.localeCompare(b.name)) };
148
+ }
149
+
150
+ export async function addAlias(name, { binDir, platform = process.platform, force = false, writeFileImpl = writeFile, statImpl = stat, chmodImpl = chmod, mkdirImpl = mkdir } = {}) {
151
+ const valid = validateAliasName(name);
152
+ if (valid === 'veo') throw new Error('veo is already the main command.');
153
+ const resolved = await resolveBinDir({ binDir, platform });
154
+ await mkdirImpl(resolved, { recursive: true });
155
+ const files = aliasFiles(resolved, valid, platform);
156
+ const present = [];
157
+ for (const file of files) {
158
+ if (await exists(file, { statImpl })) present.push(file);
159
+ }
160
+ if (present.length && !force) {
161
+ throw new Error(`${valid} already exists (${present.map(file => path.basename(file)).join(', ')}). Use --force to replace it.`);
162
+ }
163
+ for (const file of present) await rm(file, { force: true });
164
+ if (platform === 'win32') {
165
+ await writeFileImpl(files[0], windowsShWrapper(valid), { mode: 0o755 });
166
+ await writeFileImpl(files[1], windowsCmdWrapper(valid));
167
+ await writeFileImpl(files[2], windowsPsWrapper(valid));
168
+ } else {
169
+ await writeFileImpl(files[0], posixWrapper(valid), { mode: 0o755 });
170
+ try {
171
+ await chmodImpl(files[0], 0o755);
172
+ } catch {
173
+ // Non-POSIX filesystems may ignore modes; the shebang still works.
174
+ }
175
+ }
176
+ return { name: valid, binDir: resolved, paths: aliasFiles(resolved, valid, platform) };
177
+ }
178
+
179
+ export async function removeAlias(name, { binDir, platform = process.platform, statImpl = stat, rmImpl = rm } = {}) {
180
+ const valid = validateAliasName(name);
181
+ if (valid === 'veo') throw new Error('Refusing to remove veo itself. Remove veodl or a custom wrapper instead.');
182
+ const resolved = await resolveBinDir({ binDir, platform });
183
+ const status = await aliasStatus(valid, { binDir: resolved, platform, statImpl });
184
+ if (!status.present) throw new Error(`${valid} is not installed in ${resolved}.`);
185
+ for (const file of status.paths) await rmImpl(file, { force: true });
186
+ return { name: valid, binDir: resolved, removed: status.paths, builtin: status.builtin };
187
+ }
188
+
189
+ function parseAliasArgs(args) {
190
+ let binDir;
191
+ let force = false;
192
+ let json = false;
193
+ const rest = [];
194
+ for (let index = 0; index < args.length; index++) {
195
+ const token = args[index];
196
+ if (token === '--force') force = true;
197
+ else if (token === '--json') json = true;
198
+ else if (token === '--bin-dir') {
199
+ binDir = args[++index];
200
+ if (!binDir) throw new Error('--bin-dir requires a directory. Run veo alias --help for usage.');
201
+ } else if (token.startsWith('--bin-dir=')) {
202
+ binDir = token.slice(10) || undefined;
203
+ if (!binDir) throw new Error('--bin-dir requires a directory. Run veo alias --help for usage.');
204
+ } else rest.push(token);
205
+ }
206
+ return { binDir, force, json, rest };
207
+ }
208
+
209
+ export async function aliasMain(args = [], { stdout = process.stdout, stderr = process.stderr, ...options } = {}) {
210
+ [args, stdout, stderr] = commandOutput(args, stdout, stderr);
211
+ const { binDir, force, json, rest } = parseAliasArgs(args);
212
+ const [command, name, ...extra] = rest;
213
+ if (!command || command === 'list') {
214
+ if (name || extra.length) throw new Error('Usage: veo alias list [--json] (or veo alias --help)');
215
+ if (command === 'list' && extra.length) throw new Error('Usage: veo alias list [--json] (or veo alias --help)');
216
+ const result = await listAliases({ ...options, binDir });
217
+ if (json) {
218
+ stdout.write(`${JSON.stringify(result)}\n`);
219
+ return 0;
220
+ }
221
+ stdout.write(`Command directory: ${result.binDir}\n`);
222
+ for (const entry of result.aliases) {
223
+ const kind = entry.builtin ? 'shipped' : 'custom';
224
+ const state = entry.present ? (entry.managed ? 'wrapper -> veo' : 'installed') : 'missing';
225
+ stdout.write(` ${entry.name} (${kind}, ${state})\n`);
226
+ }
227
+ const missing = result.aliases.filter(entry => !entry.present).map(entry => entry.name);
228
+ if (missing.length) stdout.write(`Missing: ${missing.join(', ')} (veo update recreates shipped names).\n`);
229
+ return 0;
230
+ }
231
+ if (command === '-h' || command === '--help') {
232
+ stdout.write(ALIAS_HELP);
233
+ return 0;
234
+ }
235
+ if (command === 'add') {
236
+ if (!name || extra.length) throw new Error('Usage: veo alias add <name> [--force] (or veo alias --help)');
237
+ const created = await addAlias(name, { ...options, binDir, force });
238
+ if (json) {
239
+ stdout.write(`${JSON.stringify({ status: 'added', ...created })}\n`);
240
+ return 0;
241
+ }
242
+ stdout.write(`Alias installed: ${created.name} -> veo (${created.binDir})\n`);
243
+ stdout.write(`Run "${created.name} --help" to use it. Remove it with: veo alias remove ${created.name}\n`);
244
+ return 0;
245
+ }
246
+ if (command === 'remove' || command === 'rm' || command === 'uninstall') {
247
+ if (!name || extra.length) throw new Error('Usage: veo alias remove <name> (or veo alias --help)');
248
+ const removed = await removeAlias(name, { ...options, binDir });
249
+ if (json) {
250
+ stdout.write(`${JSON.stringify({ status: 'removed', ...removed })}\n`);
251
+ return 0;
252
+ }
253
+ stdout.write(`Alias removed: ${removed.name} (${removed.removed.length} file(s) in ${removed.binDir})\n`);
254
+ if (removed.builtin) stdout.write('Note: veo update recreates the shipped veodl name.\n');
255
+ return 0;
256
+ }
257
+ throw new Error(`Unknown alias command: ${cleanText(command)}. Use: veo alias list | veo alias add <name> | veo alias remove <name>`);
258
+ }