veodl 1.8.2 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +796 -725
- package/docs/AGENT_GUIDE.md +82 -66
- package/package.json +1 -2
- package/src/alias.js +258 -0
- package/src/cli.js +613 -454
- package/src/config-diagnostics.js +145 -144
- package/src/config-editor.js +285 -281
- package/src/config-template.js +224 -171
- package/src/config.js +217 -213
- package/src/doctor.js +1 -1
- package/src/downloader.js +601 -600
- package/src/interactive.js +115 -82
- package/src/output.js +1 -1
- package/src/runs.js +343 -333
- package/src/source-discovery.js +251 -0
- package/src/state.js +27 -27
- package/src/uninstall.js +222 -0
- package/src/updater.js +5 -3
package/src/config.js
CHANGED
|
@@ -1,213 +1,217 @@
|
|
|
1
|
-
import { commandOutput } from './output.js';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
5
|
-
import { configBase } from './paths.js';
|
|
6
|
-
import { configSyntaxError } from './config-errors.js';
|
|
7
|
-
import { CONFIG_TEMPLATE, stripConfigComments, withConfigTemplate } from './config-template.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Keys accepted in the config file, with the CLI flag they act as a default for.
|
|
11
|
-
* Everything here is a default only: an explicit command-line flag always wins.
|
|
12
|
-
*/
|
|
13
|
-
export const CONFIG_KEYS = Object.freeze({
|
|
14
|
-
concurrentDownloads: 'number',
|
|
15
|
-
adaptiveConcurrency: 'boolean',
|
|
16
|
-
filenameTemplate: 'string',
|
|
17
|
-
folderTemplate: 'string',
|
|
18
|
-
checkSpace: 'boolean',
|
|
19
|
-
timings: 'boolean',
|
|
20
|
-
color: 'boolean',
|
|
21
|
-
output: 'string',
|
|
22
|
-
quality: 'string',
|
|
23
|
-
format: 'string',
|
|
24
|
-
compatible: 'boolean',
|
|
25
|
-
recode: 'boolean',
|
|
26
|
-
playlistConcurrency: 'number',
|
|
27
|
-
rename: 'string',
|
|
28
|
-
audio: 'boolean',
|
|
29
|
-
open: 'boolean',
|
|
30
|
-
resume: 'boolean',
|
|
31
|
-
closestQuality: 'boolean',
|
|
32
|
-
cookies: 'string',
|
|
33
|
-
cookiesFromBrowser: 'string',
|
|
34
|
-
playlist: 'boolean',
|
|
35
|
-
concurrentFragments: 'number',
|
|
36
|
-
subs: 'boolean',
|
|
37
|
-
subLangs: 'string',
|
|
38
|
-
embedSubs: 'boolean',
|
|
39
|
-
embedMetadata: 'boolean',
|
|
40
|
-
embedThumbnail: 'boolean',
|
|
41
|
-
sponsorblockRemove: 'string',
|
|
42
|
-
section: 'string',
|
|
43
|
-
json: 'boolean',
|
|
44
|
-
skipExisting: 'boolean',
|
|
45
|
-
playlistItems: 'string',
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
if (!
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if ([
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
1
|
+
import { commandOutput } from './output.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { configBase } from './paths.js';
|
|
6
|
+
import { configSyntaxError } from './config-errors.js';
|
|
7
|
+
import { CONFIG_TEMPLATE, stripConfigComments, withConfigTemplate } from './config-template.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keys accepted in the config file, with the CLI flag they act as a default for.
|
|
11
|
+
* Everything here is a default only: an explicit command-line flag always wins.
|
|
12
|
+
*/
|
|
13
|
+
export const CONFIG_KEYS = Object.freeze({
|
|
14
|
+
concurrentDownloads: 'number',
|
|
15
|
+
adaptiveConcurrency: 'boolean',
|
|
16
|
+
filenameTemplate: 'string',
|
|
17
|
+
folderTemplate: 'string',
|
|
18
|
+
checkSpace: 'boolean',
|
|
19
|
+
timings: 'boolean',
|
|
20
|
+
color: 'boolean',
|
|
21
|
+
output: 'string',
|
|
22
|
+
quality: 'string',
|
|
23
|
+
format: 'string',
|
|
24
|
+
compatible: 'boolean',
|
|
25
|
+
recode: 'boolean',
|
|
26
|
+
playlistConcurrency: 'number',
|
|
27
|
+
rename: 'string',
|
|
28
|
+
audio: 'boolean',
|
|
29
|
+
open: 'boolean',
|
|
30
|
+
resume: 'boolean',
|
|
31
|
+
closestQuality: 'boolean',
|
|
32
|
+
cookies: 'string',
|
|
33
|
+
cookiesFromBrowser: 'string',
|
|
34
|
+
playlist: 'boolean',
|
|
35
|
+
concurrentFragments: 'number',
|
|
36
|
+
subs: 'boolean',
|
|
37
|
+
subLangs: 'string',
|
|
38
|
+
embedSubs: 'boolean',
|
|
39
|
+
embedMetadata: 'boolean',
|
|
40
|
+
embedThumbnail: 'boolean',
|
|
41
|
+
sponsorblockRemove: 'string',
|
|
42
|
+
section: 'string',
|
|
43
|
+
json: 'boolean',
|
|
44
|
+
skipExisting: 'boolean',
|
|
45
|
+
playlistItems: 'string',
|
|
46
|
+
deepScan: 'boolean',
|
|
47
|
+
timeout: 'string',
|
|
48
|
+
listSources: 'boolean',
|
|
49
|
+
autoListSources: 'boolean',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function configFile({ env = process.env, platform = process.platform } = {}) {
|
|
53
|
+
const override = env.VEO_CONFIG;
|
|
54
|
+
if (typeof override === 'string' && override.trim()) {
|
|
55
|
+
if (override.includes('\0')) throw new Error('VEO_CONFIG must be a filesystem path.');
|
|
56
|
+
return path.resolve(override.trim());
|
|
57
|
+
}
|
|
58
|
+
return path.join(configBase({ env, platform }), 'config.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function typeOf(value) {
|
|
62
|
+
if (value === null) return 'null';
|
|
63
|
+
if (Array.isArray(value)) return 'array';
|
|
64
|
+
return typeof value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read optional user defaults. A missing file is not an error; a malformed one
|
|
69
|
+
* is reported loudly, because silently ignoring a typo would be worse.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadConfig({ env = process.env, file } = {}) {
|
|
72
|
+
const target = file || configFile({ env });
|
|
73
|
+
let text;
|
|
74
|
+
try {
|
|
75
|
+
text = await readFile(target, 'utf8');
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code === 'ENOENT') return { file: target, exists: false, config: {}, warnings: [] };
|
|
78
|
+
throw new Error(`Cannot read the veo config file ${target}: ${error.message}`);
|
|
79
|
+
}
|
|
80
|
+
return parseConfigText(text, target);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseConfigText(text, target = 'config') {
|
|
84
|
+
let data;
|
|
85
|
+
let clean = text.replace(/^\uFEFF/, '');
|
|
86
|
+
try {
|
|
87
|
+
clean = stripConfigComments(text);
|
|
88
|
+
data = JSON.parse(clean);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw configSyntaxError(clean, target, error);
|
|
91
|
+
}
|
|
92
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
93
|
+
throw new Error(`The veo config file must contain a JSON object: ${target}`);
|
|
94
|
+
}
|
|
95
|
+
const warnings = [];
|
|
96
|
+
const config = {};
|
|
97
|
+
for (const [key, value] of Object.entries(data)) {
|
|
98
|
+
if (key === '$schema') continue;
|
|
99
|
+
if (key === 'profiles') {
|
|
100
|
+
if (!value || typeOf(value) !== 'object') throw new Error('Config profiles must be an object.');
|
|
101
|
+
config.profiles = Object.create(null);
|
|
102
|
+
for (const [name, profile] of Object.entries(value)) {
|
|
103
|
+
if (!profile || typeOf(profile) !== 'object') throw new Error(`Profile "${name}" must be an object.`);
|
|
104
|
+
const checked = {};
|
|
105
|
+
for (const [setting, item] of Object.entries(profile)) {
|
|
106
|
+
if (!Object.hasOwn(CONFIG_KEYS, setting)) throw new Error(`Unknown setting "${setting}" in profile "${name}".`);
|
|
107
|
+
if (typeOf(item) !== CONFIG_KEYS[setting]) throw new Error(`Profile "${name}": "${setting}" must be a ${CONFIG_KEYS[setting]}.`);
|
|
108
|
+
checked[setting] = item;
|
|
109
|
+
}
|
|
110
|
+
config.profiles[name] = checked;
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const expected = CONFIG_KEYS[key];
|
|
115
|
+
if (!expected) {
|
|
116
|
+
warnings.push(`Unknown config key "${key}" in ${target} was ignored.`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (typeOf(value) !== expected) {
|
|
120
|
+
throw new Error(`Config key "${key}" must be a ${expected}, not a ${typeOf(value)} (${target}).`);
|
|
121
|
+
}
|
|
122
|
+
config[key] = value;
|
|
123
|
+
}
|
|
124
|
+
return { file: target, exists: true, config, warnings };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function applyProfile(config, name) {
|
|
128
|
+
const { profiles, ...defaults } = config;
|
|
129
|
+
if (!name) {
|
|
130
|
+
if (!profiles || !Object.hasOwn(profiles, 'default')) return defaults;
|
|
131
|
+
name = 'default';
|
|
132
|
+
}
|
|
133
|
+
if (!profiles || !Object.hasOwn(profiles, name)) throw new Error(`Unknown profile "${name}". Available: ${Object.keys(profiles || {}).join(', ') || 'none'}. Use veo config edit.`);
|
|
134
|
+
return { ...defaults, ...profiles[name] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function prepareConfigEdit(file) {
|
|
138
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
139
|
+
try { await writeFile(file, CONFIG_TEMPLATE, { flag: 'wx', mode: 0o600 }); }
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (error.code !== 'EEXIST') throw error;
|
|
142
|
+
const original = await readFile(file, 'utf8');
|
|
143
|
+
const annotated = withConfigTemplate(original);
|
|
144
|
+
if (annotated !== original) await writeFile(file, annotated, 'utf8');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function configMain(args) {
|
|
149
|
+
const editorColor = !args.includes('--no-color') && !Object.hasOwn(process.env, 'NO_COLOR');
|
|
150
|
+
let stdout;
|
|
151
|
+
[args, stdout] = commandOutput(args, process.stdout);
|
|
152
|
+
if (args[0] === 'show') stdout = process.stdout;
|
|
153
|
+
const file = configFile();
|
|
154
|
+
if (args[0] === 'reset') {
|
|
155
|
+
if (args.length !== 1) throw new Error('Usage: veo config reset');
|
|
156
|
+
return (await import('./config-reset.js')).resetConfig(file, { output: stdout });
|
|
157
|
+
}
|
|
158
|
+
if (['check', 'show'].includes(args[0])) {
|
|
159
|
+
const { parseArgs } = await import('node:util');
|
|
160
|
+
const { values, positionals } = parseArgs({ args: args.slice(1), allowPositionals: true, options: { profile: { type: 'string' } } });
|
|
161
|
+
if (positionals.length) throw new Error('Usage: veo config check|show [--profile NAME]');
|
|
162
|
+
const loaded = await loadConfig({ file });
|
|
163
|
+
if (loaded.warnings.length) throw new Error(loaded.warnings.join('\n'));
|
|
164
|
+
if (args[0] === 'show') {
|
|
165
|
+
const effective = await effectiveConfig(loaded.config, values.profile);
|
|
166
|
+
stdout.write(`${JSON.stringify(effective, null, 2)}\n`);
|
|
167
|
+
} else {
|
|
168
|
+
const names = values.profile ? [values.profile] : [undefined, ...Object.keys(loaded.config.profiles || {})];
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
try { await effectiveConfig(loaded.config, name); }
|
|
171
|
+
catch (error) { throw new Error(`Profile ${name || 'default/global'}: ${error.message}`); }
|
|
172
|
+
}
|
|
173
|
+
stdout.write(`Config OK: ${file} (${names.length} effective configurations checked).\n`);
|
|
174
|
+
}
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
let editorMode;
|
|
178
|
+
if (args[0] === 'edit') {
|
|
179
|
+
if (args.length > 2 || (args[1] && !['--external', '--terminal'].includes(args[1]))) throw new Error('Usage: veo config edit [--external|--terminal]');
|
|
180
|
+
editorMode = args[1]?.slice(2) || process.env.VEO_CONFIG_EDITOR || 'auto';
|
|
181
|
+
if (!['auto', 'external', 'terminal'].includes(editorMode)) throw new Error('VEO_CONFIG_EDITOR must be auto, external or terminal.');
|
|
182
|
+
} else if (args.length !== 1 || !['path', 'profiles'].includes(args[0])) throw new Error('Usage: veo config edit|path|profiles|check|show|reset');
|
|
183
|
+
if (args[0] === 'path') { stdout.write(`${file}\n`); return 0; }
|
|
184
|
+
if (args[0] === 'profiles') {
|
|
185
|
+
const loaded = await loadConfig();
|
|
186
|
+
stdout.write(`${Object.keys(loaded.config.profiles || {}).join('\n') || 'No profiles configured. Use veo config edit.'}\n`);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
await prepareConfigEdit(file);
|
|
190
|
+
if (editorMode === 'terminal' || (editorMode === 'auto' && !process.env.VISUAL && !process.env.EDITOR && process.stdin.isTTY && process.stdout.isTTY)) {
|
|
191
|
+
const { editConfig } = await import('./config-editor.js');
|
|
192
|
+
await editConfig(file, { color: editorColor });
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
// Treat the editor as an executable path, never as shell code.
|
|
196
|
+
const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === 'win32' ? 'notepad.exe' : 'vi');
|
|
197
|
+
await new Promise((resolve, reject) => {
|
|
198
|
+
const child = spawn(editor, [file], { shell: false, stdio: 'inherit', windowsHide: true });
|
|
199
|
+
child.on('error', () => reject(new Error(`Could not launch editor. Set EDITOR to an executable path or edit ${file}.`)));
|
|
200
|
+
child.on('close', code => code === 0 ? resolve() : reject(new Error(`Editor exited with ${code}. Config: ${file}`)));
|
|
201
|
+
});
|
|
202
|
+
await loadConfig();
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function effectiveConfig(config, profile) {
|
|
207
|
+
const { parseCli } = await import('./cli.js');
|
|
208
|
+
const selected = applyProfile(config, profile);
|
|
209
|
+
// Inspection is offline and never prints credential file paths or browser profiles.
|
|
210
|
+
const { cookies, cookiesFromBrowser, ...safe } = selected;
|
|
211
|
+
const parsed = parseCli(['https://example.invalid/config-check'], { config: safe });
|
|
212
|
+
const result = Object.fromEntries(Object.keys(CONFIG_KEYS).filter(key => parsed[key] !== undefined).map(key => [key, parsed[key]]));
|
|
213
|
+
result.output = path.resolve(result.output);
|
|
214
|
+
if (cookies !== undefined) result.cookies = '[configured]';
|
|
215
|
+
if (cookiesFromBrowser !== undefined) result.cookiesFromBrowser = '[configured]';
|
|
216
|
+
return result;
|
|
217
|
+
}
|
package/src/doctor.js
CHANGED
|
@@ -249,7 +249,7 @@ export async function collectChecks({
|
|
|
249
249
|
}
|
|
250
250
|
if (latest) {
|
|
251
251
|
const newer = compareVersions(latest, version) > 0;
|
|
252
|
-
push(newer ? 'warn' : 'ok', 'veo version', newer ? `${version} — ${latest} is available (run: veo
|
|
252
|
+
push(newer ? 'warn' : 'ok', 'veo version', newer ? `${version} — ${latest} is available (run: veo up)` : `${version} is the latest release`);
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
return checks;
|