veodl 1.8.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 +11 -0
- package/CHANGELOG.md +264 -0
- package/LICENSE +21 -0
- package/README.md +725 -0
- package/bin/veo.js +4 -0
- package/docs/AGENT_GUIDE.md +66 -0
- package/package.json +59 -0
- package/src/backend-update.js +191 -0
- package/src/backend.js +520 -0
- package/src/cli.js +454 -0
- package/src/compatibility.js +39 -0
- package/src/config-clipboard.js +67 -0
- package/src/config-diagnostics.js +144 -0
- package/src/config-editor.js +281 -0
- package/src/config-errors.js +64 -0
- package/src/config-reset.js +39 -0
- package/src/config-template.js +171 -0
- package/src/config.js +213 -0
- package/src/disk-space.js +68 -0
- package/src/doctor.js +302 -0
- package/src/download-cache.js +31 -0
- package/src/downloader.js +600 -0
- package/src/execution.js +68 -0
- package/src/flush.js +66 -0
- package/src/history.js +173 -0
- package/src/inspect-media.js +168 -0
- package/src/interactive.js +82 -0
- package/src/jobs.js +172 -0
- package/src/legacy-config-comments.js +106 -0
- package/src/naming.js +50 -0
- package/src/open-file.js +15 -0
- package/src/output.js +61 -0
- package/src/paths.js +26 -0
- package/src/playlist.js +31 -0
- package/src/progress.js +152 -0
- package/src/run-archive.js +148 -0
- package/src/runs.js +333 -0
- package/src/state.js +27 -0
- package/src/stats.js +63 -0
- package/src/terminal-title.js +19 -0
- package/src/tool-setup.js +151 -0
- package/src/updater.js +227 -0
- package/src/utils.js +208 -0
- package/src/version.js +38 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { CONFIG_KEYS, parseConfigText, effectiveConfig, applyProfile } from './config.js';
|
|
2
|
+
import { stripConfigComments } from './config-template.js';
|
|
3
|
+
import { QUALITIES, AUDIO_FORMATS, VIDEO_FORMATS } from './utils.js';
|
|
4
|
+
|
|
5
|
+
// Parse only after JSON.parse succeeds. Keep paths and source spans, including
|
|
6
|
+
// escaped property names and repeated settings in different profiles.
|
|
7
|
+
export function configSource(text) {
|
|
8
|
+
const clean = stripConfigComments(text);
|
|
9
|
+
const data = JSON.parse(clean), nodes = new Map();
|
|
10
|
+
const tokens = [...clean.matchAll(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}\[\]:,]/g)];
|
|
11
|
+
let index = 0;
|
|
12
|
+
const bom = text.startsWith('\uFEFF') ? 1 : 0;
|
|
13
|
+
const visit = (path, keyToken) => {
|
|
14
|
+
const first = tokens[index++];
|
|
15
|
+
if (first[0] === '{') {
|
|
16
|
+
while (tokens[index][0] !== '}') {
|
|
17
|
+
const key = tokens[index++]; index++; // colon
|
|
18
|
+
visit([...path, JSON.parse(key[0])], key);
|
|
19
|
+
if (tokens[index][0] === ',') index++;
|
|
20
|
+
}
|
|
21
|
+
index++;
|
|
22
|
+
} else if (first[0] === '[') {
|
|
23
|
+
let item = 0;
|
|
24
|
+
while (tokens[index][0] !== ']') {
|
|
25
|
+
visit([...path, item++]);
|
|
26
|
+
if (tokens[index][0] === ',') index++;
|
|
27
|
+
}
|
|
28
|
+
index++;
|
|
29
|
+
}
|
|
30
|
+
const last = tokens[index - 1];
|
|
31
|
+
nodes.set(JSON.stringify(path), { path, start: first.index + bom, end: last.index + last[0].length + bom,
|
|
32
|
+
keyStart: keyToken?.index + bom, keyEnd: keyToken ? keyToken.index + keyToken[0].length + bom : undefined });
|
|
33
|
+
};
|
|
34
|
+
visit([]);
|
|
35
|
+
return { data, nodes };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function distance(a, b) {
|
|
39
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
40
|
+
for (let i = 0; i < a.length; i++) {
|
|
41
|
+
const next = [i + 1];
|
|
42
|
+
for (let j = 0; j < b.length; j++) next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (a[i] !== b[j])));
|
|
43
|
+
row = next;
|
|
44
|
+
}
|
|
45
|
+
return row[b.length];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const keys = Object.keys(CONFIG_KEYS);
|
|
49
|
+
function suggestion(key, choices) {
|
|
50
|
+
const ranked = choices.map(value => [value, distance(key.toLowerCase(), value.toLowerCase())]).sort((a, b) => a[1] - b[1]);
|
|
51
|
+
return ranked[0]?.[1] <= 2 ? ranked[0][0] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function located(text, node, message, property = false, choices = []) {
|
|
55
|
+
const start = (property ? node?.keyStart : node?.start) ?? 0;
|
|
56
|
+
const end = (property ? node?.keyEnd : node?.end) ?? start + 1;
|
|
57
|
+
return { message, start, end, line: text.slice(0, start).split('\n').length,
|
|
58
|
+
column: start - text.lastIndexOf('\n', start - 1), choices };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function valueChoices(key, config = {}) {
|
|
62
|
+
if (CONFIG_KEYS[key] === 'boolean') return [true, false];
|
|
63
|
+
if (key === 'quality') return QUALITIES;
|
|
64
|
+
if (key === 'format') return config.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
|
|
65
|
+
if (['concurrentDownloads', 'playlistConcurrency'].includes(key)) return [1, 2, 3, 4];
|
|
66
|
+
if (key === 'concurrentFragments') return Array.from({ length: 16 }, (_, i) => i + 1);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function semanticIssue(text) {
|
|
71
|
+
const { data, nodes } = configSource(text);
|
|
72
|
+
const node = path => nodes.get(JSON.stringify(path));
|
|
73
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
74
|
+
if (!object(data)) return located(text, node([]), 'The config must contain a JSON object.');
|
|
75
|
+
const check = (settings, path) => {
|
|
76
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
77
|
+
if (!path.length && ['$schema', 'profiles'].includes(key)) continue;
|
|
78
|
+
const target = node([...path, key]);
|
|
79
|
+
if (!Object.hasOwn(CONFIG_KEYS, key)) {
|
|
80
|
+
const proposed = suggestion(key, path.length ? keys : [...keys, 'profiles', '$schema']);
|
|
81
|
+
return located(text, target, `Unknown setting "${key}".${proposed ? ` Did you mean "${proposed}"?` : ''}`, true, proposed ? [proposed] : []);
|
|
82
|
+
}
|
|
83
|
+
if (typeof value !== CONFIG_KEYS[key]) {
|
|
84
|
+
const choices = valueChoices(key, settings);
|
|
85
|
+
return located(text, target, `"${key}" must be a ${CONFIG_KEYS[key]}.${choices.length ? ` Choose: ${choices.join(', ')}.` : ''}`, false, choices);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
let issue = check(data, []);
|
|
91
|
+
if (issue) return issue;
|
|
92
|
+
if (Object.hasOwn(data, 'profiles')) {
|
|
93
|
+
if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
|
|
94
|
+
for (const [name, profile] of Object.entries(data.profiles)) {
|
|
95
|
+
if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
|
|
96
|
+
issue = check(profile, ['profiles', name]); if (issue) return issue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const { config } = parseConfigText(text);
|
|
100
|
+
for (const profile of [undefined, ...Object.keys(config.profiles || {})]) {
|
|
101
|
+
try { await effectiveConfig(config, profile); }
|
|
102
|
+
catch (error) {
|
|
103
|
+
const message = error.message;
|
|
104
|
+
let key = /Invalid quality/.test(message) ? 'quality' : /Invalid (?:audio|video) format/.test(message) ? 'format' :
|
|
105
|
+
/output directory/.test(message) ? 'output' : /custom filename/.test(message) ? 'rename' :
|
|
106
|
+
/--([a-z-]+)/.exec(message)?.[1]?.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
107
|
+
const name = profile ?? (config.profiles?.default ? 'default' : undefined);
|
|
108
|
+
const selected = applyProfile(config, profile);
|
|
109
|
+
// Template errors do not name the CLI option. Locate the failing template.
|
|
110
|
+
if (!key) {
|
|
111
|
+
const { validateTemplate } = await import('./naming.js');
|
|
112
|
+
for (const candidate of ['filenameTemplate', 'folderTemplate']) {
|
|
113
|
+
if (selected[candidate] !== undefined) {
|
|
114
|
+
try { validateTemplate(selected[candidate], { folders: candidate === 'folderTemplate' }); }
|
|
115
|
+
catch { key = candidate; break; }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const target = name !== undefined && Object.hasOwn(config.profiles[name], key) ? node(['profiles', name, key]) : node([key]);
|
|
120
|
+
const value = ['quality', 'format'].includes(key) ? `"${key}": unsupported value ${JSON.stringify(selected[key])}. ` : '';
|
|
121
|
+
return located(text, target || node([]), `${name === undefined ? '' : `Profile "${name}": `}${value}${message}`, false, valueChoices(key, selected));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function editorCompletions(text, cursor) {
|
|
128
|
+
try {
|
|
129
|
+
const { data, nodes } = configSource(text);
|
|
130
|
+
for (const item of nodes.values()) {
|
|
131
|
+
const path = item.path;
|
|
132
|
+
if (!(path.length === 1 || (path.length === 3 && path[0] === 'profiles'))) continue;
|
|
133
|
+
const key = path.at(-1), property = cursor >= item.keyStart && cursor <= item.keyEnd;
|
|
134
|
+
if (!property && !(cursor >= item.start && cursor <= item.end)) continue;
|
|
135
|
+
const profile = path.length === 3 ? path[1] : undefined;
|
|
136
|
+
const choices = property ? (path.length === 1 ? [...keys, 'profiles', '$schema'] : keys) : valueChoices(key, applyProfile(data, profile));
|
|
137
|
+
if (!choices.length) return null;
|
|
138
|
+
const proposed = property ? suggestion(key, choices) : null;
|
|
139
|
+
return { start: property ? item.keyStart : item.start, end: property ? item.keyEnd : item.end,
|
|
140
|
+
choices: proposed ? [proposed, ...choices.filter(value => value !== proposed)] : choices };
|
|
141
|
+
}
|
|
142
|
+
} catch { /* Syntax must be valid before offering safe token replacements. */ }
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
3
|
+
import { PassThrough } from 'node:stream';
|
|
4
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
5
|
+
import { parseConfigText } from './config.js';
|
|
6
|
+
import { semanticIssue, editorCompletions } from './config-diagnostics.js';
|
|
7
|
+
import { configClipboard } from './config-clipboard.js';
|
|
8
|
+
|
|
9
|
+
const safe = text => text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
|
|
10
|
+
|
|
11
|
+
export async function validateEditorText(text) {
|
|
12
|
+
try {
|
|
13
|
+
// Run source-aware checks even when the normal loader rejects a setting.
|
|
14
|
+
return await semanticIssue(text);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
try { parseConfigText(text); } catch (configError) { error = configError; }
|
|
17
|
+
const location = /line (\d+), column (\d+)/.exec(error.message);
|
|
18
|
+
return { message: error.message.replace(/^The veo config file is not valid JSON: .*?\(line \d+, column \d+\)\. /, ''), line: location ? Number(location[1]) : null,
|
|
19
|
+
column: location ? Number(location[2]) : null };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A small text buffer shared by the keyboard handler and focused editing tests.
|
|
24
|
+
export class EditorBuffer {
|
|
25
|
+
constructor(text) { this.text = text; this.cursor = 0; this.anchor = null; }
|
|
26
|
+
get selection() {
|
|
27
|
+
return this.anchor === null ? [this.cursor, this.cursor] : [Math.min(this.anchor, this.cursor), Math.max(this.anchor, this.cursor)];
|
|
28
|
+
}
|
|
29
|
+
deleteSelection() {
|
|
30
|
+
const [start, end] = this.selection;
|
|
31
|
+
this.anchor = null;
|
|
32
|
+
if (start === end) return false;
|
|
33
|
+
this.text = this.text.slice(0, start) + this.text.slice(end); this.cursor = start;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
point(row, col, extend = false) {
|
|
37
|
+
if (extend && this.anchor === null) this.anchor = this.cursor;
|
|
38
|
+
if (!extend) this.anchor = null;
|
|
39
|
+
const lines = this.text.split('\n');
|
|
40
|
+
row = Math.max(0, Math.min(lines.length - 1, row));
|
|
41
|
+
this.cursor = lines.slice(0, row).reduce((sum, line) => sum + line.length + 1, 0) + Math.max(0, Math.min(lines[row].length, col));
|
|
42
|
+
}
|
|
43
|
+
insert(text) {
|
|
44
|
+
this.deleteSelection();
|
|
45
|
+
this.text = this.text.slice(0, this.cursor) + text + this.text.slice(this.cursor);
|
|
46
|
+
this.cursor += text.length;
|
|
47
|
+
}
|
|
48
|
+
get position() {
|
|
49
|
+
const before = this.text.slice(0, this.cursor);
|
|
50
|
+
return { row: before.split('\n').length - 1, col: this.cursor - before.lastIndexOf('\n') - 1 };
|
|
51
|
+
}
|
|
52
|
+
move(rows) {
|
|
53
|
+
const lines = this.text.split('\n'), { row, col } = this.position;
|
|
54
|
+
const next = Math.max(0, Math.min(lines.length - 1, row + rows));
|
|
55
|
+
this.cursor = lines.slice(0, next).reduce((sum, line) => sum + line.length + 1, 0) + Math.min(col, lines[next].length);
|
|
56
|
+
}
|
|
57
|
+
key(name) {
|
|
58
|
+
if (['backspace', 'delete'].includes(name) && this.deleteSelection()) return;
|
|
59
|
+
if (['left', 'right', 'up', 'down', 'home', 'end'].includes(name)) this.anchor = null;
|
|
60
|
+
if (name === 'left') this.cursor = Math.max(0, this.cursor - 1);
|
|
61
|
+
if (name === 'right') this.cursor = Math.min(this.text.length, this.cursor + 1);
|
|
62
|
+
if (name === 'up') this.move(-1);
|
|
63
|
+
if (name === 'down') this.move(1);
|
|
64
|
+
if (name === 'home') this.cursor -= this.position.col;
|
|
65
|
+
if (name === 'end') this.cursor = this.text.indexOf('\n', this.cursor) < 0 ? this.text.length : this.text.indexOf('\n', this.cursor);
|
|
66
|
+
if (name === 'backspace' && this.cursor) {
|
|
67
|
+
this.text = this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor); this.cursor--;
|
|
68
|
+
}
|
|
69
|
+
if (name === 'delete') this.text = this.text.slice(0, this.cursor) + this.text.slice(this.cursor + 1);
|
|
70
|
+
if (name === 'return') this.insert('\n');
|
|
71
|
+
if (name === 'tab') this.insert(' ');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function colorLine(line, enabled = true) {
|
|
76
|
+
const clean = safe(line);
|
|
77
|
+
if (!enabled) return clean;
|
|
78
|
+
return clean.replace(/("(?:\\.|[^"\\])*"\s*:?)|(\/\/.*$)|\b(true|false|null|-?\d+(?:\.\d+)?)\b/g,
|
|
79
|
+
(token, string, comment) => `\x1b[${comment ? 90 : string ? (token.endsWith(':') ? 36 : 32) : 33}m${token}\x1b[0m`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function editConfig(file, { input = process.stdin, output = process.stdout, color = !Object.hasOwn(process.env, 'NO_COLOR'), clipboard = configClipboard } = {}) {
|
|
83
|
+
if (!input.isTTY || !output.isTTY || !input.setRawMode) throw new Error('The config editor requires an interactive terminal. Set EDITOR to use an external editor.');
|
|
84
|
+
const original = await readFile(file, 'utf8');
|
|
85
|
+
const newline = original.includes('\r\n') ? '\r\n' : '\n';
|
|
86
|
+
const buffer = new EditorBuffer(original.replace(/\r\n/g, '\n'));
|
|
87
|
+
let saved = buffer.text, disk = original, issue = await validateEditorText(buffer.text);
|
|
88
|
+
let top = 0, left = 0, followCursor = true, question = false, busy = false, closed = false, timer;
|
|
89
|
+
let status = '', revision = 0;
|
|
90
|
+
let viewport, dragging = false, completion = null;
|
|
91
|
+
const wasRaw = input.isRaw, wasPaused = input.isPaused();
|
|
92
|
+
const render = () => {
|
|
93
|
+
if (closed) return;
|
|
94
|
+
const width = Math.max(1, (output.columns || 80) - 1), height = Math.max(1, (output.rows || 24) - 6);
|
|
95
|
+
const lines = buffer.text.split('\n'), { row, col } = buffer.position;
|
|
96
|
+
const gutter = Math.min(width - 1, String(lines.length).length + 2), available = Math.max(1, width - gutter);
|
|
97
|
+
if (followCursor) {
|
|
98
|
+
top = Math.max(0, Math.min(top, row));
|
|
99
|
+
if (row >= top + height) top = row - height + 1;
|
|
100
|
+
}
|
|
101
|
+
top = Math.max(0, Math.min(top, Math.max(0, lines.length - height)));
|
|
102
|
+
left = Math.max(0, Math.min(left, col)); if (col >= left + available) left = col - available + 1;
|
|
103
|
+
viewport = { height, gutter, width };
|
|
104
|
+
const [selectionStart, selectionEnd] = buffer.selection;
|
|
105
|
+
let offset = lines.slice(0, top).reduce((sum, line) => sum + line.length + 1, 0);
|
|
106
|
+
const screen = [safe(`veo config | ${file}${buffer.text !== saved ? ' *' : ''}`).slice(0, width)];
|
|
107
|
+
for (let index = top; index < top + height; index++) {
|
|
108
|
+
const line = lines[index];
|
|
109
|
+
const prefix = line === undefined ? '' : `${index + 1 === issue?.line ? '!' : ' '}${String(index + 1).padStart(Math.max(0, gutter - 2))} `;
|
|
110
|
+
const content = (line || '').slice(left, left + available);
|
|
111
|
+
const start = Math.max(0, selectionStart - offset - left), end = Math.min(content.length, selectionEnd - offset - left);
|
|
112
|
+
if (end > start) {
|
|
113
|
+
screen.push(prefix + colorLine(content.slice(0, start), color) + '\x1b[7m' + safe(content.slice(start, end)) + '\x1b[0m' + colorLine(content.slice(end), color));
|
|
114
|
+
} else if (issue?.start !== undefined && color) {
|
|
115
|
+
const from = Math.max(0, issue.start - offset - left), to = Math.min(content.length, issue.end - offset - left);
|
|
116
|
+
screen.push(to > from ? prefix + colorLine(content.slice(0, from), color) + '\x1b[41;97m' + safe(content.slice(from, to)) + '\x1b[0m' + colorLine(content.slice(to), color) : prefix + colorLine(content, color));
|
|
117
|
+
} else screen.push(index + 1 === issue?.line && color ? `\x1b[41;97m${prefix}${safe(content)}\x1b[0m` : prefix + colorLine(content, color));
|
|
118
|
+
offset += (line || '').length + 1;
|
|
119
|
+
}
|
|
120
|
+
screen.push(safe(question ? 'Discard unsaved changes? Y = discard, N / Esc = keep editing' : 'Ctrl+S Save | Ctrl+C Copy | Ctrl+V Paste | Esc Exit | F2 Options | Wheel Scroll').slice(0, width));
|
|
121
|
+
const diagnosis = issue ? `${issue.line ? `Line ${issue.line}, column ${issue.column}: ` : ''}${issue.message}` : '';
|
|
122
|
+
const detail = safe(completion ? `Options (${completion.index + 1}/${completion.choices.length}): ${JSON.stringify(completion.choices[completion.index])} | Up/Down choose, Enter apply, Esc cancel` :
|
|
123
|
+
status ? `${status}${diagnosis ? ` | ${diagnosis}` : ''}` : diagnosis || 'Config OK');
|
|
124
|
+
for (let row = 0; row < 3; row++) screen.push(detail.slice(row * width, (row + 1) * width));
|
|
125
|
+
screen.push(`Ln ${row + 1}, Col ${col + 1}`.slice(0, width));
|
|
126
|
+
const cursorVisible = row >= top && row < top + height;
|
|
127
|
+
output.write('\x1b[?25l\x1b[H' + screen.map(line => line + '\x1b[K').join('\r\n') +
|
|
128
|
+
(cursorVisible ? `\x1b[${row - top + 2};${gutter + col - left + 1}H\x1b[?25h` : ''));
|
|
129
|
+
};
|
|
130
|
+
await new Promise((resolve, reject) => {
|
|
131
|
+
const keyboard = new PassThrough();
|
|
132
|
+
const decoder = new StringDecoder('utf8');
|
|
133
|
+
let pending = '', mouseTimer;
|
|
134
|
+
const mouse = (button, x, y, release) => {
|
|
135
|
+
if (busy || closed || question) return;
|
|
136
|
+
if (button & 64) {
|
|
137
|
+
if (!release) {
|
|
138
|
+
followCursor = false;
|
|
139
|
+
top += (button & 1) ? 3 : -3;
|
|
140
|
+
render();
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
completion = null;
|
|
145
|
+
if ((button & 3) !== 0) return;
|
|
146
|
+
const inside = y >= 2 && y <= viewport.height + 1 && x >= 1 && x <= viewport.width;
|
|
147
|
+
if (!inside && !dragging) return;
|
|
148
|
+
if (!(button & 32) && !release) {
|
|
149
|
+
followCursor = true;
|
|
150
|
+
buffer.point(top + y - 2, left + x - viewport.gutter - 1);
|
|
151
|
+
buffer.anchor = buffer.cursor; dragging = true;
|
|
152
|
+
} else if (dragging) {
|
|
153
|
+
buffer.point(top + Math.max(0, Math.min(viewport.height - 1, y - 2)), left + x - viewport.gutter - 1, true);
|
|
154
|
+
}
|
|
155
|
+
if (release) dragging = false;
|
|
156
|
+
render();
|
|
157
|
+
};
|
|
158
|
+
// Filter SGR mouse reports before readline so their digits never enter the file.
|
|
159
|
+
const onData = chunk => {
|
|
160
|
+
clearTimeout(mouseTimer);
|
|
161
|
+
pending += typeof chunk === 'string' ? chunk : decoder.write(chunk);
|
|
162
|
+
while (pending) {
|
|
163
|
+
const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(pending);
|
|
164
|
+
if (match) {
|
|
165
|
+
pending = pending.slice(match[0].length);
|
|
166
|
+
mouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4] === 'm');
|
|
167
|
+
} else if (/^\x1b(?:\[(?:<[\d;]*)?)?$/.test(pending)) {
|
|
168
|
+
mouseTimer = setTimeout(() => { if (!pending.startsWith('\x1b[<')) keyboard.write(pending); pending = ''; }, 100);
|
|
169
|
+
break;
|
|
170
|
+
} else {
|
|
171
|
+
const character = String.fromCodePoint(pending.codePointAt(0));
|
|
172
|
+
keyboard.write(character); pending = pending.slice(character.length);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const finish = error => {
|
|
177
|
+
if (closed) return;
|
|
178
|
+
closed = true; clearTimeout(timer); clearTimeout(mouseTimer);
|
|
179
|
+
input.off('data', onData); keyboard.off('keypress', onKey); keyboard.destroy();
|
|
180
|
+
input.off('keypress', onKey); input.off('end', onEnd); input.off('error', onError); output.off('resize', render);
|
|
181
|
+
input.setRawMode(Boolean(wasRaw)); if (wasPaused) input.pause();
|
|
182
|
+
output.write('\x1b[?1002l\x1b[?1006l\x1b[0m\x1b[?25h\x1b[?1049l');
|
|
183
|
+
error ? reject(error) : resolve();
|
|
184
|
+
};
|
|
185
|
+
const onEnd = () => finish();
|
|
186
|
+
const onError = error => finish(error);
|
|
187
|
+
const onKey = async (text, key = {}) => {
|
|
188
|
+
if (busy || closed) return;
|
|
189
|
+
try {
|
|
190
|
+
if (question) {
|
|
191
|
+
if (text?.toLowerCase() === 'y') return finish();
|
|
192
|
+
if (text?.toLowerCase() === 'n' || key.name === 'escape') question = false;
|
|
193
|
+
render(); return;
|
|
194
|
+
}
|
|
195
|
+
if (completion) {
|
|
196
|
+
if (key.name === 'escape') completion = null;
|
|
197
|
+
else if (['up', 'down', 'tab'].includes(key.name)) completion.index = (completion.index + (key.name === 'up' ? -1 : 1) + completion.choices.length) % completion.choices.length;
|
|
198
|
+
else if (key.name === 'return') {
|
|
199
|
+
buffer.anchor = completion.start; buffer.cursor = completion.end;
|
|
200
|
+
buffer.insert(JSON.stringify(completion.choices[completion.index]));
|
|
201
|
+
completion = null; status = ''; revision++; clearTimeout(timer);
|
|
202
|
+
busy = true; issue = await validateEditorText(buffer.text); busy = false;
|
|
203
|
+
}
|
|
204
|
+
render(); return;
|
|
205
|
+
}
|
|
206
|
+
if (key.name === 'f2' || (key.ctrl && key.name === 'space')) {
|
|
207
|
+
const options = editorCompletions(buffer.text, buffer.cursor);
|
|
208
|
+
completion = options ? { ...options, index: 0 } : null;
|
|
209
|
+
status = options ? '' : 'No options here. Place the cursor on a property or value in valid JSON.';
|
|
210
|
+
render(); return;
|
|
211
|
+
}
|
|
212
|
+
if (key.ctrl && key.name === 'c') {
|
|
213
|
+
const [start, end] = buffer.selection;
|
|
214
|
+
if (start === end) status = 'Select text first, then press Ctrl+C.';
|
|
215
|
+
else {
|
|
216
|
+
busy = true;
|
|
217
|
+
try { await clipboard.copy(buffer.text.slice(start, end)); status = 'Copied to clipboard.'; }
|
|
218
|
+
catch (error) { status = `Copy failed: ${error.message}`; }
|
|
219
|
+
finally { busy = false; }
|
|
220
|
+
}
|
|
221
|
+
render(); return;
|
|
222
|
+
}
|
|
223
|
+
if (key.ctrl && key.name === 'v') {
|
|
224
|
+
busy = true;
|
|
225
|
+
try {
|
|
226
|
+
const pasted = (await clipboard.paste()).replace(/\r\n?|\n/g, '\n').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
|
|
227
|
+
if (pasted) {
|
|
228
|
+
buffer.insert(pasted); followCursor = true;
|
|
229
|
+
status = ''; issue = await validateEditorText(buffer.text);
|
|
230
|
+
revision++; clearTimeout(timer);
|
|
231
|
+
} else status = 'Clipboard has no text.';
|
|
232
|
+
} catch (error) { status = `Paste failed: ${error.message}`; }
|
|
233
|
+
finally { busy = false; }
|
|
234
|
+
render(); return;
|
|
235
|
+
}
|
|
236
|
+
if (key.ctrl && ['up', 'down'].includes(key.name)) {
|
|
237
|
+
followCursor = false; top += key.name === 'up' ? -3 : 3; render(); return;
|
|
238
|
+
}
|
|
239
|
+
if (key.name === 'escape' || (key.ctrl && key.name === 'q')) {
|
|
240
|
+
if (buffer.text === saved) return finish();
|
|
241
|
+
question = true; render(); return;
|
|
242
|
+
}
|
|
243
|
+
if (key.ctrl && key.name === 's') {
|
|
244
|
+
followCursor = true;
|
|
245
|
+
revision++; clearTimeout(timer); buffer.anchor = null;
|
|
246
|
+
busy = true;
|
|
247
|
+
issue = await validateEditorText(buffer.text);
|
|
248
|
+
if (issue) {
|
|
249
|
+
if (issue.line) buffer.cursor = buffer.text.split('\n').slice(0, issue.line - 1).reduce((sum, line) => sum + line.length + 1, 0) + issue.column - 1;
|
|
250
|
+
} else {
|
|
251
|
+
try {
|
|
252
|
+
if (await readFile(file, 'utf8') !== disk) throw new Error('File changed outside the editor. Exit and reopen before saving.');
|
|
253
|
+
const next = buffer.text.replace(/\n/g, newline);
|
|
254
|
+
await writeFile(file, next, 'utf8'); disk = next; saved = buffer.text; status = 'Saved';
|
|
255
|
+
} catch (error) { status = `Save failed: ${error.message}`; }
|
|
256
|
+
}
|
|
257
|
+
busy = false; render(); return;
|
|
258
|
+
}
|
|
259
|
+
if (key.ctrl || key.meta) return;
|
|
260
|
+
followCursor = true;
|
|
261
|
+
const before = buffer.text;
|
|
262
|
+
if (key.name === 'pageup' || key.name === 'pagedown') buffer.move((key.name === 'pageup' ? -1 : 1) * Math.max(1, (output.rows || 24) - 4));
|
|
263
|
+
else if (['left', 'right', 'up', 'down', 'home', 'end', 'backspace', 'delete', 'return', 'tab'].includes(key.name)) buffer.key(key.name);
|
|
264
|
+
else if (text && !/[\x00-\x1f\x7f-\x9f]/.test(text)) buffer.insert(text);
|
|
265
|
+
if (before !== buffer.text) {
|
|
266
|
+
status = ''; issue = null; const current = ++revision; clearTimeout(timer);
|
|
267
|
+
timer = setTimeout(async () => {
|
|
268
|
+
const result = await validateEditorText(buffer.text);
|
|
269
|
+
if (!closed && current === revision) { issue = result; render(); }
|
|
270
|
+
}, 150);
|
|
271
|
+
}
|
|
272
|
+
render();
|
|
273
|
+
} catch (error) { finish(error); }
|
|
274
|
+
};
|
|
275
|
+
emitKeypressEvents(keyboard); keyboard.on('keypress', onKey);
|
|
276
|
+
input.on('data', onData);
|
|
277
|
+
input.on('keypress', onKey); input.on('end', onEnd); input.on('error', onError); output.on('resize', render);
|
|
278
|
+
input.setRawMode(true); input.resume();
|
|
279
|
+
output.write('\x1b[?1049h\x1b[?1006h\x1b[?1002h'); render();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Diagnose syntax without echoing config values (which can include private paths).
|
|
2
|
+
export function configSyntaxError(text, file, original) {
|
|
3
|
+
let index = 0;
|
|
4
|
+
const fail = reason => { throw { reason, position: index }; };
|
|
5
|
+
const space = () => { while (/\s/.test(text[index] || '') && index < text.length) index++; };
|
|
6
|
+
function string() {
|
|
7
|
+
index++;
|
|
8
|
+
while (index < text.length) {
|
|
9
|
+
const char = text[index++];
|
|
10
|
+
if (char === '"') return;
|
|
11
|
+
if (char.charCodeAt(0) < 32) { index--; fail('Unescaped line break or control character inside a string.'); }
|
|
12
|
+
if (char === '\\') {
|
|
13
|
+
const escape = text[index];
|
|
14
|
+
if (escape === 'u') {
|
|
15
|
+
index++;
|
|
16
|
+
for (let count = 0; count < 4; count++, index++) {
|
|
17
|
+
if (!/[0-9a-f]/i.test(text[index] || '')) fail('Invalid Unicode escape; use four hexadecimal digits after \\u.');
|
|
18
|
+
}
|
|
19
|
+
} else if (escape && '"\\/bfnrt'.includes(escape)) index++;
|
|
20
|
+
else fail('Invalid escape sequence. For Windows paths, use forward slashes (G:/Videos) or double backslashes (G:\\\\Videos).');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
fail('Unterminated string; add a closing double quote.');
|
|
24
|
+
}
|
|
25
|
+
function value() {
|
|
26
|
+
space();
|
|
27
|
+
const char = text[index];
|
|
28
|
+
if (char === '`') fail('Markdown code fences are not valid JSON. Remove the lines containing ```json and ```.');
|
|
29
|
+
if (char === '"') return string();
|
|
30
|
+
if (char === '{' || char === '[') {
|
|
31
|
+
const object = char === '{', close = object ? '}' : ']';
|
|
32
|
+
index++; space();
|
|
33
|
+
if (text[index] === close) { index++; return; }
|
|
34
|
+
while (true) {
|
|
35
|
+
space();
|
|
36
|
+
if (text[index] === '`') fail('Markdown code fences are not valid JSON. Remove the lines containing ```json and ```.');
|
|
37
|
+
if (object) {
|
|
38
|
+
if (text[index] !== '"') fail('Expected a property name in double quotes. Check for a trailing comma or a missing closing brace.');
|
|
39
|
+
string(); space();
|
|
40
|
+
if (text[index++] !== ':') { index--; fail('Expected a colon (:) after the property name.'); }
|
|
41
|
+
}
|
|
42
|
+
value(); space();
|
|
43
|
+
if (text[index] === close) { index++; return; }
|
|
44
|
+
if (text[index] !== ',') fail(`Expected a comma (,) or closing ${close}.`);
|
|
45
|
+
index++; space();
|
|
46
|
+
if (text[index] === close) fail('Trailing commas are not allowed. Remove the comma before the closing bracket or brace.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const literal = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(index));
|
|
50
|
+
if (!literal) fail('Expected a JSON value: object, array, string, number, true, false or null.');
|
|
51
|
+
index += literal[0].length;
|
|
52
|
+
}
|
|
53
|
+
let issue;
|
|
54
|
+
if (Number.isInteger(original?.position)) issue = { position: original.position, reason: original.message };
|
|
55
|
+
else {
|
|
56
|
+
try { value(); space(); if (index !== text.length) fail('Unexpected content after the JSON value. Remove extra text or Markdown code fences.'); }
|
|
57
|
+
catch (error) { issue = error; }
|
|
58
|
+
}
|
|
59
|
+
const position = Math.min(issue?.position ?? index, text.length);
|
|
60
|
+
const preceding = text.slice(0, position);
|
|
61
|
+
const line = preceding.split('\n').length;
|
|
62
|
+
const column = position - preceding.lastIndexOf('\n');
|
|
63
|
+
return new Error(`The veo config file is not valid JSON: ${file} (line ${line}, column ${column}). ${issue?.reason || 'Invalid JSON syntax.'}`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { CONFIG_TEMPLATE } from './config-template.js';
|
|
6
|
+
|
|
7
|
+
export async function resetConfig(file, { input = process.stdin, output = process.stdout, confirm } = {}) {
|
|
8
|
+
file = path.resolve(file);
|
|
9
|
+
output.write(`Reset configuration and all profiles: ${file}\n`);
|
|
10
|
+
output.write('The existing file will be backed up. Downloads, history and statistics are preserved.\n');
|
|
11
|
+
if (!confirm && !input.isTTY) throw new Error('Config reset requires an interactive terminal for confirmation.');
|
|
12
|
+
let answer;
|
|
13
|
+
if (confirm) answer = await confirm('Reset configuration? [y/N] ');
|
|
14
|
+
else {
|
|
15
|
+
const rl = createInterface({ input, output: process.stdout });
|
|
16
|
+
try { answer = await rl.question('Reset configuration? [y/N] '); }
|
|
17
|
+
finally { rl.close(); }
|
|
18
|
+
}
|
|
19
|
+
if (!/^y(?:es)?$/i.test(String(answer).trim())) {
|
|
20
|
+
output.write('Config reset cancelled.\n');
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
let original;
|
|
24
|
+
try { original = await readFile(file); }
|
|
25
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
26
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
27
|
+
if (original !== undefined) {
|
|
28
|
+
const backup = `${file}.${new Date().toISOString().replace(/[:.]/g, '-')}.${randomUUID()}.bak`;
|
|
29
|
+
await writeFile(backup, original, { flag: 'wx', mode: 0o600 });
|
|
30
|
+
output.write(`Backup: ${backup}\n`);
|
|
31
|
+
}
|
|
32
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
33
|
+
try {
|
|
34
|
+
await writeFile(temporary, CONFIG_TEMPLATE, { flag: 'wx', mode: 0o600 });
|
|
35
|
+
await rename(temporary, file);
|
|
36
|
+
} finally { await rm(temporary, { force: true }); }
|
|
37
|
+
output.write(`Config reset: ${file}\n`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|