cdx-chores 0.0.7
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/LICENSE +21 -0
- package/README.md +312 -0
- package/dist/cjs/index.cjs +9572 -0
- package/dist/esm/bin.mjs +9544 -0
- package/dist/esm/bin.mjs.map +1 -0
- package/dist/esm/index.d.mts +43 -0
- package/dist/esm/index.mjs +9536 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +787 -0
- package/package.json +67 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { confirm, input, select } from "@inquirer/prompts";
|
|
6
|
+
|
|
7
|
+
//#region src/cli/csv.ts
|
|
8
|
+
function normalizeLineBreaks(value) {
|
|
9
|
+
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
10
|
+
}
|
|
11
|
+
function quoteCsvField(value) {
|
|
12
|
+
const text = value === null || value === void 0 ? "" : typeof value === "string" ? value : typeof value === "number" || typeof value === "boolean" ? String(value) : JSON.stringify(value);
|
|
13
|
+
if (/["\n,]/.test(text)) return `"${text.replaceAll("\"", "\"\"")}"`;
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
16
|
+
function stringifyCsv(rows) {
|
|
17
|
+
if (rows.length === 0) return "";
|
|
18
|
+
const headers = [];
|
|
19
|
+
const seen = /* @__PURE__ */ new Set();
|
|
20
|
+
for (const row of rows) for (const key of Object.keys(row)) if (!seen.has(key)) {
|
|
21
|
+
seen.add(key);
|
|
22
|
+
headers.push(key);
|
|
23
|
+
}
|
|
24
|
+
return `${[headers.map((header) => quoteCsvField(header)).join(","), ...rows.map((row) => headers.map((header) => quoteCsvField(row[header])).join(","))].join("\n")}\n`;
|
|
25
|
+
}
|
|
26
|
+
function parseCsv(text) {
|
|
27
|
+
const source = normalizeLineBreaks(text);
|
|
28
|
+
if (source.length === 0) return [];
|
|
29
|
+
const rows = [];
|
|
30
|
+
let row = [];
|
|
31
|
+
let field = "";
|
|
32
|
+
let inQuotes = false;
|
|
33
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
34
|
+
const char = source[index];
|
|
35
|
+
if (inQuotes) {
|
|
36
|
+
if (char === "\"") if (source[index + 1] === "\"") {
|
|
37
|
+
field += "\"";
|
|
38
|
+
index += 1;
|
|
39
|
+
} else inQuotes = false;
|
|
40
|
+
else field += char;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (char === "\"") {
|
|
44
|
+
inQuotes = true;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (char === ",") {
|
|
48
|
+
row.push(field);
|
|
49
|
+
field = "";
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (char === "\n") {
|
|
53
|
+
row.push(field);
|
|
54
|
+
rows.push(row);
|
|
55
|
+
row = [];
|
|
56
|
+
field = "";
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
field += char;
|
|
60
|
+
}
|
|
61
|
+
row.push(field);
|
|
62
|
+
if (row.length > 1 || row[0] !== "" || rows.length === 0) rows.push(row);
|
|
63
|
+
return rows;
|
|
64
|
+
}
|
|
65
|
+
function csvRowsToObjects(rows) {
|
|
66
|
+
if (rows.length === 0) return [];
|
|
67
|
+
const [headerRow, ...dataRows] = rows;
|
|
68
|
+
const headers = headerRow.map((header, index) => (index === 0 ? header.replace(/^\uFEFF/, "") : header).trim());
|
|
69
|
+
return dataRows.filter((row) => row.some((value) => value.length > 0)).map((row) => {
|
|
70
|
+
const record = {};
|
|
71
|
+
headers.forEach((header, index) => {
|
|
72
|
+
if (header.length === 0) return;
|
|
73
|
+
record[header] = row[index] ?? "";
|
|
74
|
+
});
|
|
75
|
+
return record;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/cli/errors.ts
|
|
81
|
+
var CliError = class extends Error {
|
|
82
|
+
exitCode;
|
|
83
|
+
code;
|
|
84
|
+
constructor(message, options = {}) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "CliError";
|
|
87
|
+
this.exitCode = options.exitCode ?? 1;
|
|
88
|
+
this.code = options.code ?? "CLI_ERROR";
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
function toCliError(error) {
|
|
92
|
+
if (error instanceof CliError) return error;
|
|
93
|
+
return new CliError(error instanceof Error ? error.message : String(error));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/cli/process.ts
|
|
98
|
+
async function execCommand(command, args, options = {}) {
|
|
99
|
+
return await new Promise((resolve, reject) => {
|
|
100
|
+
const child = spawn(command, args, {
|
|
101
|
+
cwd: options.cwd,
|
|
102
|
+
stdio: [
|
|
103
|
+
"ignore",
|
|
104
|
+
"pipe",
|
|
105
|
+
"pipe"
|
|
106
|
+
]
|
|
107
|
+
});
|
|
108
|
+
let stdout = "";
|
|
109
|
+
let stderr = "";
|
|
110
|
+
child.stdout.setEncoding("utf8");
|
|
111
|
+
child.stderr.setEncoding("utf8");
|
|
112
|
+
child.stdout.on("data", (chunk) => {
|
|
113
|
+
stdout += chunk;
|
|
114
|
+
});
|
|
115
|
+
child.stderr.on("data", (chunk) => {
|
|
116
|
+
stderr += chunk;
|
|
117
|
+
});
|
|
118
|
+
child.on("error", (error) => {
|
|
119
|
+
reject(error);
|
|
120
|
+
});
|
|
121
|
+
child.on("close", (code, signal) => {
|
|
122
|
+
resolve({
|
|
123
|
+
ok: code === 0,
|
|
124
|
+
code,
|
|
125
|
+
signal,
|
|
126
|
+
stdout,
|
|
127
|
+
stderr
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/cli/deps.ts
|
|
135
|
+
function installHintFor(command, platform) {
|
|
136
|
+
if (platform === "darwin") return command === "pandoc" ? "brew install pandoc" : "brew install ffmpeg";
|
|
137
|
+
if (platform === "win32") return command === "pandoc" ? "Install via winget/choco (example: winget install --id JohnMacFarlane.Pandoc)" : "Install via winget/choco (example: winget install Gyan.FFmpeg)";
|
|
138
|
+
return command === "pandoc" ? "Install via your package manager (examples: apt/dnf/pacman) for pandoc" : "Install via your package manager (examples: apt/dnf/pacman) for ffmpeg";
|
|
139
|
+
}
|
|
140
|
+
function parseVersion(command, output) {
|
|
141
|
+
const firstLine = output.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
142
|
+
if (!firstLine) return null;
|
|
143
|
+
if (command === "pandoc") return firstLine.match(/^pandoc\s+([^\s]+)/i)?.[1] ?? firstLine;
|
|
144
|
+
if (command === "ffmpeg") return firstLine.match(/^ffmpeg version\s+([^\s]+)/i)?.[1] ?? firstLine;
|
|
145
|
+
return firstLine;
|
|
146
|
+
}
|
|
147
|
+
async function inspectCommand(command, platform) {
|
|
148
|
+
try {
|
|
149
|
+
const result = await execCommand(command, command === "pandoc" ? ["--version"] : ["-version"]);
|
|
150
|
+
return {
|
|
151
|
+
name: command,
|
|
152
|
+
available: result.ok,
|
|
153
|
+
version: result.ok ? parseVersion(command, result.stdout || result.stderr) : null,
|
|
154
|
+
installHint: installHintFor(command, platform)
|
|
155
|
+
};
|
|
156
|
+
} catch (error) {
|
|
157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
+
if (/ENOENT/i.test(message)) return {
|
|
159
|
+
name: command,
|
|
160
|
+
available: false,
|
|
161
|
+
version: null,
|
|
162
|
+
installHint: installHintFor(command, platform)
|
|
163
|
+
};
|
|
164
|
+
throw new CliError(`Failed to inspect dependency '${command}': ${message}`, {
|
|
165
|
+
code: "DEPENDENCY_CHECK_FAILED",
|
|
166
|
+
exitCode: 2
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function requireCommandAvailable(command, platform) {
|
|
171
|
+
const status = await inspectCommand(command, platform);
|
|
172
|
+
if (status.available) return;
|
|
173
|
+
throw new CliError(`Missing required dependency: ${command}. Install suggestion: ${status.installHint}`, {
|
|
174
|
+
code: "DEPENDENCY_MISSING",
|
|
175
|
+
exitCode: 2
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/cli/fs-utils.ts
|
|
181
|
+
function resolveFromCwd(runtime, filePath) {
|
|
182
|
+
return resolve(runtime.cwd, filePath);
|
|
183
|
+
}
|
|
184
|
+
async function readTextFileRequired(path) {
|
|
185
|
+
try {
|
|
186
|
+
return await readFile(path, "utf8");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw new CliError(`Failed to read file: ${path} (${error instanceof Error ? error.message : String(error)})`, {
|
|
189
|
+
code: "FILE_READ_ERROR",
|
|
190
|
+
exitCode: 2
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function ensureParentDir(path) {
|
|
195
|
+
await mkdir(dirname(path), { recursive: true });
|
|
196
|
+
}
|
|
197
|
+
async function writeTextFileSafe(path, content, options = {}) {
|
|
198
|
+
const overwrite = options.overwrite ?? false;
|
|
199
|
+
try {
|
|
200
|
+
if (!overwrite) {
|
|
201
|
+
await stat(path);
|
|
202
|
+
throw new CliError(`Output file already exists: ${path}. Use --overwrite to replace it.`, {
|
|
203
|
+
code: "OUTPUT_EXISTS",
|
|
204
|
+
exitCode: 2
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof CliError) throw error;
|
|
209
|
+
}
|
|
210
|
+
await ensureParentDir(path);
|
|
211
|
+
try {
|
|
212
|
+
await writeFile(path, content, "utf8");
|
|
213
|
+
} catch (error) {
|
|
214
|
+
throw new CliError(`Failed to write file: ${path} (${error instanceof Error ? error.message : String(error)})`, {
|
|
215
|
+
code: "FILE_WRITE_ERROR",
|
|
216
|
+
exitCode: 2
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function defaultOutputPath(inputPath, nextExtension) {
|
|
221
|
+
const extension = nextExtension.startsWith(".") ? nextExtension : `.${nextExtension}`;
|
|
222
|
+
const currentExt = extname(inputPath);
|
|
223
|
+
if (currentExt.length === 0) return `${inputPath}${extension}`;
|
|
224
|
+
return `${inputPath.slice(0, -currentExt.length)}${extension}`;
|
|
225
|
+
}
|
|
226
|
+
function slugifyName(value) {
|
|
227
|
+
return value.normalize("NFKD").replace(/[^\x00-\x7F]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "file";
|
|
228
|
+
}
|
|
229
|
+
function formatFileDateTime(date) {
|
|
230
|
+
return `${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, "0")}${String(date.getUTCDate()).padStart(2, "0")}-${String(date.getUTCHours()).padStart(2, "0")}${String(date.getUTCMinutes()).padStart(2, "0")}${String(date.getUTCSeconds()).padStart(2, "0")}`;
|
|
231
|
+
}
|
|
232
|
+
async function planBatchRename(runtime, directoryInput, options = {}) {
|
|
233
|
+
const directoryPath = resolveFromCwd(runtime, directoryInput);
|
|
234
|
+
const files = (await readdir(directoryPath, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).sort((a, b) => a.localeCompare(b));
|
|
235
|
+
const prefix = slugifyName(options.prefix?.trim() || "file");
|
|
236
|
+
const plannedTargets = /* @__PURE__ */ new Set();
|
|
237
|
+
const sourcePaths = /* @__PURE__ */ new Set();
|
|
238
|
+
const plans = [];
|
|
239
|
+
for (const name of files) {
|
|
240
|
+
const sourcePath = join(directoryPath, name);
|
|
241
|
+
sourcePaths.add(sourcePath);
|
|
242
|
+
let fileStats;
|
|
243
|
+
try {
|
|
244
|
+
fileStats = await stat(sourcePath);
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const ext = extname(name).toLowerCase();
|
|
249
|
+
const slug = slugifyName(basename(name, extname(name))).slice(0, 48);
|
|
250
|
+
const dt = formatFileDateTime(fileStats.mtime ?? options.now ?? runtime.now());
|
|
251
|
+
let counter = 0;
|
|
252
|
+
let candidatePath = "";
|
|
253
|
+
while (true) {
|
|
254
|
+
candidatePath = join(directoryPath, `${prefix}-${dt}-${slug}${counter === 0 ? "" : `-${String(counter).padStart(2, "0")}`}${ext}`);
|
|
255
|
+
if (!plannedTargets.has(candidatePath)) break;
|
|
256
|
+
counter += 1;
|
|
257
|
+
}
|
|
258
|
+
plannedTargets.add(candidatePath);
|
|
259
|
+
plans.push({
|
|
260
|
+
fromPath: sourcePath,
|
|
261
|
+
toPath: candidatePath,
|
|
262
|
+
changed: sourcePath !== candidatePath
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
directoryPath,
|
|
267
|
+
plans
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
async function applyPlannedRenames(plans) {
|
|
271
|
+
const changes = plans.filter((plan) => plan.changed);
|
|
272
|
+
if (changes.length === 0) return;
|
|
273
|
+
const tempMoves = [];
|
|
274
|
+
for (let index = 0; index < changes.length; index += 1) {
|
|
275
|
+
const plan = changes[index];
|
|
276
|
+
const tempPath = `${plan.fromPath}.cdx-chores-tmp-${process.pid}-${index}`;
|
|
277
|
+
await rename(plan.fromPath, tempPath);
|
|
278
|
+
tempMoves.push({
|
|
279
|
+
tempPath,
|
|
280
|
+
finalPath: plan.toPath
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
for (const move of tempMoves) await rename(move.tempPath, move.finalPath);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/cli/actions.ts
|
|
288
|
+
function printLine(stream, line = "") {
|
|
289
|
+
stream.write(`${line}\n`);
|
|
290
|
+
}
|
|
291
|
+
function assertNonEmpty(value, label) {
|
|
292
|
+
const next = value?.trim() ?? "";
|
|
293
|
+
if (!next) throw new CliError(`${label} is required.`, {
|
|
294
|
+
code: "INVALID_INPUT",
|
|
295
|
+
exitCode: 2
|
|
296
|
+
});
|
|
297
|
+
return next;
|
|
298
|
+
}
|
|
299
|
+
async function ensureFileExists(path, label) {
|
|
300
|
+
try {
|
|
301
|
+
if (!(await stat(path)).isFile()) throw new Error("not a file");
|
|
302
|
+
} catch {
|
|
303
|
+
throw new CliError(`${label} file not found: ${path}`, {
|
|
304
|
+
code: "FILE_NOT_FOUND",
|
|
305
|
+
exitCode: 2
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function actionDoctor(runtime, options = {}) {
|
|
310
|
+
const [pandoc, ffmpeg] = await Promise.all([inspectCommand("pandoc", runtime.platform), inspectCommand("ffmpeg", runtime.platform)]);
|
|
311
|
+
const capabilities = {
|
|
312
|
+
"md.to-docx": pandoc.available,
|
|
313
|
+
"video.convert": ffmpeg.available,
|
|
314
|
+
"video.resize": ffmpeg.available,
|
|
315
|
+
"video.gif": ffmpeg.available
|
|
316
|
+
};
|
|
317
|
+
if (options.json) {
|
|
318
|
+
const payload = {
|
|
319
|
+
generatedAt: runtime.now().toISOString(),
|
|
320
|
+
platform: runtime.platform,
|
|
321
|
+
nodeVersion: process.version,
|
|
322
|
+
tools: {
|
|
323
|
+
pandoc,
|
|
324
|
+
ffmpeg
|
|
325
|
+
},
|
|
326
|
+
capabilities
|
|
327
|
+
};
|
|
328
|
+
printLine(runtime.stdout, JSON.stringify(payload, null, 2));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
printLine(runtime.stdout, "cdx-chores doctor");
|
|
332
|
+
printLine(runtime.stdout, `Platform: ${runtime.platform}`);
|
|
333
|
+
printLine(runtime.stdout, `Node.js: ${process.version}`);
|
|
334
|
+
printLine(runtime.stdout);
|
|
335
|
+
for (const item of [pandoc, ffmpeg]) {
|
|
336
|
+
printLine(runtime.stdout, `- ${item.name}: ${item.available ? `available (${item.version ?? "unknown version"})` : "missing"}`);
|
|
337
|
+
if (!item.available) printLine(runtime.stdout, ` Install suggestion: ${item.installHint}`);
|
|
338
|
+
}
|
|
339
|
+
printLine(runtime.stdout);
|
|
340
|
+
printLine(runtime.stdout, "Capabilities:");
|
|
341
|
+
for (const [capability, available] of Object.entries(capabilities)) printLine(runtime.stdout, `- ${capability}: ${available ? "available" : "unavailable"}`);
|
|
342
|
+
}
|
|
343
|
+
function normalizeRowsFromJson(input) {
|
|
344
|
+
if (Array.isArray(input)) {
|
|
345
|
+
if (input.length === 0) return [];
|
|
346
|
+
if (input.every((item) => item !== null && typeof item === "object" && !Array.isArray(item))) return input;
|
|
347
|
+
return input.map((item) => ({ value: item }));
|
|
348
|
+
}
|
|
349
|
+
if (input !== null && typeof input === "object") return [input];
|
|
350
|
+
return [{ value: input }];
|
|
351
|
+
}
|
|
352
|
+
async function actionJsonToCsv(runtime, options) {
|
|
353
|
+
const inputPath = resolveFromCwd(runtime, assertNonEmpty(options.input, "Input path"));
|
|
354
|
+
const outputPath = resolveFromCwd(runtime, options.output?.trim() || defaultOutputPath(inputPath, ".csv"));
|
|
355
|
+
await ensureFileExists(inputPath, "Input");
|
|
356
|
+
const raw = await readTextFileRequired(inputPath);
|
|
357
|
+
let parsed;
|
|
358
|
+
try {
|
|
359
|
+
parsed = JSON.parse(raw);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw new CliError(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`, {
|
|
362
|
+
code: "INVALID_JSON",
|
|
363
|
+
exitCode: 2
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
const rows = normalizeRowsFromJson(parsed);
|
|
367
|
+
await writeTextFileSafe(outputPath, stringifyCsv(rows), { overwrite: options.overwrite });
|
|
368
|
+
printLine(runtime.stdout, `Wrote CSV: ${outputPath}`);
|
|
369
|
+
printLine(runtime.stdout, `Rows: ${rows.length}`);
|
|
370
|
+
}
|
|
371
|
+
async function actionCsvToJson(runtime, options) {
|
|
372
|
+
const inputPath = resolveFromCwd(runtime, assertNonEmpty(options.input, "Input path"));
|
|
373
|
+
const outputPath = resolveFromCwd(runtime, options.output?.trim() || defaultOutputPath(inputPath, ".json"));
|
|
374
|
+
await ensureFileExists(inputPath, "Input");
|
|
375
|
+
const records = csvRowsToObjects(parseCsv(await readTextFileRequired(inputPath)));
|
|
376
|
+
await writeTextFileSafe(outputPath, `${JSON.stringify(records, null, options.pretty ? 2 : 0)}\n`, { overwrite: options.overwrite });
|
|
377
|
+
printLine(runtime.stdout, `Wrote JSON: ${outputPath}`);
|
|
378
|
+
printLine(runtime.stdout, `Rows: ${records.length}`);
|
|
379
|
+
}
|
|
380
|
+
async function actionMdToDocx(runtime, options) {
|
|
381
|
+
const inputPath = resolveFromCwd(runtime, assertNonEmpty(options.input, "Input path"));
|
|
382
|
+
const outputPath = resolveFromCwd(runtime, options.output?.trim() || defaultOutputPath(inputPath, ".docx"));
|
|
383
|
+
await ensureFileExists(inputPath, "Input");
|
|
384
|
+
await requireCommandAvailable("pandoc", runtime.platform);
|
|
385
|
+
if (!options.overwrite) try {
|
|
386
|
+
await stat(outputPath);
|
|
387
|
+
throw new CliError(`Output file already exists: ${outputPath}. Use --overwrite to replace it.`, {
|
|
388
|
+
code: "OUTPUT_EXISTS",
|
|
389
|
+
exitCode: 2
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (error instanceof CliError) throw error;
|
|
393
|
+
}
|
|
394
|
+
const result = await execCommand("pandoc", [
|
|
395
|
+
inputPath,
|
|
396
|
+
"-o",
|
|
397
|
+
outputPath
|
|
398
|
+
], { cwd: runtime.cwd });
|
|
399
|
+
if (!result.ok) throw new CliError(`pandoc failed (${result.code ?? "unknown"}): ${result.stderr || result.stdout}`.trim(), {
|
|
400
|
+
code: "PROCESS_FAILED",
|
|
401
|
+
exitCode: 1
|
|
402
|
+
});
|
|
403
|
+
printLine(runtime.stdout, `Wrote DOCX: ${outputPath}`);
|
|
404
|
+
}
|
|
405
|
+
function formatRenamePreviewLine(plan) {
|
|
406
|
+
const fromName = basename(plan.fromPath);
|
|
407
|
+
const toName = basename(plan.toPath);
|
|
408
|
+
return plan.changed ? `- ${fromName} -> ${toName}` : `- ${fromName} (unchanged)`;
|
|
409
|
+
}
|
|
410
|
+
async function actionRenameBatch(runtime, options) {
|
|
411
|
+
const { directoryPath, plans } = await planBatchRename(runtime, assertNonEmpty(options.directory, "Directory path"), {
|
|
412
|
+
prefix: options.prefix,
|
|
413
|
+
now: runtime.now()
|
|
414
|
+
});
|
|
415
|
+
const totalCount = plans.length;
|
|
416
|
+
const changedCount = plans.filter((plan) => plan.changed).length;
|
|
417
|
+
printLine(runtime.stdout, `Directory: ${directoryPath}`);
|
|
418
|
+
printLine(runtime.stdout, `Files found: ${totalCount}`);
|
|
419
|
+
printLine(runtime.stdout, `Files to rename: ${changedCount}`);
|
|
420
|
+
printLine(runtime.stdout);
|
|
421
|
+
for (const plan of plans) printLine(runtime.stdout, formatRenamePreviewLine(plan));
|
|
422
|
+
if (options.dryRun ?? false) {
|
|
423
|
+
printLine(runtime.stdout);
|
|
424
|
+
printLine(runtime.stdout, "Dry run only. No files were renamed.");
|
|
425
|
+
return {
|
|
426
|
+
changedCount,
|
|
427
|
+
totalCount,
|
|
428
|
+
directoryPath
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
await applyPlannedRenames(plans);
|
|
432
|
+
printLine(runtime.stdout);
|
|
433
|
+
printLine(runtime.stdout, `Renamed ${changedCount} file(s).`);
|
|
434
|
+
return {
|
|
435
|
+
changedCount,
|
|
436
|
+
totalCount,
|
|
437
|
+
directoryPath
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
async function runFfmpeg(runtime, args) {
|
|
441
|
+
await requireCommandAvailable("ffmpeg", runtime.platform);
|
|
442
|
+
const result = await execCommand("ffmpeg", args, { cwd: runtime.cwd });
|
|
443
|
+
if (!result.ok) throw new CliError(`ffmpeg failed (${result.code ?? "unknown"}): ${result.stderr || result.stdout}`.trim(), {
|
|
444
|
+
code: "PROCESS_FAILED",
|
|
445
|
+
exitCode: 1
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
async function actionVideoConvert(runtime, options) {
|
|
449
|
+
const inputPath = resolve(runtime.cwd, assertNonEmpty(options.input, "Input path"));
|
|
450
|
+
const outputPath = resolve(runtime.cwd, assertNonEmpty(options.output, "Output path"));
|
|
451
|
+
await ensureFileExists(inputPath, "Input");
|
|
452
|
+
await runFfmpeg(runtime, [
|
|
453
|
+
options.overwrite ? "-y" : "-n",
|
|
454
|
+
"-i",
|
|
455
|
+
inputPath,
|
|
456
|
+
outputPath
|
|
457
|
+
]);
|
|
458
|
+
printLine(runtime.stdout, `Wrote video: ${outputPath}`);
|
|
459
|
+
}
|
|
460
|
+
async function actionVideoResize(runtime, options) {
|
|
461
|
+
const inputPath = resolve(runtime.cwd, assertNonEmpty(options.input, "Input path"));
|
|
462
|
+
const outputPath = resolve(runtime.cwd, assertNonEmpty(options.output, "Output path"));
|
|
463
|
+
if (!Number.isFinite(options.width) || options.width <= 0) throw new CliError("Width must be a positive number.", {
|
|
464
|
+
code: "INVALID_INPUT",
|
|
465
|
+
exitCode: 2
|
|
466
|
+
});
|
|
467
|
+
if (!Number.isFinite(options.height) || options.height <= 0) throw new CliError("Height must be a positive number.", {
|
|
468
|
+
code: "INVALID_INPUT",
|
|
469
|
+
exitCode: 2
|
|
470
|
+
});
|
|
471
|
+
await ensureFileExists(inputPath, "Input");
|
|
472
|
+
await runFfmpeg(runtime, [
|
|
473
|
+
options.overwrite ? "-y" : "-n",
|
|
474
|
+
"-i",
|
|
475
|
+
inputPath,
|
|
476
|
+
"-vf",
|
|
477
|
+
`scale=${Math.trunc(options.width)}:${Math.trunc(options.height)}`,
|
|
478
|
+
outputPath
|
|
479
|
+
]);
|
|
480
|
+
printLine(runtime.stdout, `Wrote resized video: ${outputPath}`);
|
|
481
|
+
}
|
|
482
|
+
async function actionVideoGif(runtime, options) {
|
|
483
|
+
const inputPath = resolve(runtime.cwd, assertNonEmpty(options.input, "Input path"));
|
|
484
|
+
const outputPath = resolve(runtime.cwd, options.output?.trim() || defaultOutputPath(inputPath, ".gif"));
|
|
485
|
+
await ensureFileExists(inputPath, "Input");
|
|
486
|
+
const fps = Number.isFinite(options.fps) && (options.fps ?? 0) > 0 ? Math.trunc(options.fps) : 10;
|
|
487
|
+
const width = Number.isFinite(options.width) && (options.width ?? 0) > 0 ? Math.trunc(options.width) : 480;
|
|
488
|
+
await runFfmpeg(runtime, [
|
|
489
|
+
options.overwrite ? "-y" : "-n",
|
|
490
|
+
"-i",
|
|
491
|
+
inputPath,
|
|
492
|
+
"-vf",
|
|
493
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos`,
|
|
494
|
+
outputPath
|
|
495
|
+
]);
|
|
496
|
+
printLine(runtime.stdout, `Wrote GIF: ${outputPath}`);
|
|
497
|
+
}
|
|
498
|
+
async function actionDeferred(runtime, label) {
|
|
499
|
+
throw new CliError(`${label} is not implemented in the initial launch phase yet.`, {
|
|
500
|
+
code: "DEFERRED_FEATURE",
|
|
501
|
+
exitCode: 2
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/cli/interactive.ts
|
|
507
|
+
async function promptPath(message) {
|
|
508
|
+
return await input({
|
|
509
|
+
message,
|
|
510
|
+
validate: (value) => value.trim().length > 0 ? true : "Required"
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async function runInteractiveMode(runtime) {
|
|
514
|
+
const action = await select({
|
|
515
|
+
message: "Choose a command",
|
|
516
|
+
choices: [
|
|
517
|
+
{
|
|
518
|
+
name: "doctor",
|
|
519
|
+
value: "doctor",
|
|
520
|
+
description: "Check dependencies and capabilities"
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
name: "data json-to-csv",
|
|
524
|
+
value: "data:json-to-csv"
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
name: "data csv-to-json",
|
|
528
|
+
value: "data:csv-to-json"
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: "md to-docx",
|
|
532
|
+
value: "md:to-docx"
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
name: "rename batch",
|
|
536
|
+
value: "rename:batch"
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
name: "video convert",
|
|
540
|
+
value: "video:convert"
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: "video resize",
|
|
544
|
+
value: "video:resize"
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
name: "video gif",
|
|
548
|
+
value: "video:gif"
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "cancel",
|
|
552
|
+
value: "cancel"
|
|
553
|
+
}
|
|
554
|
+
]
|
|
555
|
+
});
|
|
556
|
+
if (action === "cancel") {
|
|
557
|
+
runtime.stdout.write("Cancelled.\n");
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (action === "doctor") {
|
|
561
|
+
await actionDoctor(runtime, { json: await confirm({
|
|
562
|
+
message: "Output as JSON?",
|
|
563
|
+
default: false
|
|
564
|
+
}) });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (action === "data:json-to-csv") {
|
|
568
|
+
const inputPath = await promptPath("Input JSON file");
|
|
569
|
+
const outputPath = await input({ message: "Output CSV file (optional)" });
|
|
570
|
+
const overwrite = await confirm({
|
|
571
|
+
message: "Overwrite if exists?",
|
|
572
|
+
default: false
|
|
573
|
+
});
|
|
574
|
+
await actionJsonToCsv(runtime, {
|
|
575
|
+
input: inputPath,
|
|
576
|
+
output: outputPath || void 0,
|
|
577
|
+
overwrite
|
|
578
|
+
});
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (action === "data:csv-to-json") {
|
|
582
|
+
const inputPath = await promptPath("Input CSV file");
|
|
583
|
+
const outputPath = await input({ message: "Output JSON file (optional)" });
|
|
584
|
+
const pretty = await confirm({
|
|
585
|
+
message: "Pretty-print JSON?",
|
|
586
|
+
default: true
|
|
587
|
+
});
|
|
588
|
+
const overwrite = await confirm({
|
|
589
|
+
message: "Overwrite if exists?",
|
|
590
|
+
default: false
|
|
591
|
+
});
|
|
592
|
+
await actionCsvToJson(runtime, {
|
|
593
|
+
input: inputPath,
|
|
594
|
+
output: outputPath || void 0,
|
|
595
|
+
pretty,
|
|
596
|
+
overwrite
|
|
597
|
+
});
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (action === "md:to-docx") {
|
|
601
|
+
const inputPath = await promptPath("Input Markdown file");
|
|
602
|
+
const outputPath = await input({ message: "Output DOCX file (optional)" });
|
|
603
|
+
const overwrite = await confirm({
|
|
604
|
+
message: "Overwrite if exists?",
|
|
605
|
+
default: false
|
|
606
|
+
});
|
|
607
|
+
await actionMdToDocx(runtime, {
|
|
608
|
+
input: inputPath,
|
|
609
|
+
output: outputPath || void 0,
|
|
610
|
+
overwrite
|
|
611
|
+
});
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (action === "rename:batch") {
|
|
615
|
+
const directory = await promptPath("Target directory");
|
|
616
|
+
const prefix = await input({
|
|
617
|
+
message: "Filename prefix",
|
|
618
|
+
default: "file"
|
|
619
|
+
});
|
|
620
|
+
const dryRun = await confirm({
|
|
621
|
+
message: "Dry run only?",
|
|
622
|
+
default: true
|
|
623
|
+
});
|
|
624
|
+
const result = await actionRenameBatch(runtime, {
|
|
625
|
+
directory,
|
|
626
|
+
prefix,
|
|
627
|
+
dryRun
|
|
628
|
+
});
|
|
629
|
+
if (!dryRun && result.changedCount > 0) return;
|
|
630
|
+
if (dryRun && result.changedCount > 0) {
|
|
631
|
+
if (await confirm({
|
|
632
|
+
message: "Apply these renames now?",
|
|
633
|
+
default: false
|
|
634
|
+
})) await actionRenameBatch(runtime, {
|
|
635
|
+
directory,
|
|
636
|
+
prefix,
|
|
637
|
+
dryRun: false
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (action === "video:convert") {
|
|
643
|
+
await actionVideoConvert(runtime, {
|
|
644
|
+
input: await promptPath("Input video file"),
|
|
645
|
+
output: await promptPath("Output video file"),
|
|
646
|
+
overwrite: await confirm({
|
|
647
|
+
message: "Overwrite if exists?",
|
|
648
|
+
default: false
|
|
649
|
+
})
|
|
650
|
+
});
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (action === "video:resize") {
|
|
654
|
+
await actionVideoResize(runtime, {
|
|
655
|
+
input: await promptPath("Input video file"),
|
|
656
|
+
output: await promptPath("Output video file"),
|
|
657
|
+
width: Number(await promptPath("Width (px)")),
|
|
658
|
+
height: Number(await promptPath("Height (px)")),
|
|
659
|
+
overwrite: await confirm({
|
|
660
|
+
message: "Overwrite if exists?",
|
|
661
|
+
default: false
|
|
662
|
+
})
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (action === "video:gif") {
|
|
667
|
+
const inputPath = await promptPath("Input video file");
|
|
668
|
+
const outputPath = await input({ message: "Output GIF file (optional)" });
|
|
669
|
+
const widthInput = await input({
|
|
670
|
+
message: "Width in px (optional)",
|
|
671
|
+
default: "480"
|
|
672
|
+
});
|
|
673
|
+
const fpsInput = await input({
|
|
674
|
+
message: "FPS (optional)",
|
|
675
|
+
default: "10"
|
|
676
|
+
});
|
|
677
|
+
const overwrite = await confirm({
|
|
678
|
+
message: "Overwrite if exists?",
|
|
679
|
+
default: false
|
|
680
|
+
});
|
|
681
|
+
await actionVideoGif(runtime, {
|
|
682
|
+
input: inputPath,
|
|
683
|
+
output: outputPath || void 0,
|
|
684
|
+
width: widthInput.trim() ? Number(widthInput) : void 0,
|
|
685
|
+
fps: fpsInput.trim() ? Number(fpsInput) : void 0,
|
|
686
|
+
overwrite
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
//#endregion
|
|
692
|
+
//#region src/command.ts
|
|
693
|
+
function createRuntime(options) {
|
|
694
|
+
return {
|
|
695
|
+
cwd: options.cwd ?? process.cwd(),
|
|
696
|
+
now: options.now ?? (() => /* @__PURE__ */ new Date()),
|
|
697
|
+
platform: options.platform ?? process.platform,
|
|
698
|
+
stdout: options.stdout ?? process.stdout,
|
|
699
|
+
stderr: options.stderr ?? process.stderr,
|
|
700
|
+
stdin: options.stdin ?? process.stdin
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
function applyCommonFileOptions(command) {
|
|
704
|
+
command.option("-o, --output <path>", "Output file path").option("--overwrite", "Overwrite output file if it already exists", false);
|
|
705
|
+
}
|
|
706
|
+
async function runCli(argv = process.argv, runtime = {}) {
|
|
707
|
+
const cliRuntime = createRuntime(runtime);
|
|
708
|
+
if (argv.slice(2).length === 0) {
|
|
709
|
+
if (!process.stdin.isTTY) {
|
|
710
|
+
cliRuntime.stderr.write("No arguments provided and interactive mode requires a TTY. Use --help for commands.\n");
|
|
711
|
+
process.exitCode = 2;
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
await runInteractiveMode(cliRuntime);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
const cliError = toCliError(error);
|
|
718
|
+
cliRuntime.stderr.write(`${cliError.message}\n`);
|
|
719
|
+
process.exitCode = cliError.exitCode;
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const program = new Command();
|
|
724
|
+
program.name("cdx-chores").description("CLI chores toolkit for file/media/document workflow helpers").showHelpAfterError().version("0.0.1");
|
|
725
|
+
program.command("interactive").description("Start interactive mode").action(async () => {
|
|
726
|
+
await runInteractiveMode(cliRuntime);
|
|
727
|
+
});
|
|
728
|
+
program.command("doctor").description("Check tool availability and current feature capabilities").option("--json", "Output machine-readable JSON", false).action(async (options) => {
|
|
729
|
+
await actionDoctor(cliRuntime, { json: options.json });
|
|
730
|
+
});
|
|
731
|
+
const dataCommand = program.command("data").description("Data conversion utilities");
|
|
732
|
+
applyCommonFileOptions(dataCommand.command("json-to-csv").description("Convert JSON file to CSV").requiredOption("-i, --input <path>", "Input JSON file").action(async (options) => {
|
|
733
|
+
await actionJsonToCsv(cliRuntime, options);
|
|
734
|
+
}));
|
|
735
|
+
dataCommand.command("csv-to-json").description("Convert CSV file to JSON").requiredOption("-i, --input <path>", "Input CSV file").option("-o, --output <path>", "Output JSON file path").option("--overwrite", "Overwrite output file if it already exists", false).option("--pretty", "Pretty-print JSON output", false).action(async (options) => {
|
|
736
|
+
await actionCsvToJson(cliRuntime, options);
|
|
737
|
+
});
|
|
738
|
+
applyCommonFileOptions(program.command("md").description("Markdown utilities").command("to-docx").description("Convert Markdown to DOCX using pandoc").requiredOption("-i, --input <path>", "Input Markdown file").action(async (options) => {
|
|
739
|
+
await actionMdToDocx(cliRuntime, options);
|
|
740
|
+
}));
|
|
741
|
+
program.command("docx").description("DOCX utilities").command("to-pdf").description("Convert DOCX to PDF (deferred)").action(async () => {
|
|
742
|
+
await actionDeferred(cliRuntime, "docx to-pdf");
|
|
743
|
+
});
|
|
744
|
+
const pdfCommand = program.command("pdf").description("PDF utilities (deferred in initial phase)");
|
|
745
|
+
for (const [name, description] of [
|
|
746
|
+
["to-images", "Convert PDF pages to images (deferred)"],
|
|
747
|
+
["from-images", "Build PDF from image sequence (deferred)"],
|
|
748
|
+
["merge", "Merge PDF files (deferred)"],
|
|
749
|
+
["split", "Split PDF file (deferred)"]
|
|
750
|
+
]) pdfCommand.command(name).description(description).action(async () => {
|
|
751
|
+
await actionDeferred(cliRuntime, `pdf ${name}`);
|
|
752
|
+
});
|
|
753
|
+
program.command("rename").description("Rename helpers").command("batch").description("Batch rename files in a directory").argument("<directory>", "Target directory").option("--prefix <value>", "Filename prefix", "file").option("--dry-run", "Preview rename plan only", false).action(async (directory, options) => {
|
|
754
|
+
await actionRenameBatch(cliRuntime, {
|
|
755
|
+
directory,
|
|
756
|
+
prefix: options.prefix,
|
|
757
|
+
dryRun: options.dryRun
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
program.command("batch-rename").description("Alias for `rename batch`").argument("<directory>", "Target directory").option("--prefix <value>", "Filename prefix", "file").option("--dry-run", "Preview rename plan only", false).action(async (directory, options) => {
|
|
761
|
+
await actionRenameBatch(cliRuntime, {
|
|
762
|
+
directory,
|
|
763
|
+
prefix: options.prefix,
|
|
764
|
+
dryRun: options.dryRun
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
const videoCommand = program.command("video").description("Video utilities (ffmpeg-backed)");
|
|
768
|
+
videoCommand.command("convert").description("Convert a video file to another format via ffmpeg").requiredOption("-i, --input <path>", "Input video file").requiredOption("-o, --output <path>", "Output video file").option("--overwrite", "Overwrite output file if it already exists", false).action(async (options) => {
|
|
769
|
+
await actionVideoConvert(cliRuntime, options);
|
|
770
|
+
});
|
|
771
|
+
videoCommand.command("resize").description("Resize video dimensions via ffmpeg").requiredOption("-i, --input <path>", "Input video file").requiredOption("-o, --output <path>", "Output video file").requiredOption("--width <px>", "Output width", (value) => Number(value)).requiredOption("--height <px>", "Output height", (value) => Number(value)).option("--overwrite", "Overwrite output file if it already exists", false).action(async (options) => {
|
|
772
|
+
await actionVideoResize(cliRuntime, options);
|
|
773
|
+
});
|
|
774
|
+
videoCommand.command("gif").description("Convert video to GIF via ffmpeg").requiredOption("-i, --input <path>", "Input video file").option("-o, --output <path>", "Output GIF file path").option("--width <px>", "GIF width", (value) => Number(value)).option("--fps <value>", "GIF frames per second", (value) => Number(value)).option("--overwrite", "Overwrite output file if it already exists", false).action(async (options) => {
|
|
775
|
+
await actionVideoGif(cliRuntime, options);
|
|
776
|
+
});
|
|
777
|
+
try {
|
|
778
|
+
await program.parseAsync(argv);
|
|
779
|
+
} catch (error) {
|
|
780
|
+
const cliError = toCliError(error);
|
|
781
|
+
cliRuntime.stderr.write(`${cliError.message}\n`);
|
|
782
|
+
process.exitCode = cliError.exitCode;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
//#endregion
|
|
787
|
+
export { runCli };
|