veodl 1.12.1 → 2.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/AGENTS.md +12 -12
- package/CHANGELOG.md +404 -373
- package/LICENSE +21 -21
- package/README.md +1040 -910
- package/bin/veo.js +47 -2
- package/docs/AGENT_GUIDE.md +135 -122
- package/native/android-arm64/veo +0 -0
- package/native/darwin-arm64/veo +0 -0
- package/native/darwin-x64/veo +0 -0
- package/native/linux-arm64/veo +0 -0
- package/native/linux-x64/veo +0 -0
- package/native/win32-arm64/veo.exe +0 -0
- package/native/win32-x64/veo.exe +0 -0
- package/package.json +53 -57
- package/src/alias.js +0 -258
- package/src/backend-update.js +0 -191
- package/src/backend.js +0 -572
- package/src/changes.js +0 -278
- package/src/cli.js +0 -785
- package/src/compatibility.js +0 -39
- package/src/config-clipboard.js +0 -67
- package/src/config-diagnostics.js +0 -155
- package/src/config-editor.js +0 -404
- package/src/config-errors.js +0 -64
- package/src/config-reset.js +0 -39
- package/src/config-template.js +0 -227
- package/src/config.js +0 -307
- package/src/disk-space.js +0 -68
- package/src/doctor.js +0 -306
- package/src/download-cache.js +0 -31
- package/src/downloader.js +0 -704
- package/src/execution.js +0 -68
- package/src/experimental-fragments.js +0 -29
- package/src/flush.js +0 -66
- package/src/history.js +0 -173
- package/src/inline-region.js +0 -72
- package/src/inspect-media.js +0 -179
- package/src/interactive.js +0 -120
- package/src/jobs.js +0 -218
- package/src/legacy-config-comments.js +0 -106
- package/src/media-assets.js +0 -272
- package/src/naming.js +0 -54
- package/src/open-file.js +0 -15
- package/src/option-suggestions.js +0 -43
- package/src/output.js +0 -81
- package/src/path-links.js +0 -53
- package/src/paths.js +0 -26
- package/src/playlist.js +0 -31
- package/src/progress.js +0 -341
- package/src/run-archive.js +0 -148
- package/src/runs.js +0 -346
- package/src/source-discovery.js +0 -305
- package/src/state.js +0 -27
- package/src/stats.js +0 -63
- package/src/terminal-size.js +0 -8
- package/src/terminal-title.js +0 -19
- package/src/tool-setup.js +0 -289
- package/src/uninstall.js +0 -222
- package/src/updater.js +0 -229
- package/src/utils.js +0 -208
- package/src/version.js +0 -38
package/src/stats.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { commandOutput } from './output.js';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { readdir, lstat, rm } from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { cacheBase } from './paths.js';
|
|
6
|
-
import { readJson, writeJson } from './state.js';
|
|
7
|
-
|
|
8
|
-
const fields = ['videos', 'audio', 'failed', 'skipped', 'cancelled', 'elapsedMs'];
|
|
9
|
-
const pattern = /^[a-f0-9-]{36}\.json$/;
|
|
10
|
-
const empty = () => Object.fromEntries(fields.map(key => [key, 0]));
|
|
11
|
-
|
|
12
|
-
export function createStatsRecorder(root = cacheBase()) {
|
|
13
|
-
const file = path.join(root, 'stats', `${randomUUID()}.json`);
|
|
14
|
-
const totals = { version: 1, since: new Date().toISOString(), ...empty() };
|
|
15
|
-
return async delta => {
|
|
16
|
-
for (const key of fields) totals[key] += delta[key] || 0;
|
|
17
|
-
await writeJson(file, totals);
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function statFiles(root) {
|
|
22
|
-
const directory = path.resolve(root, 'stats');
|
|
23
|
-
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
24
|
-
if (!info) return [];
|
|
25
|
-
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Invalid statistics directory: ${directory}`);
|
|
26
|
-
return (await readdir(directory, { withFileTypes: true }))
|
|
27
|
-
.filter(entry => entry.isFile() && pattern.test(entry.name)).map(entry => path.join(directory, entry.name));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function readStats(root = cacheBase()) {
|
|
31
|
-
const result = { since: null, ...empty() };
|
|
32
|
-
for (const file of await statFiles(root)) {
|
|
33
|
-
const value = await readJson(file, null);
|
|
34
|
-
if (!value) continue;
|
|
35
|
-
if (value.version !== 1 || !Number.isFinite(Date.parse(value.since)) || fields.some(key => !Number.isFinite(value[key]) || value[key] < 0)) {
|
|
36
|
-
throw new Error(`Invalid statistics file: ${file}`);
|
|
37
|
-
}
|
|
38
|
-
if (!result.since || value.since < result.since) result.since = value.since;
|
|
39
|
-
for (const key of fields) result[key] += value[key];
|
|
40
|
-
}
|
|
41
|
-
return result;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Called by flush only after registered runs have stopped and while its gate is held.
|
|
45
|
-
export async function resetStats(root = cacheBase()) {
|
|
46
|
-
for (const file of await statFiles(root)) await rm(file, { force: true });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function statsMain(args = [], { stdout = process.stdout, root = cacheBase() } = {}) {
|
|
50
|
-
[args, stdout] = commandOutput(args, stdout);
|
|
51
|
-
if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
|
|
52
|
-
stdout.write('veo stats [--json]\n\nShow recorded download totals. Time includes preparation, processing and saving,\nincluding failed attempts. Parallel run times are added together.\nStats start with this version; old downloads are not imported.\nUse veo flush --stats to clear temporary data and reset statistics.\n');
|
|
53
|
-
return 0;
|
|
54
|
-
}
|
|
55
|
-
if (args.length && (args.length !== 1 || args[0] !== '--json')) throw new Error('Usage: veo stats [--json]');
|
|
56
|
-
const stats = await readStats(root);
|
|
57
|
-
if (args[0] === '--json') stdout.write(`${JSON.stringify(stats)}\n`);
|
|
58
|
-
else {
|
|
59
|
-
const seconds = Math.floor(stats.elapsedMs / 1000);
|
|
60
|
-
stdout.write(`veo stats\n\nTracking since: ${stats.since || 'No downloads recorded yet'}\nVideos saved: ${stats.videos}\nAudio saved: ${stats.audio}\nTotal failures: ${stats.failed}\nSkipped: ${stats.skipped}\nCancelled: ${stats.cancelled}\nDownload time: ${Math.floor(seconds / 3600)}h ${Math.floor(seconds / 60) % 60}m ${seconds % 60}s\n\nTime includes preparation, processing and saving. Active requests appear when finished.\n`);
|
|
61
|
-
}
|
|
62
|
-
return 0;
|
|
63
|
-
}
|
package/src/terminal-size.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// Windows stderr can retain an old size between writes. Refresh the native TTY
|
|
2
|
-
// before reading it; custom/test streams retain their own live columns getter.
|
|
3
|
-
export function terminalColumns(stream) {
|
|
4
|
-
if (process.platform === 'win32' && (stream === process.stdout || stream === process.stderr)) {
|
|
5
|
-
try { stream._refreshSize?.(); } catch { /* Cosmetic fallback only. */ }
|
|
6
|
-
}
|
|
7
|
-
return Number.isInteger(stream.columns) && stream.columns > 0 ? stream.columns : 80;
|
|
8
|
-
}
|
package/src/terminal-title.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
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/tool-setup.js
DELETED
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { constants, createReadStream, createWriteStream } from 'node:fs';
|
|
4
|
-
import { chmod, lstat, mkdir, mkdtemp, open, rm } from 'node:fs/promises';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { Readable, Transform, Writable } from 'node:stream';
|
|
7
|
-
import { pipeline } from 'node:stream/promises';
|
|
8
|
-
import { createGunzip } from 'node:zlib';
|
|
9
|
-
import { MEDIA_ASSETS, MEDIA_ASSET_BASE } from './media-assets.js';
|
|
10
|
-
import { cleanText } from './utils.js';
|
|
11
|
-
|
|
12
|
-
// Only fixed package names and arguments are passed to these installers.
|
|
13
|
-
export function runSetup(command, args, {
|
|
14
|
-
signal, env = process.env, cwd, status = () => {}, timeoutMs = 600_000,
|
|
15
|
-
} = {}) {
|
|
16
|
-
signal?.throwIfAborted();
|
|
17
|
-
return new Promise((resolve, reject) => {
|
|
18
|
-
const child = spawn(command, args, {
|
|
19
|
-
shell: false, windowsHide: true, detached: process.platform !== 'win32',
|
|
20
|
-
stdio: ['ignore', 'pipe', 'pipe'], env, cwd,
|
|
21
|
-
});
|
|
22
|
-
let output = '';
|
|
23
|
-
let stopped;
|
|
24
|
-
const stop = error => {
|
|
25
|
-
if (stopped) return;
|
|
26
|
-
stopped = error;
|
|
27
|
-
if (process.platform === 'win32' && child.pid) {
|
|
28
|
-
const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { shell: false, windowsHide: true, stdio: 'ignore' });
|
|
29
|
-
killer.on('error', () => child.kill());
|
|
30
|
-
} else {
|
|
31
|
-
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
const abort = () => stop(signal.reason);
|
|
35
|
-
const timer = setTimeout(() => stop(new Error('Tool installation timed out. Retry with veo doctor fix.')), timeoutMs);
|
|
36
|
-
const receive = chunk => {
|
|
37
|
-
const text = cleanText(String(chunk));
|
|
38
|
-
output = (output + text).slice(-8192);
|
|
39
|
-
// Surface package-manager progress without taking over stdin or stdout.
|
|
40
|
-
for (const line of text.split(/[\r\n]+/)) if (line.trim()) status(line.slice(0, 500));
|
|
41
|
-
};
|
|
42
|
-
child.stdout.on('data', receive);
|
|
43
|
-
child.stderr.on('data', receive);
|
|
44
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
45
|
-
if (signal?.aborted) abort();
|
|
46
|
-
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
|
|
47
|
-
child.once('error', error => { cleanup(); reject(stopped || error); });
|
|
48
|
-
child.once('close', code => {
|
|
49
|
-
cleanup();
|
|
50
|
-
if (stopped) reject(stopped);
|
|
51
|
-
else if (code !== 0) reject(new Error(`${path.basename(command)} exited with code ${code}: ${output.trim()}`));
|
|
52
|
-
else resolve(output);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export async function hasTermuxEjs({ find, run = runSetup, signal }) {
|
|
58
|
-
const python = await find(['python', 'python3']);
|
|
59
|
-
if (!python) return false;
|
|
60
|
-
try {
|
|
61
|
-
await run(python, ['-B', '-c', 'import yt_dlp_ejs'], { signal, timeoutMs: 15_000 });
|
|
62
|
-
return true;
|
|
63
|
-
} catch {
|
|
64
|
-
signal?.throwIfAborted();
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let termuxInstall;
|
|
70
|
-
export async function installTermuxTools({ find, signal, status, run = runSetup, env = process.env }) {
|
|
71
|
-
if (termuxInstall) {
|
|
72
|
-
await termuxInstall;
|
|
73
|
-
signal?.throwIfAborted();
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
termuxInstall = (async () => {
|
|
77
|
-
const pkg = await find(['pkg']);
|
|
78
|
-
if (!pkg) throw new Error('Automatic Android setup requires Termux with pkg on PATH. Install python-yt-dlp, yt-dlp-ejs and ffmpeg in Termux.');
|
|
79
|
-
signal?.throwIfAborted();
|
|
80
|
-
status('Installing yt-dlp, JavaScript support and FFmpeg with Termux pkg (first use)…');
|
|
81
|
-
// pkg refreshes mirrors/package lists itself. No full system upgrade or sudo.
|
|
82
|
-
await run(pkg, ['install', env.TERMUX_APP_PACKAGE_MANAGER === 'pacman' ? '--noconfirm' : '-y', 'python-yt-dlp', 'yt-dlp-ejs', 'ffmpeg'], {
|
|
83
|
-
signal, status, env: { ...env, DEBIAN_FRONTEND: 'noninteractive' },
|
|
84
|
-
});
|
|
85
|
-
})();
|
|
86
|
-
try { await termuxInstall; }
|
|
87
|
-
catch (cause) {
|
|
88
|
-
signal?.throwIfAborted();
|
|
89
|
-
throw new Error(`Automatic Termux setup failed: ${cause.message}. Retry with veo doctor fix; if the repository needs repair, run pkg update in Termux.`, { cause });
|
|
90
|
-
} finally { termuxInstall = undefined; }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function mediaPackagePlan(platform = process.platform, arch = process.arch) {
|
|
94
|
-
// Windows 11 ARM runs the upstream x64 media tools via OS emulation.
|
|
95
|
-
const binaryArch = platform === 'win32' && arch === 'arm64' ? 'x64' : arch;
|
|
96
|
-
const assets = MEDIA_ASSETS[`${platform}-${binaryArch}`];
|
|
97
|
-
return assets ? { binaryArch, ...assets } : undefined;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const DOWNLOAD_TIMEOUT_MS = 600_000;
|
|
101
|
-
const MEDIA_ORIGINS = new Set(['https://github.com', 'https://release-assets.githubusercontent.com']);
|
|
102
|
-
|
|
103
|
-
function validateAsset(asset) {
|
|
104
|
-
if (!asset || typeof asset.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(asset.name) || asset.name.includes('..')) {
|
|
105
|
-
throw new Error('Invalid media asset name.');
|
|
106
|
-
}
|
|
107
|
-
if (!Number.isSafeInteger(asset.size) || asset.size <= 0 || typeof asset.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(asset.sha256)) {
|
|
108
|
-
throw new Error(`Invalid pinned integrity metadata for ${asset.name}.`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function integrityStream(asset) {
|
|
113
|
-
validateAsset(asset);
|
|
114
|
-
const hash = createHash('sha256');
|
|
115
|
-
let size = 0;
|
|
116
|
-
return new Transform({
|
|
117
|
-
transform(chunk, _encoding, callback) {
|
|
118
|
-
size += chunk.length;
|
|
119
|
-
if (size > asset.size) return callback(new Error(`Unexpected size for ${asset.name}: exceeds pinned byte count.`));
|
|
120
|
-
hash.update(chunk);
|
|
121
|
-
callback(null, chunk);
|
|
122
|
-
},
|
|
123
|
-
flush(callback) {
|
|
124
|
-
if (size !== asset.size) return callback(new Error(`Unexpected size for ${asset.name}: does not match pinned byte count.`));
|
|
125
|
-
if (hash.digest('hex') !== asset.sha256) return callback(new Error(`Integrity verification failed for ${asset.name}: SHA-256 mismatch.`));
|
|
126
|
-
callback();
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function downloadUrl(value) {
|
|
132
|
-
const url = new URL(value);
|
|
133
|
-
if (!MEDIA_ORIGINS.has(url.origin) || url.username || url.password) throw new Error('Media download redirect must remain on an approved HTTPS origin.');
|
|
134
|
-
return url;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function mediaResponse(url, { signal, fetchImpl }) {
|
|
138
|
-
let current = downloadUrl(url);
|
|
139
|
-
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
140
|
-
signal.throwIfAborted();
|
|
141
|
-
const response = await fetchImpl(current.href, {
|
|
142
|
-
signal, redirect: 'manual', headers: { 'Accept-Encoding': 'identity' },
|
|
143
|
-
});
|
|
144
|
-
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
145
|
-
await response.body?.cancel?.().catch(() => {});
|
|
146
|
-
const location = response.headers.get('location');
|
|
147
|
-
if (!location || redirects === 5) throw new Error('Media download has an invalid or excessive redirect.');
|
|
148
|
-
current = downloadUrl(new URL(location, current));
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (response.status !== 200) {
|
|
152
|
-
await response.body?.cancel?.().catch(() => {});
|
|
153
|
-
throw new Error(`Media download failed with HTTP ${response.status}.`);
|
|
154
|
-
}
|
|
155
|
-
if (!response.body) throw new Error('Media download response has no body.');
|
|
156
|
-
return response;
|
|
157
|
-
}
|
|
158
|
-
throw new Error('Media download has an excessive redirect.');
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// This reusable primitive receives pinned metadata from its caller. Desktop
|
|
162
|
-
// setup supplies only the release manifest shipped with veo, never env values.
|
|
163
|
-
export async function downloadMediaAsset({
|
|
164
|
-
asset, release, destination, signal, fetchImpl = fetch, timeoutMs = DOWNLOAD_TIMEOUT_MS,
|
|
165
|
-
}) {
|
|
166
|
-
validateAsset(asset);
|
|
167
|
-
if (!/^b\d+\.\d+(?:\.\d+)?$/.test(release)) throw new Error('Invalid pinned media release.');
|
|
168
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DOWNLOAD_TIMEOUT_MS) throw new Error('Invalid media download timeout.');
|
|
169
|
-
if (asset.gzip) validateAsset(asset.gzip);
|
|
170
|
-
signal?.throwIfAborted();
|
|
171
|
-
const deadline = new AbortController();
|
|
172
|
-
const timer = setTimeout(() => deadline.abort(new DOMException('Media download timed out.', 'TimeoutError')), timeoutMs);
|
|
173
|
-
const downloadSignal = AbortSignal.any([deadline.signal, ...(signal ? [signal] : [])]);
|
|
174
|
-
const transferAsset = asset.gzip || asset;
|
|
175
|
-
const compressed = asset.gzip ? `${destination}.gz` : undefined;
|
|
176
|
-
const transferDestination = compressed || destination;
|
|
177
|
-
let createdTransfer = false;
|
|
178
|
-
let createdDestination = false;
|
|
179
|
-
try {
|
|
180
|
-
const response = await mediaResponse(`${MEDIA_ASSET_BASE}/${release}/${transferAsset.name}`, { signal: downloadSignal, fetchImpl });
|
|
181
|
-
const length = response.headers.get('content-length');
|
|
182
|
-
if (length !== null && (!/^\d+$/.test(length) || Number(length) !== transferAsset.size)) {
|
|
183
|
-
await response.body.cancel?.().catch(() => {});
|
|
184
|
-
throw new Error(`Unexpected content length for ${transferAsset.name}: does not match pinned byte count.`);
|
|
185
|
-
}
|
|
186
|
-
const transfer = createWriteStream(transferDestination, { flags: 'wx', mode: 0o600 });
|
|
187
|
-
transfer.once('open', () => { createdTransfer = true; });
|
|
188
|
-
const body = typeof response.body.getReader === 'function' ? Readable.fromWeb(response.body) : Readable.from(response.body);
|
|
189
|
-
await pipeline(body, integrityStream(transferAsset), transfer, { signal: downloadSignal });
|
|
190
|
-
if (asset.gzip) {
|
|
191
|
-
const output = createWriteStream(destination, { flags: 'wx', mode: 0o600 });
|
|
192
|
-
output.once('open', () => { createdDestination = true; });
|
|
193
|
-
await pipeline(createReadStream(compressed), createGunzip(), integrityStream(asset), output, { signal: downloadSignal });
|
|
194
|
-
}
|
|
195
|
-
downloadSignal.throwIfAborted();
|
|
196
|
-
return destination;
|
|
197
|
-
} catch (error) {
|
|
198
|
-
if (createdDestination) await rm(destination, { force: true });
|
|
199
|
-
if (createdTransfer) await rm(transferDestination, { force: true });
|
|
200
|
-
downloadSignal.throwIfAborted();
|
|
201
|
-
throw error;
|
|
202
|
-
} finally {
|
|
203
|
-
clearTimeout(timer);
|
|
204
|
-
if (compressed && createdTransfer) await rm(compressed, { force: true });
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async function verifyMediaFile(file, asset, signal) {
|
|
209
|
-
signal?.throwIfAborted();
|
|
210
|
-
const info = await lstat(file);
|
|
211
|
-
if (!info.isFile() || info.isSymbolicLink() || info.size !== asset.size) return false;
|
|
212
|
-
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
213
|
-
try {
|
|
214
|
-
const opened = await handle.stat();
|
|
215
|
-
if (!opened.isFile() || opened.size !== asset.size || opened.dev !== info.dev || opened.ino !== info.ino) return false;
|
|
216
|
-
await pipeline(handle.createReadStream({ autoClose: false }), integrityStream(asset), new Writable({ write(_chunk, _encoding, callback) { callback(); } }), { signal });
|
|
217
|
-
const after = await lstat(file);
|
|
218
|
-
return after.isFile() && !after.isSymbolicLink() && after.dev === opened.dev && after.ino === opened.ino && after.size === opened.size && after.mtimeMs === opened.mtimeMs;
|
|
219
|
-
} finally {
|
|
220
|
-
await handle.close();
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export async function verifiedMediaTools({ directory, platform = process.platform, arch = process.arch, signal }) {
|
|
225
|
-
const plan = mediaPackagePlan(platform, arch);
|
|
226
|
-
if (!plan) return false;
|
|
227
|
-
const suffix = platform === 'win32' ? '.exe' : '';
|
|
228
|
-
try {
|
|
229
|
-
for (const tool of ['ffmpeg', 'ffprobe']) {
|
|
230
|
-
if (!await verifyMediaFile(path.join(directory, `${tool}${suffix}`), plan[tool], signal)) return false;
|
|
231
|
-
}
|
|
232
|
-
return true;
|
|
233
|
-
} catch {
|
|
234
|
-
signal?.throwIfAborted();
|
|
235
|
-
return false;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export function mediaProbeEnvironment(env = process.env) {
|
|
240
|
-
// Native version probes do not need any uploader configuration or secrets.
|
|
241
|
-
return Object.fromEntries(Object.entries(env).filter(([name]) => !/^(?:SMOLUP|SMOP|SMUP)_/i.test(name)));
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export async function installMediaTools({
|
|
245
|
-
directory, stage, signal, status = () => {}, platform = process.platform, arch = process.arch,
|
|
246
|
-
env = process.env, run = runSetup, fetchImpl = fetch,
|
|
247
|
-
}) {
|
|
248
|
-
const plan = mediaPackagePlan(platform, arch);
|
|
249
|
-
if (!plan) throw new Error(`Automatic FFmpeg setup is unavailable for ${platform}/${arch}. Set VEO_FFMPEG_PATH to a directory containing ffmpeg and ffprobe.`);
|
|
250
|
-
signal?.throwIfAborted();
|
|
251
|
-
const setupSignal = AbortSignal.any([AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), ...(signal ? [signal] : [])]);
|
|
252
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
253
|
-
const temporary = await mkdtemp(path.join(directory, '.media-setup-'));
|
|
254
|
-
const suffix = platform === 'win32' ? '.exe' : '';
|
|
255
|
-
try {
|
|
256
|
-
if (process.platform !== 'win32') await chmod(temporary, 0o700);
|
|
257
|
-
status('Downloading FFmpeg and FFprobe into the veo cache (first use)…');
|
|
258
|
-
const files = {
|
|
259
|
-
license: path.join(temporary, 'FFmpeg.LICENSE'),
|
|
260
|
-
readme: path.join(temporary, 'FFmpeg.README'),
|
|
261
|
-
ffmpeg: path.join(temporary, `ffmpeg${suffix}`),
|
|
262
|
-
ffprobe: path.join(temporary, `ffprobe${suffix}`),
|
|
263
|
-
};
|
|
264
|
-
for (const name of ['license', 'readme', 'ffmpeg', 'ffprobe']) {
|
|
265
|
-
await downloadMediaAsset({ asset: plan[name], release: plan.release, destination: files[name], signal: setupSignal, fetchImpl });
|
|
266
|
-
}
|
|
267
|
-
// Verify the whole pair before either downloaded program may execute.
|
|
268
|
-
if (!await verifiedMediaTools({ directory: temporary, platform, arch, signal: setupSignal })) throw new Error('Downloaded media tools failed pinned integrity verification.');
|
|
269
|
-
for (const name of ['license', 'readme']) {
|
|
270
|
-
if (!await verifyMediaFile(files[name], plan[name], setupSignal)) throw new Error('Downloaded media notices failed pinned integrity verification.');
|
|
271
|
-
}
|
|
272
|
-
for (const name of ['ffmpeg', 'ffprobe']) {
|
|
273
|
-
if (process.platform !== 'win32') await chmod(files[name], 0o700);
|
|
274
|
-
await run(files[name], ['-version'], { signal: setupSignal, env: mediaProbeEnvironment(env), timeoutMs: 15_000 });
|
|
275
|
-
}
|
|
276
|
-
for (const name of ['ffmpeg', 'ffprobe', 'license', 'readme']) {
|
|
277
|
-
await stage(files[name], path.join(directory, path.basename(files[name])), setupSignal);
|
|
278
|
-
}
|
|
279
|
-
if (!await verifiedMediaTools({ directory, platform, arch, signal: setupSignal })) throw new Error('Staged media tools failed pinned integrity verification.');
|
|
280
|
-
for (const name of ['license', 'readme']) {
|
|
281
|
-
if (!await verifyMediaFile(path.join(directory, path.basename(files[name])), plan[name], setupSignal)) throw new Error('Staged media notices failed pinned integrity verification.');
|
|
282
|
-
}
|
|
283
|
-
setupSignal.throwIfAborted();
|
|
284
|
-
return directory;
|
|
285
|
-
} catch (cause) {
|
|
286
|
-
signal?.throwIfAborted();
|
|
287
|
-
throw new Error(`Automatic FFmpeg setup failed: ${cause.message}. Retry with veo doctor fix or set VEO_FFMPEG_PATH to installed tools.`, { cause });
|
|
288
|
-
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
289
|
-
}
|
package/src/uninstall.js
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import { rm } from 'node:fs/promises';
|
|
4
|
-
import { commandOutput } from './output.js';
|
|
5
|
-
import { cacheBase } from './paths.js';
|
|
6
|
-
import { configFile as resolveConfigFile } from './config.js';
|
|
7
|
-
import { cleanText, readableError } from './utils.js';
|
|
8
|
-
import { removeAlias, validateAliasName, listAliases } from './alias.js';
|
|
9
|
-
|
|
10
|
-
const PACKAGE = 'veodl';
|
|
11
|
-
|
|
12
|
-
export const UNINSTALL_HELP = `veo uninstall - remove veo completely or one command alias
|
|
13
|
-
|
|
14
|
-
Usage:
|
|
15
|
-
veo uninstall [-p|--prefix <name>] [--json]
|
|
16
|
-
Remove one alias (e.g. veo uninstall -p veodl)
|
|
17
|
-
veo uninstall [--yes] [--keep-cache] [--keep-config] [--keep-aliases] [--json]
|
|
18
|
-
Remove everything: aliases, package, cache, config
|
|
19
|
-
veo uninstall --help Show this help
|
|
20
|
-
|
|
21
|
-
With -p/--prefix, only that alias is deleted; veo itself stays installed.
|
|
22
|
-
Without -p, veo prints what would be removed. Add --yes to delete everything:
|
|
23
|
-
custom wrappers created with veo alias add, the ${PACKAGE} package itself
|
|
24
|
-
(commands veo, veodl) with npm, veo's cache directory (downloads, jobs,
|
|
25
|
-
backend tools, history, statistics) and the config file. Use --keep-cache,
|
|
26
|
-
--keep-config or --keep-aliases to preserve one part. The config file is
|
|
27
|
-
deleted by default; its path is shown before and after.
|
|
28
|
-
`;
|
|
29
|
-
|
|
30
|
-
export function npmUninstallCommand({ platform = process.platform } = {}) {
|
|
31
|
-
const args = ['uninstall', '-g', PACKAGE];
|
|
32
|
-
if (platform === 'win32') {
|
|
33
|
-
return {
|
|
34
|
-
command: 'cmd.exe',
|
|
35
|
-
args: ['/d', '/s', '/c', `npm ${args.join(' ')}`],
|
|
36
|
-
shell: false,
|
|
37
|
-
windowsVerbatimArguments: true,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
return { command: 'npm', args, shell: false };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function runNpmUninstall({ spawnImpl = spawn, platform = process.platform, signal, timeoutMs = 600_000 } = {}) {
|
|
44
|
-
const { command, args, ...spawnOptions } = npmUninstallCommand({ platform });
|
|
45
|
-
return new Promise((resolve, reject) => {
|
|
46
|
-
const child = spawnImpl(command, args, { ...spawnOptions, stdio: 'inherit', windowsHide: true, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
|
|
47
|
-
child.once('error', reject);
|
|
48
|
-
child.once('close', resolve);
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function parseUninstallArgs(args) {
|
|
53
|
-
let prefix;
|
|
54
|
-
let yes = false;
|
|
55
|
-
let keepCache = false;
|
|
56
|
-
let keepConfig = false;
|
|
57
|
-
let keepAliases = false;
|
|
58
|
-
let json = false;
|
|
59
|
-
let binDir;
|
|
60
|
-
const rest = [];
|
|
61
|
-
for (let index = 0; index < args.length; index++) {
|
|
62
|
-
const token = args[index];
|
|
63
|
-
if (token === '-p' || token === '--prefix') {
|
|
64
|
-
prefix = args[++index];
|
|
65
|
-
if (!prefix) throw new Error('--prefix requires an alias name. Use: veo uninstall -p veodl');
|
|
66
|
-
} else if (token.startsWith('-p=')) {
|
|
67
|
-
prefix = token.slice(3) || undefined;
|
|
68
|
-
if (!prefix) throw new Error('--prefix requires an alias name. Use: veo uninstall -p veodl');
|
|
69
|
-
} else if (token.startsWith('--prefix=')) {
|
|
70
|
-
prefix = token.slice(9) || undefined;
|
|
71
|
-
if (!prefix) throw new Error('--prefix requires an alias name. Use: veo uninstall -p veodl');
|
|
72
|
-
} else if (token === '--yes') yes = true;
|
|
73
|
-
else if (token === '--purge') {
|
|
74
|
-
// Deprecated: full cleanup is now the default. Kept so older commands keep working.
|
|
75
|
-
} else if (token === '--keep-cache') keepCache = true;
|
|
76
|
-
else if (token === '--keep-config') keepConfig = true;
|
|
77
|
-
else if (token === '--keep-aliases') keepAliases = true;
|
|
78
|
-
else if (token === '--json') json = true;
|
|
79
|
-
else if (token === '--bin-dir') {
|
|
80
|
-
binDir = args[++index];
|
|
81
|
-
if (!binDir) throw new Error('--bin-dir requires a directory.');
|
|
82
|
-
} else if (token.startsWith('--bin-dir=')) {
|
|
83
|
-
binDir = token.slice(10) || undefined;
|
|
84
|
-
if (!binDir) throw new Error('--bin-dir requires a directory.');
|
|
85
|
-
} else rest.push(token);
|
|
86
|
-
}
|
|
87
|
-
return { prefix, yes, keepCache, keepConfig, keepAliases, json, binDir, rest };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export async function uninstallMain(args = [], {
|
|
91
|
-
stdout = process.stdout,
|
|
92
|
-
stderr = process.stderr,
|
|
93
|
-
platform = process.platform,
|
|
94
|
-
env = process.env,
|
|
95
|
-
spawnImpl = spawn,
|
|
96
|
-
rmImpl = rm,
|
|
97
|
-
cacheRoot,
|
|
98
|
-
configFile,
|
|
99
|
-
listImpl = listAliases,
|
|
100
|
-
removeImpl = removeAlias,
|
|
101
|
-
} = {}) {
|
|
102
|
-
[args, stdout, stderr] = commandOutput(args, stdout, stderr);
|
|
103
|
-
const { prefix, yes, keepCache, keepConfig, keepAliases, json, binDir, rest } = parseUninstallArgs(args);
|
|
104
|
-
if (rest.includes('-h') || rest.includes('--help')) {
|
|
105
|
-
stdout.write(UNINSTALL_HELP);
|
|
106
|
-
return 0;
|
|
107
|
-
}
|
|
108
|
-
if (rest.length) throw new Error(`Unknown option for veo uninstall: ${rest.join(' ')}. Use: veo uninstall --help`);
|
|
109
|
-
|
|
110
|
-
if (prefix) {
|
|
111
|
-
if (keepCache || keepConfig || keepAliases) throw new Error('--keep-* cannot be combined with --prefix. Remove one alias or uninstall veo itself.');
|
|
112
|
-
const valid = validateAliasName(prefix);
|
|
113
|
-
const removed = await removeImpl(valid, { binDir, platform, env });
|
|
114
|
-
if (json) {
|
|
115
|
-
stdout.write(`${JSON.stringify({ status: 'removed', ...removed })}\n`);
|
|
116
|
-
return 0;
|
|
117
|
-
}
|
|
118
|
-
stdout.write(`Alias removed: ${removed.name} (${removed.removed.length} file(s) in ${removed.binDir})\n`);
|
|
119
|
-
if (removed.builtin) stdout.write('Note: veo update recreates the shipped veodl name.\n');
|
|
120
|
-
return 0;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const cache = cacheRoot || cacheBase({ platform, env });
|
|
124
|
-
const config = configFile || resolveConfigFile({ platform, env });
|
|
125
|
-
const manual = `npm uninstall -g ${PACKAGE}`;
|
|
126
|
-
|
|
127
|
-
// Custom wrappers first so the plan names them; npm removes veo/veodl itself.
|
|
128
|
-
let customAliases = [];
|
|
129
|
-
let aliasDir = binDir ? path.resolve(binDir) : null;
|
|
130
|
-
try {
|
|
131
|
-
const listed = await listImpl({ binDir: binDir || undefined, platform, env });
|
|
132
|
-
aliasDir = listed.binDir;
|
|
133
|
-
customAliases = listed.aliases.filter(entry => entry.present && entry.managed && !entry.builtin).map(entry => entry.name);
|
|
134
|
-
} catch {
|
|
135
|
-
// No bin directory (e.g. running from source without a global install):
|
|
136
|
-
// npm uninstall still works, alias cleanup is reported as skipped.
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (!yes) {
|
|
140
|
-
if (json) {
|
|
141
|
-
stdout.write(`${JSON.stringify({ status: 'planned', package: PACKAGE, manual, aliases: customAliases, binDir: aliasDir, cache, config })}\n`);
|
|
142
|
-
return 0;
|
|
143
|
-
}
|
|
144
|
-
stdout.write(`This removes everything: the ${PACKAGE} package with npm (commands veo, veodl).\n`);
|
|
145
|
-
if (!keepAliases) {
|
|
146
|
-
stdout.write(customAliases.length
|
|
147
|
-
? `Custom wrappers to delete: ${customAliases.join(', ')} (${aliasDir})\n`
|
|
148
|
-
: `Custom wrappers: none found${aliasDir ? ` (${aliasDir})` : ''}.\n`);
|
|
149
|
-
}
|
|
150
|
-
if (!keepCache) stdout.write(`Cache to delete: ${cache}\n`);
|
|
151
|
-
if (!keepConfig) stdout.write(`Config to delete: ${config}\n`);
|
|
152
|
-
if (keepCache || keepConfig || keepAliases) {
|
|
153
|
-
const kept = [
|
|
154
|
-
keepAliases && 'aliases',
|
|
155
|
-
keepCache && 'cache',
|
|
156
|
-
keepConfig && 'config',
|
|
157
|
-
].filter(Boolean).join(', ');
|
|
158
|
-
stdout.write(`Kept: ${kept}.\n`);
|
|
159
|
-
}
|
|
160
|
-
const keepFlags = `${keepAliases ? ' --keep-aliases' : ''}${keepCache ? ' --keep-cache' : ''}${keepConfig ? ' --keep-config' : ''}`;
|
|
161
|
-
stdout.write(`Run with --yes to proceed: veo uninstall --yes${keepFlags}\n`);
|
|
162
|
-
stdout.write(`Manual command: ${manual}\n`);
|
|
163
|
-
return 0;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
let code;
|
|
167
|
-
try {
|
|
168
|
-
stdout.write(`Uninstalling ${PACKAGE} with npm…\n`);
|
|
169
|
-
code = await runNpmUninstall({ spawnImpl, platform, signal: AbortSignal.timeout(600_000) });
|
|
170
|
-
} catch (error) {
|
|
171
|
-
const reason = error.code === 'ENOENT' ? 'npm was not found. Install Node.js/npm, or uninstall manually.' : `Could not run npm: ${readableError(error)}`;
|
|
172
|
-
stderr.write(`veo: ${reason}\nManual command: ${manual}\n`);
|
|
173
|
-
return 1;
|
|
174
|
-
}
|
|
175
|
-
if (code !== 0) {
|
|
176
|
-
stderr.write(`veo: npm exited with code ${code}. Uninstall manually with: ${manual}\n`);
|
|
177
|
-
return 1;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const removedAliases = [];
|
|
181
|
-
if (!keepAliases) {
|
|
182
|
-
for (const name of customAliases) {
|
|
183
|
-
try {
|
|
184
|
-
await removeImpl(name, { binDir: aliasDir, platform, env });
|
|
185
|
-
removedAliases.push(name);
|
|
186
|
-
} catch (error) {
|
|
187
|
-
stderr.write(`veo: removed the package but could not delete alias ${name}: ${readableError(error)}\n`);
|
|
188
|
-
return 1;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
let cacheDeleted = false;
|
|
193
|
-
if (!keepCache) {
|
|
194
|
-
try {
|
|
195
|
-
await rmImpl(cache, { recursive: true, force: true });
|
|
196
|
-
cacheDeleted = true;
|
|
197
|
-
} catch (error) {
|
|
198
|
-
stderr.write(`veo: removed the package but could not delete the cache ${cache}: ${readableError(error)}\n`);
|
|
199
|
-
return 1;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
let configDeleted = false;
|
|
203
|
-
if (!keepConfig) {
|
|
204
|
-
try {
|
|
205
|
-
await rmImpl(config, { force: true });
|
|
206
|
-
configDeleted = true;
|
|
207
|
-
} catch (error) {
|
|
208
|
-
stderr.write(`veo: removed the package but could not delete the config ${config}: ${readableError(error)}\n`);
|
|
209
|
-
return 1;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
if (json) {
|
|
213
|
-
stdout.write(`${JSON.stringify({ status: 'uninstalled', package: PACKAGE, aliases: removedAliases, binDir: aliasDir, cacheDeleted, cache, configDeleted, config })}\n`);
|
|
214
|
-
return 0;
|
|
215
|
-
}
|
|
216
|
-
stdout.write(`veo uninstalled (${PACKAGE}).`);
|
|
217
|
-
if (!keepAliases) stdout.write(removedAliases.length ? ` Aliases deleted: ${removedAliases.join(', ')}.` : ' No custom wrappers found.');
|
|
218
|
-
stdout.write(cacheDeleted ? ` Cache deleted: ${cache}.` : ` Cache kept: ${cache}.`);
|
|
219
|
-
stdout.write(configDeleted ? ` Config deleted: ${config}.` : ` Config kept: ${config}.`);
|
|
220
|
-
stdout.write('\n');
|
|
221
|
-
return 0;
|
|
222
|
-
}
|