- Add `--json` flag for machine-readable JSON output - Add `--log-level` flag to control logging verbosity - Add `skipped` counter to ImportResult for idempotent operations - Change "already exists" conditions from warnings to skipped (info logs) - Recovery key mismatch now errors and stops import early - Pre-check volumes and notification destinations before creation - Attachment functions merge missing items instead of overwriting - Add toError() and mergeResults() helpers to reduce code duplication - Extract readConfigJson() and createOutput() for cleaner CLI code - Move fs/path imports to top level in config-import.ts
149 lines
4.4 KiB
TypeScript
149 lines
4.4 KiB
TypeScript
import { Command } from "commander";
|
|
import path from "node:path";
|
|
import fs from "node:fs/promises";
|
|
|
|
const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e)));
|
|
|
|
type Output = ReturnType<typeof createOutput>;
|
|
|
|
async function readStdin(): Promise<string> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of process.stdin) {
|
|
chunks.push(chunk);
|
|
}
|
|
return Buffer.concat(chunks).toString("utf-8");
|
|
}
|
|
|
|
function createOutput(jsonOutput: boolean) {
|
|
return {
|
|
error: (message: string): never => {
|
|
if (jsonOutput) {
|
|
console.log(JSON.stringify({ error: message }));
|
|
} else {
|
|
console.error(`❌ ${message}`);
|
|
}
|
|
process.exit(1);
|
|
},
|
|
info: (message: string): void => {
|
|
if (!jsonOutput) {
|
|
console.log(message);
|
|
}
|
|
},
|
|
json: (data: object): void => {
|
|
if (jsonOutput) {
|
|
console.log(JSON.stringify(data));
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async function readConfigJson(options: { stdin?: boolean; config?: string }, out: Output): Promise<string> {
|
|
if (options.stdin) {
|
|
out.info("📄 Reading config from stdin...");
|
|
try {
|
|
const configJson = await readStdin();
|
|
if (!configJson.trim()) {
|
|
out.error("No input received from stdin");
|
|
}
|
|
return configJson;
|
|
} catch (e) {
|
|
out.error(`Failed to read stdin: ${toError(e).message}`);
|
|
}
|
|
}
|
|
|
|
const configPath = path.resolve(process.cwd(), options.config ?? "");
|
|
try {
|
|
await fs.access(configPath);
|
|
} catch {
|
|
out.error(`Config file not found: ${configPath}`);
|
|
}
|
|
|
|
out.info(`📄 Config file: ${configPath}`);
|
|
return fs.readFile(configPath, "utf-8");
|
|
}
|
|
|
|
export const importConfigCommand = new Command("import-config")
|
|
.description("Import configuration from a JSON file or stdin")
|
|
.option("-c, --config <path>", "Path to the configuration file")
|
|
.option("--stdin", "Read configuration from stdin")
|
|
.option("--dry-run", "Validate the config without importing")
|
|
.option("--json", "Output results in JSON format")
|
|
.option("--log-level <level>", "Set log level (debug, info, warn, error)")
|
|
.action(async (options) => {
|
|
const jsonOutput = options.json;
|
|
const out = createOutput(jsonOutput);
|
|
|
|
// Set log level: explicit option takes precedence
|
|
if (options.logLevel) {
|
|
process.env.LOG_LEVEL = options.logLevel;
|
|
}
|
|
|
|
out.info("\n📦 Zerobyte Config Import\n");
|
|
|
|
if (!options.config && !options.stdin) {
|
|
if (!jsonOutput) {
|
|
console.log("\nUsage:");
|
|
console.log(" zerobyte import-config --config /path/to/config.json");
|
|
console.log(" cat config.json | zerobyte import-config --stdin");
|
|
}
|
|
out.error("Either --config <path> or --stdin is required");
|
|
}
|
|
|
|
if (options.config && options.stdin) {
|
|
out.error("Cannot use both --config and --stdin");
|
|
}
|
|
|
|
const configJson = await readConfigJson(options, out);
|
|
|
|
// Parse and validate JSON
|
|
let config: unknown;
|
|
try {
|
|
config = JSON.parse(configJson);
|
|
} catch (e) {
|
|
out.error(`Invalid JSON: ${toError(e).message}`);
|
|
}
|
|
|
|
if (options.dryRun) {
|
|
const root = typeof config === "object" && config !== null ? config : {};
|
|
const configObj =
|
|
"config" in root && typeof root.config === "object" && root.config !== null ? root.config : root;
|
|
|
|
const sections = ["volumes", "repositories", "backupSchedules", "notificationDestinations", "users"];
|
|
const counts: Record<string, number> = {};
|
|
for (const section of sections) {
|
|
const items = (configObj as Record<string, unknown>)[section] || [];
|
|
counts[section] = Array.isArray(items) ? items.length : 0;
|
|
}
|
|
const hasRecoveryKey = !!(configObj as Record<string, unknown>).recoveryKey;
|
|
|
|
if (jsonOutput) {
|
|
out.json({ dryRun: true, valid: true, counts, hasRecoveryKey });
|
|
} else {
|
|
console.log("🔍 Dry run mode - validating config only\n");
|
|
for (const section of sections) {
|
|
console.log(` ${section}: ${counts[section]} item(s)`);
|
|
}
|
|
console.log(` recoveryKey: ${hasRecoveryKey ? "provided" : "not provided"}`);
|
|
console.log("\n✅ Config is valid JSON");
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Ensure database is initialized with migrations
|
|
const { runDbMigrations } = await import("../../db/db");
|
|
runDbMigrations();
|
|
|
|
const { applyConfigImport } = await import("../../modules/lifecycle/config-import");
|
|
const result = await applyConfigImport(config);
|
|
|
|
out.json({ ...result, success: result.errors === 0 });
|
|
|
|
// Exit with error code if there were errors
|
|
if (result.errors > 0) {
|
|
process.exit(1);
|
|
}
|
|
} catch (e) {
|
|
out.error(`Import failed: ${toError(e).message}`);
|
|
}
|
|
});
|