@lumy-pack/syncpoint 0.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/dist/cli.mjs ADDED
@@ -0,0 +1,2651 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/Init.tsx
7
+ import { useState, useEffect } from "react";
8
+ import { Text, Box, useApp } from "ink";
9
+ import { render } from "ink";
10
+ import { join as join5 } from "path";
11
+
12
+ // src/constants.ts
13
+ import { join as join2 } from "path";
14
+
15
+ // src/utils/paths.ts
16
+ import { mkdir, stat } from "fs/promises";
17
+ import { homedir } from "os";
18
+ import { join, normalize, resolve } from "path";
19
+ function getHomeDir() {
20
+ const envHome = process.env.SYNCPOINT_HOME;
21
+ if (envHome) {
22
+ const normalized = normalize(envHome);
23
+ if (normalized.includes("..")) {
24
+ throw new Error(`SYNCPOINT_HOME contains path traversal: ${envHome}`);
25
+ }
26
+ if (!resolve(normalized).startsWith("/")) {
27
+ throw new Error(`SYNCPOINT_HOME must be an absolute path: ${envHome}`);
28
+ }
29
+ return normalized;
30
+ }
31
+ return homedir();
32
+ }
33
+ function expandTilde(p) {
34
+ if (p === "~") return getHomeDir();
35
+ if (p.startsWith("~/")) return join(getHomeDir(), p.slice(2));
36
+ return p;
37
+ }
38
+ function contractTilde(p) {
39
+ const home = getHomeDir();
40
+ if (p === home) return "~";
41
+ if (p.startsWith(home + "/")) return "~" + p.slice(home.length);
42
+ return p;
43
+ }
44
+ function resolveTargetPath(p) {
45
+ return resolve(expandTilde(p));
46
+ }
47
+ async function ensureDir(dirPath) {
48
+ await mkdir(dirPath, { recursive: true });
49
+ }
50
+ async function fileExists(filePath) {
51
+ try {
52
+ await stat(filePath);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+ function isInsideDir(filePath, dir) {
59
+ const resolvedFile = resolve(filePath);
60
+ const resolvedDir = resolve(dir);
61
+ return resolvedFile.startsWith(resolvedDir + "/") || resolvedFile === resolvedDir;
62
+ }
63
+
64
+ // src/constants.ts
65
+ var APP_NAME = "syncpoint";
66
+ var APP_DIR = `.${APP_NAME}`;
67
+ var CONFIG_FILENAME = "config.yml";
68
+ var METADATA_FILENAME = "_metadata.json";
69
+ var LARGE_FILE_THRESHOLD = 10 * 1024 * 1024;
70
+ var SENSITIVE_PATTERNS = ["id_rsa", "id_ed25519", "*.pem", "*.key"];
71
+ var BACKUPS_DIR = "backups";
72
+ var TEMPLATES_DIR = "templates";
73
+ var SCRIPTS_DIR = "scripts";
74
+ var LOGS_DIR = "logs";
75
+ function getAppDir() {
76
+ return join2(getHomeDir(), APP_DIR);
77
+ }
78
+ var APP_VERSION = "0.0.1";
79
+ function getSubDir(sub) {
80
+ return join2(getAppDir(), sub);
81
+ }
82
+
83
+ // src/utils/assets.ts
84
+ import { existsSync, readFileSync } from "fs";
85
+ import { dirname, join as join3 } from "path";
86
+ import { fileURLToPath } from "url";
87
+ function getPackageRoot() {
88
+ let dir = dirname(fileURLToPath(import.meta.url));
89
+ while (dir !== dirname(dir)) {
90
+ if (existsSync(join3(dir, "package.json"))) return dir;
91
+ dir = dirname(dir);
92
+ }
93
+ throw new Error("Could not find package root");
94
+ }
95
+ function getAssetPath(filename) {
96
+ return join3(getPackageRoot(), "assets", filename);
97
+ }
98
+ function readAsset(filename) {
99
+ return readFileSync(getAssetPath(filename), "utf-8");
100
+ }
101
+
102
+ // src/core/config.ts
103
+ import { readFile, writeFile } from "fs/promises";
104
+ import { join as join4 } from "path";
105
+ import YAML from "yaml";
106
+
107
+ // src/schemas/ajv.ts
108
+ import Ajv from "ajv";
109
+ import addFormats from "ajv-formats";
110
+ var ajv = new Ajv({ allErrors: true });
111
+ addFormats(ajv);
112
+
113
+ // src/schemas/config.schema.ts
114
+ var configSchema = {
115
+ type: "object",
116
+ required: ["backup"],
117
+ properties: {
118
+ backup: {
119
+ type: "object",
120
+ required: ["targets", "exclude", "filename"],
121
+ properties: {
122
+ targets: {
123
+ type: "array",
124
+ items: { type: "string" }
125
+ },
126
+ exclude: {
127
+ type: "array",
128
+ items: { type: "string" }
129
+ },
130
+ filename: {
131
+ type: "string",
132
+ minLength: 1
133
+ },
134
+ destination: {
135
+ type: "string"
136
+ }
137
+ },
138
+ additionalProperties: false
139
+ },
140
+ scripts: {
141
+ type: "object",
142
+ properties: {
143
+ includeInBackup: {
144
+ type: "boolean"
145
+ }
146
+ },
147
+ additionalProperties: false
148
+ }
149
+ },
150
+ additionalProperties: false
151
+ };
152
+ var validate = ajv.compile(configSchema);
153
+ function validateConfig(data) {
154
+ const valid = validate(data);
155
+ if (valid) return { valid: true };
156
+ const errors = validate.errors?.map(
157
+ (e) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`
158
+ );
159
+ return { valid: false, errors };
160
+ }
161
+
162
+ // src/core/config.ts
163
+ function stripDangerousKeys(obj) {
164
+ if (obj === null || typeof obj !== "object") return obj;
165
+ if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
166
+ const cleaned = {};
167
+ for (const [key, value] of Object.entries(obj)) {
168
+ if (["__proto__", "constructor", "prototype"].includes(key)) continue;
169
+ cleaned[key] = stripDangerousKeys(value);
170
+ }
171
+ return cleaned;
172
+ }
173
+ function getConfigPath() {
174
+ return join4(getAppDir(), CONFIG_FILENAME);
175
+ }
176
+ async function loadConfig() {
177
+ const configPath = getConfigPath();
178
+ const exists = await fileExists(configPath);
179
+ if (!exists) {
180
+ throw new Error(
181
+ `Config file not found: ${configPath}
182
+ Run "syncpoint init" first.`
183
+ );
184
+ }
185
+ const raw = await readFile(configPath, "utf-8");
186
+ const data = stripDangerousKeys(YAML.parse(raw));
187
+ const result = validateConfig(data);
188
+ if (!result.valid) {
189
+ throw new Error(
190
+ `Invalid config:
191
+ ${(result.errors ?? []).join("\n")}`
192
+ );
193
+ }
194
+ return data;
195
+ }
196
+ async function initDefaultConfig() {
197
+ const created = [];
198
+ const skipped = [];
199
+ const dirs = [
200
+ getAppDir(),
201
+ getSubDir(BACKUPS_DIR),
202
+ getSubDir(TEMPLATES_DIR),
203
+ getSubDir(SCRIPTS_DIR),
204
+ getSubDir(LOGS_DIR)
205
+ ];
206
+ for (const dir of dirs) {
207
+ const exists = await fileExists(dir);
208
+ if (!exists) {
209
+ await ensureDir(dir);
210
+ created.push(dir);
211
+ } else {
212
+ skipped.push(dir);
213
+ }
214
+ }
215
+ const configPath = getConfigPath();
216
+ const configExists = await fileExists(configPath);
217
+ if (!configExists) {
218
+ const yamlContent = readAsset("config.default.yml");
219
+ await writeFile(configPath, yamlContent, "utf-8");
220
+ created.push(configPath);
221
+ } else {
222
+ skipped.push(configPath);
223
+ }
224
+ return { created, skipped };
225
+ }
226
+
227
+ // src/commands/Init.tsx
228
+ import { jsx, jsxs } from "react/jsx-runtime";
229
+ var InitView = () => {
230
+ const { exit } = useApp();
231
+ const [steps, setSteps] = useState([]);
232
+ const [error, setError] = useState(null);
233
+ const [complete, setComplete] = useState(false);
234
+ useEffect(() => {
235
+ (async () => {
236
+ try {
237
+ const appDir = getAppDir();
238
+ if (await fileExists(join5(appDir, CONFIG_FILENAME))) {
239
+ setError(`Already initialized: ${appDir}`);
240
+ exit();
241
+ return;
242
+ }
243
+ const dirs = [
244
+ { name: appDir, label: `~/.${APP_NAME}/` },
245
+ {
246
+ name: getSubDir(BACKUPS_DIR),
247
+ label: `~/.${APP_NAME}/${BACKUPS_DIR}/`
248
+ },
249
+ {
250
+ name: getSubDir(TEMPLATES_DIR),
251
+ label: `~/.${APP_NAME}/${TEMPLATES_DIR}/`
252
+ },
253
+ {
254
+ name: getSubDir(SCRIPTS_DIR),
255
+ label: `~/.${APP_NAME}/${SCRIPTS_DIR}/`
256
+ },
257
+ {
258
+ name: getSubDir(LOGS_DIR),
259
+ label: `~/.${APP_NAME}/${LOGS_DIR}/`
260
+ }
261
+ ];
262
+ const completed = [];
263
+ for (const dir of dirs) {
264
+ await ensureDir(dir.name);
265
+ completed.push({ name: `Created ${dir.label}`, done: true });
266
+ setSteps([...completed]);
267
+ }
268
+ await initDefaultConfig();
269
+ completed.push({ name: `Created ${CONFIG_FILENAME} (defaults)`, done: true });
270
+ setSteps([...completed]);
271
+ const exampleTemplatePath = join5(getSubDir(TEMPLATES_DIR), "example.yml");
272
+ if (!await fileExists(exampleTemplatePath)) {
273
+ const { writeFile: writeFile3 } = await import("fs/promises");
274
+ const exampleYaml = readAsset("template.example.yml");
275
+ await writeFile3(exampleTemplatePath, exampleYaml, "utf-8");
276
+ completed.push({ name: `Created templates/example.yml`, done: true });
277
+ setSteps([...completed]);
278
+ }
279
+ setComplete(true);
280
+ setTimeout(() => exit(), 100);
281
+ } catch (err) {
282
+ setError(err instanceof Error ? err.message : String(err));
283
+ exit();
284
+ }
285
+ })();
286
+ }, []);
287
+ if (error) {
288
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: /* @__PURE__ */ jsxs(Text, { color: "red", children: [
289
+ "\u2717 ",
290
+ error
291
+ ] }) });
292
+ }
293
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
294
+ steps.map((step, idx) => /* @__PURE__ */ jsxs(Text, { children: [
295
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "\u2713" }),
296
+ " ",
297
+ step.name
298
+ ] }, idx)),
299
+ complete && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
300
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "Initialization complete! Next steps:" }),
301
+ /* @__PURE__ */ jsxs(Text, { children: [
302
+ " ",
303
+ "1. Edit config.yml to specify backup targets"
304
+ ] }),
305
+ /* @__PURE__ */ jsxs(Text, { children: [
306
+ " ",
307
+ "\u2192 ~/.",
308
+ APP_NAME,
309
+ "/",
310
+ CONFIG_FILENAME
311
+ ] }),
312
+ /* @__PURE__ */ jsxs(Text, { children: [
313
+ " ",
314
+ "2. Run ",
315
+ APP_NAME,
316
+ " backup to create your first snapshot"
317
+ ] })
318
+ ] })
319
+ ] });
320
+ };
321
+ function registerInitCommand(program2) {
322
+ program2.command("init").description(`Initialize ~/.${APP_NAME}/ directory structure and default config`).action(async () => {
323
+ const { waitUntilExit } = render(/* @__PURE__ */ jsx(InitView, {}));
324
+ await waitUntilExit();
325
+ });
326
+ }
327
+
328
+ // src/commands/Backup.tsx
329
+ import { useState as useState2, useEffect as useEffect2 } from "react";
330
+ import { Text as Text3, Box as Box2, useApp as useApp2 } from "ink";
331
+ import { render as render2 } from "ink";
332
+
333
+ // src/core/backup.ts
334
+ import { readdir } from "fs/promises";
335
+ import { join as join8, basename } from "path";
336
+ import fg from "fast-glob";
337
+
338
+ // src/utils/system.ts
339
+ import { hostname as osHostname, platform, release, arch } from "os";
340
+ function getHostname() {
341
+ return osHostname();
342
+ }
343
+ function getSystemInfo() {
344
+ return {
345
+ platform: platform(),
346
+ release: release(),
347
+ arch: arch()
348
+ };
349
+ }
350
+ function formatHostname(name) {
351
+ const raw = name ?? getHostname();
352
+ return raw.replace(/\s+/g, "-").replace(/\./g, "-").replace(/[^a-zA-Z0-9\-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
353
+ }
354
+
355
+ // src/utils/format.ts
356
+ function formatBytes(bytes) {
357
+ if (bytes <= 0) return "0 B";
358
+ const units = ["B", "KB", "MB", "GB", "TB"];
359
+ const k = 1024;
360
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
361
+ const value = bytes / Math.pow(k, i);
362
+ if (i === 0) return `${bytes} B`;
363
+ return `${value.toFixed(1)} ${units[i]}`;
364
+ }
365
+ function formatDate(date) {
366
+ const y = date.getFullYear();
367
+ const m = String(date.getMonth() + 1).padStart(2, "0");
368
+ const d = String(date.getDate()).padStart(2, "0");
369
+ const h = String(date.getHours()).padStart(2, "0");
370
+ const min = String(date.getMinutes()).padStart(2, "0");
371
+ return `${y}-${m}-${d} ${h}:${min}`;
372
+ }
373
+ function formatDatetime(date) {
374
+ const y = date.getFullYear();
375
+ const m = String(date.getMonth() + 1).padStart(2, "0");
376
+ const d = String(date.getDate()).padStart(2, "0");
377
+ const h = String(date.getHours()).padStart(2, "0");
378
+ const min = String(date.getMinutes()).padStart(2, "0");
379
+ const s = String(date.getSeconds()).padStart(2, "0");
380
+ return `${y}-${m}-${d}_${h}${min}${s}`;
381
+ }
382
+ function formatRelativeTime(date) {
383
+ const now = Date.now();
384
+ const diffMs = now - date.getTime();
385
+ const diffSec = Math.floor(diffMs / 1e3);
386
+ const diffMin = Math.floor(diffSec / 60);
387
+ const diffHour = Math.floor(diffMin / 60);
388
+ const diffDay = Math.floor(diffHour / 24);
389
+ if (diffSec < 60) return `${diffSec}s ago`;
390
+ if (diffMin < 60) return `${diffMin}m ago`;
391
+ if (diffHour < 24) return `${diffHour}h ago`;
392
+ return `${diffDay}d ago`;
393
+ }
394
+ function generateFilename(pattern, options) {
395
+ const now = options?.date ?? /* @__PURE__ */ new Date();
396
+ const host = formatHostname(options?.hostname);
397
+ const y = now.getFullYear();
398
+ const m = String(now.getMonth() + 1).padStart(2, "0");
399
+ const d = String(now.getDate()).padStart(2, "0");
400
+ const h = String(now.getHours()).padStart(2, "0");
401
+ const min = String(now.getMinutes()).padStart(2, "0");
402
+ const s = String(now.getSeconds()).padStart(2, "0");
403
+ let result = pattern.replace(/\{hostname\}/g, host).replace(/\{date\}/g, `${y}-${m}-${d}`).replace(/\{time\}/g, `${h}${min}${s}`).replace(/\{datetime\}/g, `${y}-${m}-${d}_${h}${min}${s}`);
404
+ if (options?.tag) {
405
+ result = result.replace(/\{tag\}/g, options.tag);
406
+ if (!pattern.includes("{tag}")) {
407
+ result += `_${options.tag}`;
408
+ }
409
+ } else {
410
+ result = result.replace(/\{tag\}/g, "").replace(/_+/g, "_").replace(/_$/, "");
411
+ }
412
+ return result;
413
+ }
414
+
415
+ // src/utils/logger.ts
416
+ import { appendFile, mkdir as mkdir2 } from "fs/promises";
417
+ import { join as join6 } from "path";
418
+ import pc from "picocolors";
419
+ var ANSI_RE = /\x1b\[[0-9;]*m/g;
420
+ function stripAnsi(str) {
421
+ return str.replace(ANSI_RE, "");
422
+ }
423
+ function timestamp() {
424
+ const now = /* @__PURE__ */ new Date();
425
+ const h = String(now.getHours()).padStart(2, "0");
426
+ const m = String(now.getMinutes()).padStart(2, "0");
427
+ const s = String(now.getSeconds()).padStart(2, "0");
428
+ return `${h}:${m}:${s}`;
429
+ }
430
+ function dateStamp() {
431
+ const now = /* @__PURE__ */ new Date();
432
+ const y = now.getFullYear();
433
+ const m = String(now.getMonth() + 1).padStart(2, "0");
434
+ const d = String(now.getDate()).padStart(2, "0");
435
+ return `${y}-${m}-${d}`;
436
+ }
437
+ var logDirCreated = false;
438
+ async function writeToFile(level, message) {
439
+ try {
440
+ const logsDir = join6(getAppDir(), LOGS_DIR);
441
+ if (!logDirCreated) {
442
+ await mkdir2(logsDir, { recursive: true });
443
+ logDirCreated = true;
444
+ }
445
+ const logFile = join6(logsDir, `${dateStamp()}.log`);
446
+ const line = `[${timestamp()}] [${level.toUpperCase()}] ${stripAnsi(message)}
447
+ `;
448
+ await appendFile(logFile, line, "utf-8");
449
+ } catch {
450
+ }
451
+ }
452
+ var logger = {
453
+ info(message) {
454
+ console.log(`${pc.blue("info")} ${message}`);
455
+ void writeToFile("info", message);
456
+ },
457
+ success(message) {
458
+ console.log(`${pc.green("success")} ${message}`);
459
+ void writeToFile("success", message);
460
+ },
461
+ warn(message) {
462
+ console.warn(`${pc.yellow("warn")} ${message}`);
463
+ void writeToFile("warn", message);
464
+ },
465
+ error(message) {
466
+ console.error(`${pc.red("error")} ${message}`);
467
+ void writeToFile("error", message);
468
+ }
469
+ };
470
+
471
+ // src/core/metadata.ts
472
+ import { createHash } from "crypto";
473
+ import { readFile as readFile2, lstat } from "fs/promises";
474
+
475
+ // src/schemas/metadata.schema.ts
476
+ var metadataSchema = {
477
+ type: "object",
478
+ required: [
479
+ "version",
480
+ "toolVersion",
481
+ "createdAt",
482
+ "hostname",
483
+ "system",
484
+ "config",
485
+ "files",
486
+ "summary"
487
+ ],
488
+ properties: {
489
+ version: { type: "string" },
490
+ toolVersion: { type: "string" },
491
+ createdAt: { type: "string" },
492
+ hostname: { type: "string" },
493
+ system: {
494
+ type: "object",
495
+ required: ["platform", "release", "arch"],
496
+ properties: {
497
+ platform: { type: "string" },
498
+ release: { type: "string" },
499
+ arch: { type: "string" }
500
+ },
501
+ additionalProperties: false
502
+ },
503
+ config: {
504
+ type: "object",
505
+ required: ["filename"],
506
+ properties: {
507
+ filename: { type: "string" },
508
+ destination: { type: "string" }
509
+ },
510
+ additionalProperties: false
511
+ },
512
+ files: {
513
+ type: "array",
514
+ items: {
515
+ type: "object",
516
+ required: ["path", "absolutePath", "size", "hash"],
517
+ properties: {
518
+ path: { type: "string" },
519
+ absolutePath: { type: "string" },
520
+ size: { type: "number", minimum: 0 },
521
+ hash: { type: "string" },
522
+ type: { type: "string" }
523
+ },
524
+ additionalProperties: false
525
+ }
526
+ },
527
+ summary: {
528
+ type: "object",
529
+ required: ["fileCount", "totalSize"],
530
+ properties: {
531
+ fileCount: { type: "integer", minimum: 0 },
532
+ totalSize: { type: "number", minimum: 0 }
533
+ },
534
+ additionalProperties: false
535
+ }
536
+ },
537
+ additionalProperties: false
538
+ };
539
+ var validate2 = ajv.compile(metadataSchema);
540
+ function validateMetadata(data) {
541
+ const valid = validate2(data);
542
+ if (valid) return { valid: true };
543
+ const errors = validate2.errors?.map(
544
+ (e) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`
545
+ );
546
+ return { valid: false, errors };
547
+ }
548
+
549
+ // src/core/metadata.ts
550
+ var METADATA_VERSION = "1.0.0";
551
+ function createMetadata(files, config) {
552
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
553
+ return {
554
+ version: METADATA_VERSION,
555
+ toolVersion: APP_VERSION,
556
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
557
+ hostname: getHostname(),
558
+ system: getSystemInfo(),
559
+ config: {
560
+ filename: config.backup.filename,
561
+ destination: config.backup.destination
562
+ },
563
+ files,
564
+ summary: {
565
+ fileCount: files.length,
566
+ totalSize
567
+ }
568
+ };
569
+ }
570
+ function parseMetadata(data) {
571
+ const str = typeof data === "string" ? data : data.toString("utf-8");
572
+ const parsed = JSON.parse(str);
573
+ const result = validateMetadata(parsed);
574
+ if (!result.valid) {
575
+ throw new Error(
576
+ `Invalid metadata:
577
+ ${(result.errors ?? []).join("\n")}`
578
+ );
579
+ }
580
+ return parsed;
581
+ }
582
+ async function computeFileHash(filePath) {
583
+ const content = await readFile2(filePath);
584
+ const hash = createHash("sha256").update(content).digest("hex");
585
+ return `sha256:${hash}`;
586
+ }
587
+ async function collectFileInfo(absolutePath, logicalPath) {
588
+ const lstats = await lstat(absolutePath);
589
+ let type;
590
+ if (lstats.isSymbolicLink()) {
591
+ type = "symlink";
592
+ } else if (lstats.isDirectory()) {
593
+ type = "directory";
594
+ }
595
+ let hash;
596
+ if (lstats.isSymbolicLink()) {
597
+ hash = `sha256:${createHash("sha256").update(absolutePath).digest("hex")}`;
598
+ } else {
599
+ hash = await computeFileHash(absolutePath);
600
+ }
601
+ return {
602
+ path: contractTilde(logicalPath),
603
+ absolutePath,
604
+ size: lstats.size,
605
+ hash,
606
+ type
607
+ };
608
+ }
609
+
610
+ // src/core/storage.ts
611
+ import { mkdir as mkdir3, mkdtemp, readFile as readFile3, rm, writeFile as writeFile2 } from "fs/promises";
612
+ import { tmpdir } from "os";
613
+ import { join as join7, normalize as normalize2 } from "path";
614
+ import * as tar from "tar";
615
+ async function createArchive(files, outputPath) {
616
+ const tmpDir = await mkdtemp(join7(tmpdir(), "syncpoint-"));
617
+ try {
618
+ const fileNames = [];
619
+ for (const file of files) {
620
+ const targetPath = join7(tmpDir, file.name);
621
+ const parentDir = join7(tmpDir, file.name.split("/").slice(0, -1).join("/"));
622
+ if (parentDir !== tmpDir) {
623
+ await mkdir3(parentDir, { recursive: true });
624
+ }
625
+ if (file.content !== void 0) {
626
+ await writeFile2(targetPath, file.content);
627
+ } else if (file.sourcePath) {
628
+ const data = await readFile3(file.sourcePath);
629
+ await writeFile2(targetPath, data);
630
+ }
631
+ fileNames.push(file.name);
632
+ }
633
+ await tar.create(
634
+ {
635
+ gzip: true,
636
+ file: outputPath,
637
+ cwd: tmpDir
638
+ },
639
+ fileNames
640
+ );
641
+ } finally {
642
+ await rm(tmpDir, { recursive: true, force: true });
643
+ }
644
+ }
645
+ async function extractArchive(archivePath, destDir) {
646
+ await mkdir3(destDir, { recursive: true });
647
+ await tar.extract({
648
+ file: archivePath,
649
+ cwd: destDir,
650
+ preservePaths: false,
651
+ filter: (path, entry) => {
652
+ const normalizedPath = normalize2(path);
653
+ if (normalizedPath.includes("..")) return false;
654
+ if (normalizedPath.startsWith("/")) return false;
655
+ if (entry.type === "SymbolicLink" || entry.type === "Link") return false;
656
+ return true;
657
+ }
658
+ });
659
+ }
660
+ async function readFileFromArchive(archivePath, filename) {
661
+ if (filename.includes("..") || filename.startsWith("/")) {
662
+ throw new Error(`Invalid filename: ${filename}`);
663
+ }
664
+ const tmpDir = await mkdtemp(join7(tmpdir(), "syncpoint-read-"));
665
+ try {
666
+ await tar.extract({
667
+ file: archivePath,
668
+ cwd: tmpDir,
669
+ filter: (path) => {
670
+ const normalized = path.replace(/^\.\//, "");
671
+ return normalized === filename;
672
+ }
673
+ });
674
+ const extractedPath = join7(tmpDir, filename);
675
+ try {
676
+ return await readFile3(extractedPath);
677
+ } catch {
678
+ return null;
679
+ }
680
+ } finally {
681
+ await rm(tmpDir, { recursive: true, force: true });
682
+ }
683
+ }
684
+
685
+ // src/core/backup.ts
686
+ function isSensitiveFile(filePath) {
687
+ const name = basename(filePath);
688
+ return SENSITIVE_PATTERNS.some((pattern) => {
689
+ if (pattern.startsWith("*")) {
690
+ return name.endsWith(pattern.slice(1));
691
+ }
692
+ return name === pattern || filePath.includes(pattern);
693
+ });
694
+ }
695
+ async function scanTargets(config) {
696
+ const found = [];
697
+ const missing = [];
698
+ for (const target of config.backup.targets) {
699
+ const expanded = expandTilde(target);
700
+ if (expanded.includes("*") || expanded.includes("?") || expanded.includes("{")) {
701
+ const matches = await fg(expanded, {
702
+ dot: true,
703
+ absolute: true,
704
+ ignore: config.backup.exclude,
705
+ onlyFiles: true
706
+ });
707
+ for (const match of matches) {
708
+ const entry = await collectFileInfo(match, match);
709
+ found.push(entry);
710
+ }
711
+ } else {
712
+ const absPath = resolveTargetPath(target);
713
+ const exists = await fileExists(absPath);
714
+ if (!exists) {
715
+ missing.push(target);
716
+ continue;
717
+ }
718
+ const entry = await collectFileInfo(absPath, absPath);
719
+ if (entry.size > LARGE_FILE_THRESHOLD) {
720
+ logger.warn(
721
+ `Large file (>${Math.round(LARGE_FILE_THRESHOLD / 1024 / 1024)}MB): ${target}`
722
+ );
723
+ }
724
+ if (isSensitiveFile(absPath)) {
725
+ logger.warn(`Sensitive file detected: ${target}`);
726
+ }
727
+ found.push(entry);
728
+ }
729
+ }
730
+ return { found, missing };
731
+ }
732
+ async function collectScripts() {
733
+ const scriptsDir = getSubDir(SCRIPTS_DIR);
734
+ const exists = await fileExists(scriptsDir);
735
+ if (!exists) return [];
736
+ const entries = [];
737
+ try {
738
+ const files = await readdir(scriptsDir, { withFileTypes: true });
739
+ for (const file of files) {
740
+ if (file.isFile() && file.name.endsWith(".sh")) {
741
+ const absPath = join8(scriptsDir, file.name);
742
+ const entry = await collectFileInfo(absPath, absPath);
743
+ entries.push(entry);
744
+ }
745
+ }
746
+ } catch {
747
+ logger.info("Skipping unreadable scripts directory");
748
+ }
749
+ return entries;
750
+ }
751
+ async function createBackup(config, options = {}) {
752
+ const { found, missing } = await scanTargets(config);
753
+ for (const m of missing) {
754
+ logger.warn(`File not found, skipping: ${m}`);
755
+ }
756
+ let allFiles = [...found];
757
+ if (config.scripts.includeInBackup) {
758
+ const scripts = await collectScripts();
759
+ allFiles = [...allFiles, ...scripts];
760
+ }
761
+ if (allFiles.length === 0) {
762
+ throw new Error("No files found to backup.");
763
+ }
764
+ const metadata = createMetadata(allFiles, config);
765
+ const filename = generateFilename(config.backup.filename, {
766
+ tag: options.tag
767
+ });
768
+ const archiveFilename = `${filename}.tar.gz`;
769
+ const destDir = config.backup.destination ? resolveTargetPath(config.backup.destination) : getSubDir(BACKUPS_DIR);
770
+ await ensureDir(destDir);
771
+ const archivePath = join8(destDir, archiveFilename);
772
+ if (options.dryRun) {
773
+ return { archivePath, metadata };
774
+ }
775
+ const archiveFiles = [];
776
+ archiveFiles.push({
777
+ name: METADATA_FILENAME,
778
+ content: JSON.stringify(metadata, null, 2)
779
+ });
780
+ for (const file of allFiles) {
781
+ archiveFiles.push({
782
+ name: file.path.startsWith("~/") ? file.path.slice(2) : file.path,
783
+ sourcePath: file.absolutePath
784
+ });
785
+ }
786
+ await createArchive(archiveFiles, archivePath);
787
+ logger.success(`Backup created: ${archivePath}`);
788
+ return { archivePath, metadata };
789
+ }
790
+
791
+ // src/components/ProgressBar.tsx
792
+ import { Text as Text2 } from "ink";
793
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
794
+ var ProgressBar = ({
795
+ percent,
796
+ width = 30
797
+ }) => {
798
+ const clamped = Math.max(0, Math.min(100, percent));
799
+ const filled = Math.round(width * (clamped / 100));
800
+ const empty = width - filled;
801
+ return /* @__PURE__ */ jsxs2(Text2, { children: [
802
+ /* @__PURE__ */ jsx2(Text2, { color: "green", children: "\u2588".repeat(filled) }),
803
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u2591".repeat(empty) }),
804
+ /* @__PURE__ */ jsxs2(Text2, { children: [
805
+ " ",
806
+ clamped,
807
+ "%"
808
+ ] })
809
+ ] });
810
+ };
811
+
812
+ // src/commands/Backup.tsx
813
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
814
+ var BackupView = ({ options }) => {
815
+ const { exit } = useApp2();
816
+ const [phase, setPhase] = useState2("scanning");
817
+ const [, setConfig] = useState2(null);
818
+ const [foundFiles, setFoundFiles] = useState2([]);
819
+ const [missingFiles, setMissingFiles] = useState2([]);
820
+ const [progress, setProgress] = useState2(0);
821
+ const [result, setResult] = useState2(null);
822
+ const [error, setError] = useState2(null);
823
+ useEffect2(() => {
824
+ (async () => {
825
+ try {
826
+ const cfg = await loadConfig();
827
+ setConfig(cfg);
828
+ const { found, missing } = await scanTargets(cfg);
829
+ setFoundFiles(found);
830
+ setMissingFiles(missing);
831
+ if (options.dryRun) {
832
+ setPhase("done");
833
+ setTimeout(() => exit(), 100);
834
+ return;
835
+ }
836
+ setPhase("compressing");
837
+ const progressInterval = setInterval(() => {
838
+ setProgress((prev) => {
839
+ if (prev >= 90) return prev;
840
+ return prev + 10;
841
+ });
842
+ }, 100);
843
+ const backupResult = await createBackup(cfg, options);
844
+ clearInterval(progressInterval);
845
+ setProgress(100);
846
+ setResult(backupResult);
847
+ setPhase("done");
848
+ setTimeout(() => exit(), 100);
849
+ } catch (err) {
850
+ setError(err instanceof Error ? err.message : String(err));
851
+ setPhase("error");
852
+ exit();
853
+ }
854
+ })();
855
+ }, []);
856
+ if (phase === "error" || error) {
857
+ return /* @__PURE__ */ jsx3(Box2, { flexDirection: "column", children: /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
858
+ "\u2717 Backup failed: ",
859
+ error
860
+ ] }) });
861
+ }
862
+ return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
863
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "\u25B8 Scanning backup targets..." }),
864
+ foundFiles.map((file, idx) => /* @__PURE__ */ jsxs3(Text3, { children: [
865
+ " ",
866
+ /* @__PURE__ */ jsx3(Text3, { color: "green", children: "\u2713" }),
867
+ " ",
868
+ contractTilde(file.absolutePath),
869
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
870
+ " ",
871
+ formatBytes(file.size).padStart(10)
872
+ ] })
873
+ ] }, idx)),
874
+ missingFiles.map((file, idx) => /* @__PURE__ */ jsxs3(Text3, { children: [
875
+ " ",
876
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "\u26A0" }),
877
+ " ",
878
+ file,
879
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
880
+ " ",
881
+ "File not found, skipped"
882
+ ] })
883
+ ] }, idx)),
884
+ options.dryRun && phase === "done" && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
885
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "(dry-run) No actual backup was created" }),
886
+ /* @__PURE__ */ jsxs3(Text3, { children: [
887
+ "Target files: ",
888
+ foundFiles.length,
889
+ " (",
890
+ formatBytes(foundFiles.reduce((sum, f) => sum + f.size, 0)),
891
+ ")"
892
+ ] })
893
+ ] }),
894
+ phase === "compressing" && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
895
+ /* @__PURE__ */ jsx3(Text3, { children: "\u25B8 Compressing..." }),
896
+ /* @__PURE__ */ jsxs3(Text3, { children: [
897
+ " ",
898
+ /* @__PURE__ */ jsx3(ProgressBar, { percent: progress })
899
+ ] })
900
+ ] }),
901
+ phase === "done" && result && !options.dryRun && /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", marginTop: 1, children: [
902
+ /* @__PURE__ */ jsx3(Text3, { color: "green", bold: true, children: "\u2713 Backup complete" }),
903
+ /* @__PURE__ */ jsxs3(Text3, { children: [
904
+ " ",
905
+ "File: ",
906
+ result.metadata.config.filename
907
+ ] }),
908
+ /* @__PURE__ */ jsxs3(Text3, { children: [
909
+ " ",
910
+ "Size: ",
911
+ formatBytes(result.metadata.summary.totalSize),
912
+ " (",
913
+ result.metadata.summary.fileCount,
914
+ " files + metadata)"
915
+ ] }),
916
+ /* @__PURE__ */ jsxs3(Text3, { children: [
917
+ " ",
918
+ "Path: ",
919
+ contractTilde(result.archivePath)
920
+ ] })
921
+ ] })
922
+ ] });
923
+ };
924
+ function registerBackupCommand(program2) {
925
+ program2.command("backup").description("Create a config file backup").option("--dry-run", "Show target file list without actual compression", false).option("--tag <name>", "Add a tag to the backup filename").action(async (opts) => {
926
+ const { waitUntilExit } = render2(
927
+ /* @__PURE__ */ jsx3(BackupView, { options: { dryRun: opts.dryRun, tag: opts.tag } })
928
+ );
929
+ await waitUntilExit();
930
+ });
931
+ }
932
+
933
+ // src/commands/Restore.tsx
934
+ import { useState as useState4, useEffect as useEffect3 } from "react";
935
+ import { Text as Text5, Box as Box3, useApp as useApp3 } from "ink";
936
+ import SelectInput from "ink-select-input";
937
+ import { render as render3 } from "ink";
938
+
939
+ // src/core/restore.ts
940
+ import { copyFile, lstat as lstat2, readdir as readdir2, stat as stat2 } from "fs/promises";
941
+ import { join as join9, dirname as dirname2 } from "path";
942
+ async function getBackupList(config) {
943
+ const backupDir = config?.backup.destination ? resolveTargetPath(config.backup.destination) : getSubDir(BACKUPS_DIR);
944
+ const exists = await fileExists(backupDir);
945
+ if (!exists) return [];
946
+ const entries = await readdir2(backupDir, { withFileTypes: true });
947
+ const backups = [];
948
+ for (const entry of entries) {
949
+ if (!entry.isFile() || !entry.name.endsWith(".tar.gz")) continue;
950
+ const fullPath = join9(backupDir, entry.name);
951
+ const fileStat = await stat2(fullPath);
952
+ let hostname;
953
+ let fileCount;
954
+ try {
955
+ const metaBuf = await readFileFromArchive(fullPath, METADATA_FILENAME);
956
+ if (metaBuf) {
957
+ const meta = parseMetadata(metaBuf);
958
+ hostname = meta.hostname;
959
+ fileCount = meta.summary.fileCount;
960
+ }
961
+ } catch {
962
+ logger.info(`Could not read metadata from: ${entry.name}`);
963
+ }
964
+ backups.push({
965
+ filename: entry.name,
966
+ path: fullPath,
967
+ size: fileStat.size,
968
+ createdAt: fileStat.mtime,
969
+ hostname,
970
+ fileCount
971
+ });
972
+ }
973
+ backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
974
+ return backups;
975
+ }
976
+ async function getRestorePlan(archivePath) {
977
+ const metaBuf = await readFileFromArchive(archivePath, METADATA_FILENAME);
978
+ if (!metaBuf) {
979
+ throw new Error(
980
+ `No metadata found in archive: ${archivePath}
981
+ This may not be a valid syncpoint backup.`
982
+ );
983
+ }
984
+ const metadata = parseMetadata(metaBuf);
985
+ const actions = [];
986
+ for (const file of metadata.files) {
987
+ const absPath = resolveTargetPath(file.path);
988
+ const exists = await fileExists(absPath);
989
+ if (!exists) {
990
+ actions.push({
991
+ path: file.path,
992
+ action: "create",
993
+ backupSize: file.size,
994
+ reason: "File does not exist on this machine"
995
+ });
996
+ continue;
997
+ }
998
+ const currentHash = await computeFileHash(absPath);
999
+ const currentStat = await stat2(absPath);
1000
+ if (currentHash === file.hash) {
1001
+ actions.push({
1002
+ path: file.path,
1003
+ action: "skip",
1004
+ currentSize: currentStat.size,
1005
+ backupSize: file.size,
1006
+ reason: "File is identical (same hash)"
1007
+ });
1008
+ } else {
1009
+ actions.push({
1010
+ path: file.path,
1011
+ action: "overwrite",
1012
+ currentSize: currentStat.size,
1013
+ backupSize: file.size,
1014
+ reason: "File has been modified"
1015
+ });
1016
+ }
1017
+ }
1018
+ return { metadata, actions };
1019
+ }
1020
+ async function createSafetyBackup(filePaths) {
1021
+ const now = /* @__PURE__ */ new Date();
1022
+ const filename = `_pre-restore_${formatDatetime(now)}.tar.gz`;
1023
+ const backupDir = getSubDir(BACKUPS_DIR);
1024
+ await ensureDir(backupDir);
1025
+ const archivePath = join9(backupDir, filename);
1026
+ const files = [];
1027
+ for (const fp of filePaths) {
1028
+ const absPath = resolveTargetPath(fp);
1029
+ const exists = await fileExists(absPath);
1030
+ if (!exists) continue;
1031
+ const archiveName = fp.startsWith("~/") ? fp.slice(2) : fp;
1032
+ files.push({ name: archiveName, sourcePath: absPath });
1033
+ }
1034
+ if (files.length === 0) {
1035
+ logger.info("No existing files to safety-backup.");
1036
+ return archivePath;
1037
+ }
1038
+ await createArchive(files, archivePath);
1039
+ logger.info(`Safety backup created: ${archivePath}`);
1040
+ return archivePath;
1041
+ }
1042
+ async function restoreBackup(archivePath, options = {}) {
1043
+ const plan = await getRestorePlan(archivePath);
1044
+ const restoredFiles = [];
1045
+ const skippedFiles = [];
1046
+ const overwritePaths = plan.actions.filter((a) => a.action === "overwrite").map((a) => a.path);
1047
+ let safetyBackupPath;
1048
+ if (overwritePaths.length > 0 && !options.dryRun) {
1049
+ safetyBackupPath = await createSafetyBackup(overwritePaths);
1050
+ }
1051
+ if (options.dryRun) {
1052
+ return {
1053
+ restoredFiles: plan.actions.filter((a) => a.action !== "skip").map((a) => a.path),
1054
+ skippedFiles: plan.actions.filter((a) => a.action === "skip").map((a) => a.path),
1055
+ safetyBackupPath
1056
+ };
1057
+ }
1058
+ const { mkdtemp: mkdtemp2, rm: rm2 } = await import("fs/promises");
1059
+ const { tmpdir: tmpdir2 } = await import("os");
1060
+ const tmpDir = await mkdtemp2(join9(tmpdir2(), "syncpoint-restore-"));
1061
+ try {
1062
+ await extractArchive(archivePath, tmpDir);
1063
+ for (const action of plan.actions) {
1064
+ if (action.action === "skip") {
1065
+ skippedFiles.push(action.path);
1066
+ continue;
1067
+ }
1068
+ const archiveName = action.path.startsWith("~/") ? action.path.slice(2) : action.path;
1069
+ const extractedPath = join9(tmpDir, archiveName);
1070
+ const destPath = resolveTargetPath(action.path);
1071
+ const extractedExists = await fileExists(extractedPath);
1072
+ if (!extractedExists) {
1073
+ logger.warn(`File not found in archive: ${archiveName}`);
1074
+ skippedFiles.push(action.path);
1075
+ continue;
1076
+ }
1077
+ await ensureDir(dirname2(destPath));
1078
+ try {
1079
+ const destStat = await lstat2(destPath);
1080
+ if (destStat.isSymbolicLink()) {
1081
+ logger.warn(`Skipping symlink target: ${action.path}`);
1082
+ skippedFiles.push(action.path);
1083
+ continue;
1084
+ }
1085
+ } catch (err) {
1086
+ if (err.code !== "ENOENT") throw err;
1087
+ }
1088
+ await copyFile(extractedPath, destPath);
1089
+ restoredFiles.push(action.path);
1090
+ }
1091
+ } finally {
1092
+ await rm2(tmpDir, { recursive: true, force: true });
1093
+ }
1094
+ return { restoredFiles, skippedFiles, safetyBackupPath };
1095
+ }
1096
+
1097
+ // src/components/Confirm.tsx
1098
+ import { Text as Text4, useInput } from "ink";
1099
+ import { useState as useState3 } from "react";
1100
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1101
+ var Confirm = ({
1102
+ message,
1103
+ onConfirm,
1104
+ defaultYes = true
1105
+ }) => {
1106
+ const [answered, setAnswered] = useState3(false);
1107
+ useInput((input, key) => {
1108
+ if (answered) return;
1109
+ if (input === "y" || input === "Y") {
1110
+ setAnswered(true);
1111
+ onConfirm(true);
1112
+ } else if (input === "n" || input === "N") {
1113
+ setAnswered(true);
1114
+ onConfirm(false);
1115
+ } else if (key.return) {
1116
+ setAnswered(true);
1117
+ onConfirm(defaultYes);
1118
+ }
1119
+ });
1120
+ const yText = defaultYes ? /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Y" }) : /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "y" });
1121
+ const nText = defaultYes ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "n" }) : /* @__PURE__ */ jsx4(Text4, { bold: true, children: "N" });
1122
+ return /* @__PURE__ */ jsxs4(Text4, { children: [
1123
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "? " }),
1124
+ message,
1125
+ " ",
1126
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "[" }),
1127
+ yText,
1128
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "/" }),
1129
+ nText,
1130
+ /* @__PURE__ */ jsx4(Text4, { color: "gray", children: "]" })
1131
+ ] });
1132
+ };
1133
+
1134
+ // src/commands/Restore.tsx
1135
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1136
+ var RestoreView = ({ filename, options }) => {
1137
+ const { exit } = useApp3();
1138
+ const [phase, setPhase] = useState4("loading");
1139
+ const [backups, setBackups] = useState4([]);
1140
+ const [selectedPath, setSelectedPath] = useState4(null);
1141
+ const [plan, setPlan] = useState4(null);
1142
+ const [result, setResult] = useState4(null);
1143
+ const [safetyDone, setSafetyDone] = useState4(false);
1144
+ const [error, setError] = useState4(null);
1145
+ useEffect3(() => {
1146
+ (async () => {
1147
+ try {
1148
+ const list2 = await getBackupList();
1149
+ setBackups(list2);
1150
+ if (list2.length === 0) {
1151
+ setError("No backups available.");
1152
+ setPhase("error");
1153
+ exit();
1154
+ return;
1155
+ }
1156
+ if (filename) {
1157
+ const match = list2.find(
1158
+ (b) => b.filename === filename || b.filename.startsWith(filename)
1159
+ );
1160
+ if (!match) {
1161
+ setError(`Backup not found: ${filename}`);
1162
+ setPhase("error");
1163
+ exit();
1164
+ return;
1165
+ }
1166
+ setSelectedPath(match.path);
1167
+ setPhase("planning");
1168
+ } else {
1169
+ setPhase("selecting");
1170
+ }
1171
+ } catch (err) {
1172
+ setError(err instanceof Error ? err.message : String(err));
1173
+ setPhase("error");
1174
+ exit();
1175
+ }
1176
+ })();
1177
+ }, []);
1178
+ useEffect3(() => {
1179
+ if (phase !== "planning" || !selectedPath) return;
1180
+ (async () => {
1181
+ try {
1182
+ const restorePlan = await getRestorePlan(selectedPath);
1183
+ setPlan(restorePlan);
1184
+ if (options.dryRun) {
1185
+ setPhase("done");
1186
+ setTimeout(() => exit(), 100);
1187
+ } else {
1188
+ setPhase("confirming");
1189
+ }
1190
+ } catch (err) {
1191
+ setError(err instanceof Error ? err.message : String(err));
1192
+ setPhase("error");
1193
+ exit();
1194
+ }
1195
+ })();
1196
+ }, [phase, selectedPath]);
1197
+ const handleSelect = (item) => {
1198
+ setSelectedPath(item.value);
1199
+ setPhase("planning");
1200
+ };
1201
+ const handleConfirm = async (yes) => {
1202
+ if (!yes || !selectedPath) {
1203
+ setPhase("done");
1204
+ setTimeout(() => exit(), 100);
1205
+ return;
1206
+ }
1207
+ try {
1208
+ setPhase("restoring");
1209
+ try {
1210
+ const config = await loadConfig();
1211
+ await createBackup(config, { tag: "pre-restore" });
1212
+ setSafetyDone(true);
1213
+ } catch {
1214
+ }
1215
+ const restoreResult = await restoreBackup(selectedPath, options);
1216
+ setResult(restoreResult);
1217
+ setPhase("done");
1218
+ setTimeout(() => exit(), 100);
1219
+ } catch (err) {
1220
+ setError(err instanceof Error ? err.message : String(err));
1221
+ setPhase("error");
1222
+ exit();
1223
+ }
1224
+ };
1225
+ if (phase === "error" || error) {
1226
+ return /* @__PURE__ */ jsx5(Box3, { flexDirection: "column", children: /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
1227
+ "\u2717 ",
1228
+ error
1229
+ ] }) });
1230
+ }
1231
+ const selectItems = backups.map((b, idx) => ({
1232
+ label: `${String(idx + 1).padStart(2)} ${b.filename.replace(".tar.gz", "").padEnd(40)} ${formatBytes(b.size).padStart(8)} ${formatDate(b.createdAt)}`,
1233
+ value: b.path
1234
+ }));
1235
+ const currentHostname = getHostname();
1236
+ const isRemoteBackup = plan?.metadata.hostname && plan.metadata.hostname !== currentHostname;
1237
+ return /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", children: [
1238
+ phase === "loading" && /* @__PURE__ */ jsx5(Text5, { children: "\u25B8 Loading backup list..." }),
1239
+ phase === "selecting" && /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", children: [
1240
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "\u25B8 Select backup" }),
1241
+ /* @__PURE__ */ jsx5(SelectInput, { items: selectItems, onSelect: handleSelect })
1242
+ ] }),
1243
+ (phase === "planning" || phase === "confirming" || phase === "restoring" || phase === "done" && plan) && plan && /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", children: [
1244
+ /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", marginBottom: 1, children: [
1245
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
1246
+ "\u25B8 Metadata (",
1247
+ plan.metadata.config.filename ?? "",
1248
+ ")"
1249
+ ] }),
1250
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1251
+ " ",
1252
+ "Host: ",
1253
+ plan.metadata.hostname
1254
+ ] }),
1255
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1256
+ " ",
1257
+ "Created: ",
1258
+ plan.metadata.createdAt
1259
+ ] }),
1260
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1261
+ " ",
1262
+ "Files: ",
1263
+ plan.metadata.summary.fileCount,
1264
+ " (",
1265
+ formatBytes(plan.metadata.summary.totalSize),
1266
+ ")"
1267
+ ] }),
1268
+ isRemoteBackup && /* @__PURE__ */ jsxs5(Text5, { color: "yellow", children: [
1269
+ " ",
1270
+ "\u26A0 This backup was created on a different machine (",
1271
+ plan.metadata.hostname,
1272
+ ")"
1273
+ ] })
1274
+ ] }),
1275
+ /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", marginBottom: 1, children: [
1276
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "\u25B8 Restore plan:" }),
1277
+ plan.actions.map((action, idx) => {
1278
+ let icon;
1279
+ let color;
1280
+ let label;
1281
+ switch (action.action) {
1282
+ case "overwrite":
1283
+ icon = "Overwrite";
1284
+ color = "yellow";
1285
+ label = `(${formatBytes(action.currentSize ?? 0)} \u2192 ${formatBytes(action.backupSize ?? 0)}, ${action.reason})`;
1286
+ break;
1287
+ case "skip":
1288
+ icon = "Skip";
1289
+ color = "gray";
1290
+ label = `(${action.reason})`;
1291
+ break;
1292
+ case "create":
1293
+ icon = "Create";
1294
+ color = "green";
1295
+ label = "(not present)";
1296
+ break;
1297
+ }
1298
+ return /* @__PURE__ */ jsxs5(Text5, { children: [
1299
+ " ",
1300
+ /* @__PURE__ */ jsx5(Text5, { color, children: icon.padEnd(8) }),
1301
+ " ",
1302
+ contractTilde(action.path),
1303
+ " ",
1304
+ /* @__PURE__ */ jsx5(Text5, { color: "gray", children: label })
1305
+ ] }, idx);
1306
+ })
1307
+ ] }),
1308
+ options.dryRun && phase === "done" && /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "(dry-run) No actual restore was performed" })
1309
+ ] }),
1310
+ phase === "confirming" && /* @__PURE__ */ jsx5(Box3, { flexDirection: "column", children: /* @__PURE__ */ jsx5(Confirm, { message: "Proceed with restore?", onConfirm: handleConfirm }) }),
1311
+ phase === "restoring" && /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", children: [
1312
+ safetyDone && /* @__PURE__ */ jsxs5(Text5, { children: [
1313
+ /* @__PURE__ */ jsx5(Text5, { color: "green", children: "\u2713" }),
1314
+ " Safety backup of current files complete"
1315
+ ] }),
1316
+ /* @__PURE__ */ jsx5(Text5, { children: "\u25B8 Restoring..." })
1317
+ ] }),
1318
+ phase === "done" && result && !options.dryRun && /* @__PURE__ */ jsxs5(Box3, { flexDirection: "column", marginTop: 1, children: [
1319
+ safetyDone && /* @__PURE__ */ jsxs5(Text5, { children: [
1320
+ /* @__PURE__ */ jsx5(Text5, { color: "green", children: "\u2713" }),
1321
+ " Safety backup of current files complete"
1322
+ ] }),
1323
+ /* @__PURE__ */ jsx5(Text5, { color: "green", bold: true, children: "\u2713 Restore complete" }),
1324
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1325
+ " ",
1326
+ "Restored: ",
1327
+ result.restoredFiles.length,
1328
+ " files"
1329
+ ] }),
1330
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1331
+ " ",
1332
+ "Skipped: ",
1333
+ result.skippedFiles.length,
1334
+ " files"
1335
+ ] }),
1336
+ result.safetyBackupPath && /* @__PURE__ */ jsxs5(Text5, { children: [
1337
+ " ",
1338
+ "Safety backup: ",
1339
+ contractTilde(result.safetyBackupPath)
1340
+ ] })
1341
+ ] })
1342
+ ] });
1343
+ };
1344
+ function registerRestoreCommand(program2) {
1345
+ program2.command("restore [filename]").description("Restore config files from a backup").option("--dry-run", "Show planned changes without actual restore", false).action(async (filename, opts) => {
1346
+ const { waitUntilExit } = render3(
1347
+ /* @__PURE__ */ jsx5(
1348
+ RestoreView,
1349
+ {
1350
+ filename,
1351
+ options: { dryRun: opts.dryRun }
1352
+ }
1353
+ )
1354
+ );
1355
+ await waitUntilExit();
1356
+ });
1357
+ }
1358
+
1359
+ // src/commands/Provision.tsx
1360
+ import { useState as useState5, useEffect as useEffect4 } from "react";
1361
+ import { Text as Text7, Box as Box5, useApp as useApp4 } from "ink";
1362
+ import { render as render4 } from "ink";
1363
+
1364
+ // src/core/provision.ts
1365
+ import { exec } from "child_process";
1366
+ import { readFile as readFile4, readdir as readdir3 } from "fs/promises";
1367
+ import { join as join10 } from "path";
1368
+ import YAML2 from "yaml";
1369
+
1370
+ // src/schemas/template.schema.ts
1371
+ var templateSchema = {
1372
+ type: "object",
1373
+ required: ["name", "steps"],
1374
+ properties: {
1375
+ name: { type: "string", minLength: 1 },
1376
+ description: { type: "string" },
1377
+ backup: { type: "string" },
1378
+ sudo: { type: "boolean" },
1379
+ steps: {
1380
+ type: "array",
1381
+ minItems: 1,
1382
+ items: {
1383
+ type: "object",
1384
+ required: ["name", "command"],
1385
+ properties: {
1386
+ name: { type: "string", minLength: 1 },
1387
+ description: { type: "string" },
1388
+ command: { type: "string", minLength: 1 },
1389
+ skip_if: { type: "string" },
1390
+ continue_on_error: { type: "boolean" }
1391
+ },
1392
+ additionalProperties: false
1393
+ }
1394
+ }
1395
+ },
1396
+ additionalProperties: false
1397
+ };
1398
+ var validate3 = ajv.compile(templateSchema);
1399
+ function validateTemplate(data) {
1400
+ const valid = validate3(data);
1401
+ if (valid) return { valid: true };
1402
+ const errors = validate3.errors?.map(
1403
+ (e) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`
1404
+ );
1405
+ return { valid: false, errors };
1406
+ }
1407
+
1408
+ // src/core/provision.ts
1409
+ var REMOTE_SCRIPT_PATTERNS = [
1410
+ /curl\s.*\|\s*(ba)?sh/,
1411
+ /wget\s.*\|\s*(ba)?sh/,
1412
+ /curl\s.*\|\s*python/,
1413
+ /wget\s.*\|\s*python/
1414
+ ];
1415
+ function containsRemoteScriptPattern(command) {
1416
+ return REMOTE_SCRIPT_PATTERNS.some((p) => p.test(command));
1417
+ }
1418
+ function sanitizeErrorOutput(output) {
1419
+ return output.replace(/\/Users\/[^\s/]+/g, "/Users/***").replace(/\/home\/[^\s/]+/g, "/home/***").replace(/(password|token|key|secret)[=:]\s*\S+/gi, "$1=***").slice(0, 500);
1420
+ }
1421
+ async function loadTemplate(templatePath) {
1422
+ const exists = await fileExists(templatePath);
1423
+ if (!exists) {
1424
+ throw new Error(`Template not found: ${templatePath}`);
1425
+ }
1426
+ const raw = await readFile4(templatePath, "utf-8");
1427
+ const data = YAML2.parse(raw);
1428
+ const result = validateTemplate(data);
1429
+ if (!result.valid) {
1430
+ throw new Error(
1431
+ `Invalid template ${templatePath}:
1432
+ ${(result.errors ?? []).join("\n")}`
1433
+ );
1434
+ }
1435
+ return data;
1436
+ }
1437
+ async function listTemplates() {
1438
+ const templatesDir = getSubDir(TEMPLATES_DIR);
1439
+ const exists = await fileExists(templatesDir);
1440
+ if (!exists) return [];
1441
+ const entries = await readdir3(templatesDir, { withFileTypes: true });
1442
+ const templates = [];
1443
+ for (const entry of entries) {
1444
+ if (!entry.isFile() || !entry.name.endsWith(".yml") && !entry.name.endsWith(".yaml")) {
1445
+ continue;
1446
+ }
1447
+ const fullPath = join10(templatesDir, entry.name);
1448
+ try {
1449
+ const config = await loadTemplate(fullPath);
1450
+ templates.push({
1451
+ name: entry.name.replace(/\.ya?ml$/, ""),
1452
+ path: fullPath,
1453
+ config
1454
+ });
1455
+ } catch {
1456
+ logger.warn(`Skipping invalid template: ${entry.name}`);
1457
+ }
1458
+ }
1459
+ return templates;
1460
+ }
1461
+ function execAsync(command) {
1462
+ return new Promise((resolve2, reject) => {
1463
+ exec(
1464
+ command,
1465
+ { shell: "/bin/bash", timeout: 3e5 },
1466
+ (error, stdout, stderr) => {
1467
+ if (error) {
1468
+ reject(
1469
+ Object.assign(error, {
1470
+ stdout: stdout?.toString() ?? "",
1471
+ stderr: stderr?.toString() ?? ""
1472
+ })
1473
+ );
1474
+ } else {
1475
+ resolve2({
1476
+ stdout: stdout?.toString() ?? "",
1477
+ stderr: stderr?.toString() ?? ""
1478
+ });
1479
+ }
1480
+ }
1481
+ );
1482
+ });
1483
+ }
1484
+ async function evaluateSkipIf(command, stepName) {
1485
+ if (containsRemoteScriptPattern(command)) {
1486
+ throw new Error(`Blocked dangerous remote script pattern in skip_if: ${stepName}`);
1487
+ }
1488
+ try {
1489
+ await execAsync(command);
1490
+ return true;
1491
+ } catch {
1492
+ return false;
1493
+ }
1494
+ }
1495
+ async function executeStep(step) {
1496
+ const startTime = Date.now();
1497
+ if (containsRemoteScriptPattern(step.command)) {
1498
+ throw new Error(`Blocked dangerous remote script pattern in command: ${step.name}`);
1499
+ }
1500
+ if (step.skip_if) {
1501
+ const shouldSkip = await evaluateSkipIf(step.skip_if, step.name);
1502
+ if (shouldSkip) {
1503
+ return {
1504
+ name: step.name,
1505
+ status: "skipped",
1506
+ duration: Date.now() - startTime
1507
+ };
1508
+ }
1509
+ }
1510
+ try {
1511
+ const { stdout, stderr } = await execAsync(step.command);
1512
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
1513
+ return {
1514
+ name: step.name,
1515
+ status: "success",
1516
+ duration: Date.now() - startTime,
1517
+ output: output || void 0
1518
+ };
1519
+ } catch (err) {
1520
+ const error = err;
1521
+ const errorOutput = [error.stdout, error.stderr, error.message].filter(Boolean).join("\n").trim();
1522
+ return {
1523
+ name: step.name,
1524
+ status: "failed",
1525
+ duration: Date.now() - startTime,
1526
+ error: sanitizeErrorOutput(errorOutput)
1527
+ };
1528
+ }
1529
+ }
1530
+ async function* runProvision(templatePath, options = {}) {
1531
+ const template = await loadTemplate(templatePath);
1532
+ for (const step of template.steps) {
1533
+ if (options.dryRun) {
1534
+ let status = "pending";
1535
+ if (step.skip_if) {
1536
+ const shouldSkip = await evaluateSkipIf(step.skip_if, step.name);
1537
+ if (shouldSkip) status = "skipped";
1538
+ }
1539
+ yield {
1540
+ name: step.name,
1541
+ status
1542
+ };
1543
+ continue;
1544
+ }
1545
+ yield {
1546
+ name: step.name,
1547
+ status: "running"
1548
+ };
1549
+ const result = await executeStep(step);
1550
+ yield result;
1551
+ if (result.status === "failed" && !step.continue_on_error) {
1552
+ logger.error(
1553
+ `Step "${step.name}" failed. Stopping provisioning.`
1554
+ );
1555
+ return;
1556
+ }
1557
+ }
1558
+ }
1559
+
1560
+ // src/utils/sudo.ts
1561
+ import { execSync } from "child_process";
1562
+ import pc2 from "picocolors";
1563
+ function isSudoCached() {
1564
+ try {
1565
+ execSync("sudo -n true", { stdio: "ignore" });
1566
+ return true;
1567
+ } catch {
1568
+ return false;
1569
+ }
1570
+ }
1571
+ function ensureSudo(templateName) {
1572
+ if (isSudoCached()) return;
1573
+ console.log(
1574
+ `
1575
+ ${pc2.yellow("\u26A0")} Template ${pc2.bold(templateName)} requires ${pc2.bold("sudo")} privileges.`
1576
+ );
1577
+ console.log(
1578
+ pc2.gray(" Some provisioning steps need elevated permissions to execute.")
1579
+ );
1580
+ console.log(pc2.gray(" You will be prompted for your password.\n"));
1581
+ try {
1582
+ execSync("sudo -v", { stdio: "inherit", timeout: 6e4 });
1583
+ } catch {
1584
+ console.error(
1585
+ `
1586
+ ${pc2.red("\u2717")} Sudo authentication failed or was cancelled. Aborting.`
1587
+ );
1588
+ process.exit(1);
1589
+ }
1590
+ }
1591
+
1592
+ // src/components/StepRunner.tsx
1593
+ import { Text as Text6, Box as Box4 } from "ink";
1594
+ import Spinner from "ink-spinner";
1595
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1596
+ var StepIcon = ({ status }) => {
1597
+ switch (status) {
1598
+ case "success":
1599
+ return /* @__PURE__ */ jsx6(Text6, { color: "green", children: "\u2713" });
1600
+ case "running":
1601
+ return /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: /* @__PURE__ */ jsx6(Spinner, { type: "dots" }) });
1602
+ case "skipped":
1603
+ return /* @__PURE__ */ jsx6(Text6, { color: "blue", children: "\u23ED" });
1604
+ case "failed":
1605
+ return /* @__PURE__ */ jsx6(Text6, { color: "red", children: "\u2717" });
1606
+ case "pending":
1607
+ default:
1608
+ return /* @__PURE__ */ jsx6(Text6, { color: "gray", children: "\u25CB" });
1609
+ }
1610
+ };
1611
+ var StepStatusText = ({ step }) => {
1612
+ switch (step.status) {
1613
+ case "success":
1614
+ return /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
1615
+ "Done",
1616
+ step.duration != null ? ` (${Math.round(step.duration / 1e3)}s)` : ""
1617
+ ] });
1618
+ case "running":
1619
+ return /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "Running..." });
1620
+ case "skipped":
1621
+ return /* @__PURE__ */ jsx6(Text6, { color: "blue", children: "Skipped (already installed)" });
1622
+ case "failed":
1623
+ return /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
1624
+ "Failed",
1625
+ step.error ? `: ${step.error}` : ""
1626
+ ] });
1627
+ case "pending":
1628
+ default:
1629
+ return null;
1630
+ }
1631
+ };
1632
+ var StepRunner = ({
1633
+ steps,
1634
+ total
1635
+ }) => {
1636
+ return /* @__PURE__ */ jsx6(Box4, { flexDirection: "column", children: steps.map((step, idx) => /* @__PURE__ */ jsxs6(Box4, { flexDirection: "column", marginBottom: idx < steps.length - 1 ? 1 : 0, children: [
1637
+ /* @__PURE__ */ jsxs6(Text6, { children: [
1638
+ " ",
1639
+ /* @__PURE__ */ jsx6(StepIcon, { status: step.status }),
1640
+ /* @__PURE__ */ jsxs6(Text6, { children: [
1641
+ " ",
1642
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, children: [
1643
+ "Step ",
1644
+ idx + 1,
1645
+ "/",
1646
+ total
1647
+ ] }),
1648
+ " ",
1649
+ step.name
1650
+ ] })
1651
+ ] }),
1652
+ step.output && step.status !== "pending" && /* @__PURE__ */ jsxs6(Text6, { color: "gray", children: [
1653
+ " ",
1654
+ step.output
1655
+ ] }),
1656
+ /* @__PURE__ */ jsxs6(Text6, { children: [
1657
+ " ",
1658
+ /* @__PURE__ */ jsx6(StepStatusText, { step })
1659
+ ] })
1660
+ ] }, idx)) });
1661
+ };
1662
+
1663
+ // src/commands/Provision.tsx
1664
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1665
+ var ProvisionView = ({
1666
+ template,
1667
+ templatePath,
1668
+ options
1669
+ }) => {
1670
+ const { exit } = useApp4();
1671
+ const [phase, setPhase] = useState5(options.dryRun ? "done" : "running");
1672
+ const [steps, setSteps] = useState5(
1673
+ template.steps.map((s) => ({
1674
+ name: s.name,
1675
+ status: "pending",
1676
+ output: s.description
1677
+ }))
1678
+ );
1679
+ const [currentStep, setCurrentStep] = useState5(0);
1680
+ const [error, setError] = useState5(null);
1681
+ useEffect4(() => {
1682
+ if (options.dryRun) {
1683
+ setTimeout(() => exit(), 100);
1684
+ return;
1685
+ }
1686
+ (async () => {
1687
+ try {
1688
+ const generator = runProvision(templatePath, options);
1689
+ let stepIdx = 0;
1690
+ for await (const result of generator) {
1691
+ setCurrentStep(stepIdx);
1692
+ setSteps((prev) => {
1693
+ const updated = [...prev];
1694
+ updated[stepIdx] = result;
1695
+ return updated;
1696
+ });
1697
+ if (result.status === "success" || result.status === "skipped" || result.status === "failed") {
1698
+ stepIdx++;
1699
+ }
1700
+ }
1701
+ setPhase("done");
1702
+ setTimeout(() => exit(), 100);
1703
+ } catch (err) {
1704
+ setError(err instanceof Error ? err.message : String(err));
1705
+ setPhase("error");
1706
+ exit();
1707
+ }
1708
+ })();
1709
+ }, []);
1710
+ if (phase === "error" || error) {
1711
+ return /* @__PURE__ */ jsx7(Box5, { flexDirection: "column", children: /* @__PURE__ */ jsxs7(Text7, { color: "red", children: [
1712
+ "\u2717 ",
1713
+ error
1714
+ ] }) });
1715
+ }
1716
+ const successCount = steps.filter((s) => s.status === "success").length;
1717
+ const skippedCount = steps.filter((s) => s.status === "skipped").length;
1718
+ const failedCount = steps.filter((s) => s.status === "failed").length;
1719
+ return /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", children: [
1720
+ /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", marginBottom: 1, children: [
1721
+ /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
1722
+ "\u25B8 ",
1723
+ template.name
1724
+ ] }),
1725
+ template.description && /* @__PURE__ */ jsxs7(Text7, { color: "gray", children: [
1726
+ " ",
1727
+ template.description
1728
+ ] })
1729
+ ] }),
1730
+ options.dryRun && phase === "done" && /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", children: [
1731
+ /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: "(dry-run) Showing execution plan only" }),
1732
+ template.sudo && /* @__PURE__ */ jsxs7(Text7, { color: "yellow", children: [
1733
+ " ",
1734
+ "\u26A0 This template requires sudo privileges (will prompt on actual run)"
1735
+ ] }),
1736
+ /* @__PURE__ */ jsx7(Box5, { flexDirection: "column", marginTop: 1, children: template.steps.map((step, idx) => /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", marginBottom: 1, children: [
1737
+ /* @__PURE__ */ jsxs7(Text7, { children: [
1738
+ " ",
1739
+ /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
1740
+ "Step ",
1741
+ idx + 1,
1742
+ "/",
1743
+ template.steps.length
1744
+ ] }),
1745
+ " ",
1746
+ step.name
1747
+ ] }),
1748
+ step.description && /* @__PURE__ */ jsxs7(Text7, { color: "gray", children: [
1749
+ " ",
1750
+ step.description
1751
+ ] }),
1752
+ step.skip_if && /* @__PURE__ */ jsxs7(Text7, { color: "blue", children: [
1753
+ " ",
1754
+ "Skip condition: ",
1755
+ step.skip_if
1756
+ ] })
1757
+ ] }, idx)) })
1758
+ ] }),
1759
+ (phase === "running" || phase === "done" && !options.dryRun) && /* @__PURE__ */ jsx7(
1760
+ StepRunner,
1761
+ {
1762
+ steps,
1763
+ currentStep,
1764
+ total: template.steps.length
1765
+ }
1766
+ ),
1767
+ phase === "done" && !options.dryRun && /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", marginTop: 1, children: [
1768
+ /* @__PURE__ */ jsxs7(Text7, { color: "gray", children: [
1769
+ " ",
1770
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
1771
+ ] }),
1772
+ /* @__PURE__ */ jsxs7(Text7, { children: [
1773
+ " ",
1774
+ "Result: ",
1775
+ /* @__PURE__ */ jsxs7(Text7, { color: "green", children: [
1776
+ successCount,
1777
+ " succeeded"
1778
+ ] }),
1779
+ " \xB7",
1780
+ " ",
1781
+ /* @__PURE__ */ jsxs7(Text7, { color: "blue", children: [
1782
+ skippedCount,
1783
+ " skipped"
1784
+ ] }),
1785
+ " \xB7",
1786
+ " ",
1787
+ /* @__PURE__ */ jsxs7(Text7, { color: "red", children: [
1788
+ failedCount,
1789
+ " failed"
1790
+ ] })
1791
+ ] }),
1792
+ template.backup && !options.skipRestore && /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", marginTop: 1, children: [
1793
+ /* @__PURE__ */ jsx7(Text7, { bold: true, children: "\u25B8 Proceeding with config file restore..." }),
1794
+ /* @__PURE__ */ jsxs7(Text7, { color: "gray", children: [
1795
+ " ",
1796
+ "Backup link: ",
1797
+ template.backup
1798
+ ] })
1799
+ ] })
1800
+ ] })
1801
+ ] });
1802
+ };
1803
+ function registerProvisionCommand(program2) {
1804
+ program2.command("provision <template>").description("Run template-based machine provisioning").option("--dry-run", "Show plan without execution", false).option("--skip-restore", "Skip automatic restore after template completion", false).action(
1805
+ async (templateName, opts) => {
1806
+ const templates = await listTemplates();
1807
+ const match = templates.find(
1808
+ (t) => t.name === templateName || t.name === `${templateName}.yml` || t.config.name === templateName
1809
+ );
1810
+ if (!match) {
1811
+ console.error(`Template not found: ${templateName}`);
1812
+ process.exit(1);
1813
+ }
1814
+ const tmpl = await loadTemplate(match.path);
1815
+ if (tmpl.sudo && !opts.dryRun) {
1816
+ ensureSudo(tmpl.name);
1817
+ }
1818
+ const { waitUntilExit } = render4(
1819
+ /* @__PURE__ */ jsx7(
1820
+ ProvisionView,
1821
+ {
1822
+ template: tmpl,
1823
+ templatePath: match.path,
1824
+ options: {
1825
+ dryRun: opts.dryRun,
1826
+ skipRestore: opts.skipRestore
1827
+ }
1828
+ }
1829
+ )
1830
+ );
1831
+ await waitUntilExit();
1832
+ }
1833
+ );
1834
+ }
1835
+
1836
+ // src/commands/List.tsx
1837
+ import { unlinkSync } from "fs";
1838
+ import { Box as Box7, Text as Text9, useApp as useApp5, useInput as useInput2 } from "ink";
1839
+ import { render as render5 } from "ink";
1840
+ import SelectInput2 from "ink-select-input";
1841
+ import { useEffect as useEffect5, useState as useState6 } from "react";
1842
+
1843
+ // src/components/Table.tsx
1844
+ import { Text as Text8, Box as Box6 } from "ink";
1845
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
1846
+ var Table = ({
1847
+ headers,
1848
+ rows,
1849
+ columnWidths
1850
+ }) => {
1851
+ const widths = columnWidths ?? headers.map((header, colIdx) => {
1852
+ const dataMax = rows.reduce(
1853
+ (max, row) => Math.max(max, (row[colIdx] ?? "").length),
1854
+ 0
1855
+ );
1856
+ return Math.max(header.length, dataMax) + 2;
1857
+ });
1858
+ const padCell = (text, width) => {
1859
+ return text.padEnd(width);
1860
+ };
1861
+ const separator = widths.map((w) => "\u2500".repeat(w)).join(" ");
1862
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
1863
+ /* @__PURE__ */ jsx8(Text8, { children: headers.map((h, i) => /* @__PURE__ */ jsxs8(Text8, { bold: true, children: [
1864
+ padCell(h, widths[i]),
1865
+ i < headers.length - 1 ? " " : ""
1866
+ ] }, i)) }),
1867
+ /* @__PURE__ */ jsx8(Text8, { color: "gray", children: separator }),
1868
+ rows.map((row, rowIdx) => /* @__PURE__ */ jsx8(Text8, { children: row.map((cell, colIdx) => /* @__PURE__ */ jsxs8(Text8, { children: [
1869
+ padCell(cell, widths[colIdx]),
1870
+ colIdx < row.length - 1 ? " " : ""
1871
+ ] }, colIdx)) }, rowIdx))
1872
+ ] });
1873
+ };
1874
+
1875
+ // src/commands/List.tsx
1876
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
1877
+ var MenuItem = ({ isSelected = false, label }) => {
1878
+ if (label === "Delete") {
1879
+ return /* @__PURE__ */ jsx9(Text9, { bold: isSelected, color: "red", children: label });
1880
+ }
1881
+ const match = label.match(/^(.+?)\s+\((\d+)\)$/);
1882
+ if (match) {
1883
+ const [, name, count] = match;
1884
+ return /* @__PURE__ */ jsxs9(Text9, { children: [
1885
+ /* @__PURE__ */ jsx9(Text9, { bold: isSelected, children: name }),
1886
+ " ",
1887
+ /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
1888
+ "(",
1889
+ count,
1890
+ ")"
1891
+ ] })
1892
+ ] });
1893
+ }
1894
+ return /* @__PURE__ */ jsx9(Text9, { bold: isSelected, children: label });
1895
+ };
1896
+ var ListView = ({ type, deleteIndex }) => {
1897
+ const { exit } = useApp5();
1898
+ const [phase, setPhase] = useState6("loading");
1899
+ const [backups, setBackups] = useState6([]);
1900
+ const [templates, setTemplates] = useState6([]);
1901
+ const [selectedTemplate, setSelectedTemplate] = useState6(
1902
+ null
1903
+ );
1904
+ const [selectedBackup, setSelectedBackup] = useState6(null);
1905
+ const [deleteTarget, setDeleteTarget] = useState6(null);
1906
+ const [error, setError] = useState6(null);
1907
+ useInput2((_input, key) => {
1908
+ if (!key.escape) return;
1909
+ switch (phase) {
1910
+ case "main-menu":
1911
+ exit();
1912
+ break;
1913
+ case "backup-list":
1914
+ case "template-list":
1915
+ setSelectedBackup(null);
1916
+ setSelectedTemplate(null);
1917
+ setPhase("main-menu");
1918
+ break;
1919
+ case "backup-detail":
1920
+ setSelectedBackup(null);
1921
+ setPhase("backup-list");
1922
+ break;
1923
+ case "template-detail":
1924
+ setSelectedTemplate(null);
1925
+ setPhase("template-list");
1926
+ break;
1927
+ }
1928
+ });
1929
+ useEffect5(() => {
1930
+ (async () => {
1931
+ try {
1932
+ const showBackups = !type || type === "backups";
1933
+ const showTemplates = !type || type === "templates";
1934
+ if (showBackups) {
1935
+ const list2 = await getBackupList();
1936
+ setBackups(list2);
1937
+ }
1938
+ if (showTemplates) {
1939
+ const tmpls = await listTemplates();
1940
+ setTemplates(tmpls);
1941
+ }
1942
+ if (deleteIndex != null && type === "backups") {
1943
+ const list2 = await getBackupList();
1944
+ const idx = deleteIndex - 1;
1945
+ if (idx < 0 || idx >= list2.length) {
1946
+ setError(`Invalid index: ${deleteIndex}`);
1947
+ setPhase("error");
1948
+ setTimeout(() => exit(), 100);
1949
+ return;
1950
+ }
1951
+ setDeleteTarget({
1952
+ name: list2[idx].filename,
1953
+ path: list2[idx].path
1954
+ });
1955
+ setPhase("deleting");
1956
+ return;
1957
+ }
1958
+ setPhase("main-menu");
1959
+ } catch (err) {
1960
+ setError(err instanceof Error ? err.message : String(err));
1961
+ setPhase("error");
1962
+ setTimeout(() => exit(), 100);
1963
+ }
1964
+ })();
1965
+ }, []);
1966
+ const goBackToMainMenu = () => {
1967
+ setSelectedBackup(null);
1968
+ setSelectedTemplate(null);
1969
+ setPhase("main-menu");
1970
+ };
1971
+ const goBackToBackupList = () => {
1972
+ setSelectedBackup(null);
1973
+ setPhase("backup-list");
1974
+ };
1975
+ const goBackToTemplateList = () => {
1976
+ setSelectedTemplate(null);
1977
+ setPhase("template-list");
1978
+ };
1979
+ const handleDeleteConfirm = (yes) => {
1980
+ if (yes && deleteTarget) {
1981
+ try {
1982
+ if (!isInsideDir(deleteTarget.path, getSubDir("backups"))) {
1983
+ throw new Error(`Refusing to delete file outside backups directory: ${deleteTarget.path}`);
1984
+ }
1985
+ unlinkSync(deleteTarget.path);
1986
+ setPhase("done");
1987
+ setTimeout(() => exit(), 100);
1988
+ } catch (err) {
1989
+ setError(err instanceof Error ? err.message : String(err));
1990
+ setPhase("error");
1991
+ setTimeout(() => exit(), 100);
1992
+ }
1993
+ } else {
1994
+ setDeleteTarget(null);
1995
+ if (selectedBackup) {
1996
+ setPhase("backup-detail");
1997
+ } else {
1998
+ goBackToMainMenu();
1999
+ }
2000
+ }
2001
+ };
2002
+ if (phase === "error" || error) {
2003
+ return /* @__PURE__ */ jsx9(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsxs9(Text9, { color: "red", children: [
2004
+ "\u2717 ",
2005
+ error
2006
+ ] }) });
2007
+ }
2008
+ if (phase === "loading") {
2009
+ return /* @__PURE__ */ jsx9(Text9, { color: "cyan", children: "Loading..." });
2010
+ }
2011
+ if (phase === "deleting" && deleteTarget) {
2012
+ return /* @__PURE__ */ jsx9(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsx9(
2013
+ Confirm,
2014
+ {
2015
+ message: `Delete ${deleteTarget.name}?`,
2016
+ onConfirm: handleDeleteConfirm,
2017
+ defaultYes: false
2018
+ }
2019
+ ) });
2020
+ }
2021
+ if (phase === "done" && deleteTarget) {
2022
+ return /* @__PURE__ */ jsxs9(Text9, { color: "green", children: [
2023
+ "\u2713 ",
2024
+ deleteTarget.name,
2025
+ " deleted"
2026
+ ] });
2027
+ }
2028
+ if (phase === "main-menu") {
2029
+ const showBackups = !type || type === "backups";
2030
+ const showTemplates = !type || type === "templates";
2031
+ const menuItems = [];
2032
+ if (showBackups) {
2033
+ menuItems.push({
2034
+ label: `Backups (${backups.length})`,
2035
+ value: "backups"
2036
+ });
2037
+ }
2038
+ if (showTemplates) {
2039
+ menuItems.push({
2040
+ label: `Templates (${templates.length})`,
2041
+ value: "templates"
2042
+ });
2043
+ }
2044
+ menuItems.push({ label: "Exit", value: "exit" });
2045
+ const handleMainMenu = (item) => {
2046
+ if (item.value === "exit") {
2047
+ exit();
2048
+ } else if (item.value === "backups") {
2049
+ setPhase("backup-list");
2050
+ } else if (item.value === "templates") {
2051
+ setPhase("template-list");
2052
+ }
2053
+ };
2054
+ return /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", children: [
2055
+ /* @__PURE__ */ jsx9(
2056
+ SelectInput2,
2057
+ {
2058
+ items: menuItems,
2059
+ onSelect: handleMainMenu,
2060
+ itemComponent: MenuItem
2061
+ }
2062
+ ),
2063
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2064
+ "Press ",
2065
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "ESC" }),
2066
+ " to exit"
2067
+ ] }) })
2068
+ ] });
2069
+ }
2070
+ if (phase === "backup-list") {
2071
+ const items = backups.map((b) => ({
2072
+ label: `${b.filename.replace(".tar.gz", "")} \u2022 ${formatBytes(b.size)} \u2022 ${formatDate(b.createdAt)}`,
2073
+ value: b.path
2074
+ }));
2075
+ const handleBackupSelect = (item) => {
2076
+ const backup = backups.find((b) => b.path === item.value);
2077
+ if (backup) {
2078
+ setSelectedBackup(backup);
2079
+ setPhase("backup-detail");
2080
+ }
2081
+ };
2082
+ return /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", children: [
2083
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "\u25B8 Backups" }),
2084
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: backups.length === 0 ? /* @__PURE__ */ jsx9(Text9, { color: "gray", children: " No backups found." }) : /* @__PURE__ */ jsx9(
2085
+ SelectInput2,
2086
+ {
2087
+ items,
2088
+ onSelect: handleBackupSelect,
2089
+ itemComponent: MenuItem
2090
+ }
2091
+ ) }),
2092
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2093
+ "Press ",
2094
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "ESC" }),
2095
+ " to go back"
2096
+ ] }) })
2097
+ ] });
2098
+ }
2099
+ if (phase === "template-list") {
2100
+ const items = templates.map((t) => ({
2101
+ label: t.config.description ? `${t.config.name} \u2014 ${t.config.description}` : t.config.name,
2102
+ value: t.path
2103
+ }));
2104
+ const handleTemplateSelect = (item) => {
2105
+ const tmpl = templates.find((t) => t.path === item.value);
2106
+ if (tmpl) {
2107
+ setSelectedTemplate(tmpl);
2108
+ setPhase("template-detail");
2109
+ }
2110
+ };
2111
+ return /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", children: [
2112
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "\u25B8 Templates" }),
2113
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: templates.length === 0 ? /* @__PURE__ */ jsx9(Text9, { color: "gray", children: " No templates found." }) : /* @__PURE__ */ jsx9(
2114
+ SelectInput2,
2115
+ {
2116
+ items,
2117
+ onSelect: handleTemplateSelect,
2118
+ itemComponent: MenuItem
2119
+ }
2120
+ ) }),
2121
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2122
+ "Press ",
2123
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "ESC" }),
2124
+ " to go back"
2125
+ ] }) })
2126
+ ] });
2127
+ }
2128
+ if (phase === "backup-detail" && selectedBackup) {
2129
+ const sections = [
2130
+ { label: "Filename", value: selectedBackup.filename },
2131
+ { label: "Date", value: formatDate(selectedBackup.createdAt) },
2132
+ { label: "Size", value: formatBytes(selectedBackup.size) },
2133
+ ...selectedBackup.hostname ? [{ label: "Hostname", value: selectedBackup.hostname }] : [],
2134
+ ...selectedBackup.fileCount != null ? [{ label: "Files", value: String(selectedBackup.fileCount) }] : []
2135
+ ];
2136
+ const actionItems = [
2137
+ { label: "Delete", value: "delete" },
2138
+ { label: "Cancel", value: "cancel" }
2139
+ ];
2140
+ const handleDetailAction = (item) => {
2141
+ if (item.value === "delete") {
2142
+ setDeleteTarget({
2143
+ name: selectedBackup.filename,
2144
+ path: selectedBackup.path
2145
+ });
2146
+ setPhase("deleting");
2147
+ } else if (item.value === "cancel") {
2148
+ goBackToBackupList();
2149
+ }
2150
+ };
2151
+ return /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", children: [
2152
+ /* @__PURE__ */ jsxs9(Text9, { bold: true, children: [
2153
+ "\u25B8 ",
2154
+ selectedBackup.filename.replace(".tar.gz", "")
2155
+ ] }),
2156
+ /* @__PURE__ */ jsx9(Box7, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: sections.map((section, idx) => {
2157
+ const labelWidth = Math.max(...sections.map((s) => s.label.length)) + 1;
2158
+ return /* @__PURE__ */ jsxs9(Box7, { children: [
2159
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: section.label.padEnd(labelWidth) }),
2160
+ /* @__PURE__ */ jsx9(Text9, { children: " " }),
2161
+ /* @__PURE__ */ jsx9(Text9, { children: section.value })
2162
+ ] }, idx);
2163
+ }) }),
2164
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx9(
2165
+ SelectInput2,
2166
+ {
2167
+ items: actionItems,
2168
+ onSelect: handleDetailAction,
2169
+ itemComponent: MenuItem
2170
+ }
2171
+ ) }),
2172
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2173
+ "Press ",
2174
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "ESC" }),
2175
+ " to go back"
2176
+ ] }) })
2177
+ ] });
2178
+ }
2179
+ if (phase === "template-detail" && selectedTemplate) {
2180
+ const t = selectedTemplate.config;
2181
+ const sections = [
2182
+ { label: "Name", value: t.name },
2183
+ ...t.description ? [{ label: "Description", value: t.description }] : [],
2184
+ ...t.backup ? [{ label: "Backup link", value: t.backup }] : [],
2185
+ { label: "Steps", value: String(t.steps.length) }
2186
+ ];
2187
+ const actionItems = [{ label: "Cancel", value: "cancel" }];
2188
+ const handleDetailAction = (item) => {
2189
+ if (item.value === "cancel") {
2190
+ goBackToTemplateList();
2191
+ }
2192
+ };
2193
+ const labelWidth = Math.max(...sections.map((s) => s.label.length)) + 1;
2194
+ return /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", children: [
2195
+ /* @__PURE__ */ jsxs9(Text9, { bold: true, children: [
2196
+ "\u25B8 ",
2197
+ selectedTemplate.name
2198
+ ] }),
2199
+ /* @__PURE__ */ jsx9(Box7, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: sections.map((section, idx) => /* @__PURE__ */ jsxs9(Box7, { children: [
2200
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: section.label.padEnd(labelWidth) }),
2201
+ /* @__PURE__ */ jsx9(Text9, { children: " " }),
2202
+ /* @__PURE__ */ jsx9(Text9, { children: section.value })
2203
+ ] }, idx)) }),
2204
+ t.steps.length > 0 && /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", marginTop: 1, children: [
2205
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: " Provisioning Steps" }),
2206
+ /* @__PURE__ */ jsx9(Box7, { marginLeft: 2, children: /* @__PURE__ */ jsx9(
2207
+ Table,
2208
+ {
2209
+ headers: ["#", "Step", "Description"],
2210
+ rows: t.steps.map((s, idx) => [
2211
+ String(idx + 1),
2212
+ s.name,
2213
+ s.description ?? "\u2014"
2214
+ ])
2215
+ }
2216
+ ) })
2217
+ ] }),
2218
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx9(
2219
+ SelectInput2,
2220
+ {
2221
+ items: actionItems,
2222
+ onSelect: handleDetailAction,
2223
+ itemComponent: MenuItem
2224
+ }
2225
+ ) }),
2226
+ /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
2227
+ "Press ",
2228
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "ESC" }),
2229
+ " to go back"
2230
+ ] }) })
2231
+ ] });
2232
+ }
2233
+ return null;
2234
+ };
2235
+ function registerListCommand(program2) {
2236
+ program2.command("list [type]").description("List backups and templates").option("--delete <n>", "Delete item #n").action(async (type, opts) => {
2237
+ const deleteIndex = opts.delete ? parseInt(opts.delete, 10) : void 0;
2238
+ const { waitUntilExit } = render5(
2239
+ /* @__PURE__ */ jsx9(ListView, { type, deleteIndex })
2240
+ );
2241
+ await waitUntilExit();
2242
+ });
2243
+ }
2244
+
2245
+ // src/commands/Status.tsx
2246
+ import { readdirSync, statSync, unlinkSync as unlinkSync2 } from "fs";
2247
+ import { join as join11 } from "path";
2248
+ import { Box as Box8, Text as Text10, useApp as useApp6, useInput as useInput3 } from "ink";
2249
+ import { render as render6 } from "ink";
2250
+ import SelectInput3 from "ink-select-input";
2251
+ import { useEffect as useEffect6, useState as useState7 } from "react";
2252
+ import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
2253
+ function getDirStats(dirPath) {
2254
+ try {
2255
+ const entries = readdirSync(dirPath);
2256
+ let totalSize = 0;
2257
+ let count = 0;
2258
+ for (const entry of entries) {
2259
+ try {
2260
+ const stat3 = statSync(join11(dirPath, entry));
2261
+ if (stat3.isFile()) {
2262
+ totalSize += stat3.size;
2263
+ count++;
2264
+ }
2265
+ } catch {
2266
+ }
2267
+ }
2268
+ return { count, totalSize };
2269
+ } catch {
2270
+ return { count: 0, totalSize: 0 };
2271
+ }
2272
+ }
2273
+ var DisplayActionItem = ({ isSelected = false, label }) => {
2274
+ return /* @__PURE__ */ jsx10(Text10, { bold: isSelected, children: label });
2275
+ };
2276
+ var CleanupActionItem = ({ isSelected = false, label }) => {
2277
+ if (label === "Cancel" || label === "Select specific backups to delete") {
2278
+ return /* @__PURE__ */ jsx10(Text10, { bold: isSelected, children: label });
2279
+ }
2280
+ const parts = label.split(/\s{2,}/);
2281
+ if (parts.length === 2) {
2282
+ return /* @__PURE__ */ jsxs10(Text10, { bold: isSelected, children: [
2283
+ parts[0],
2284
+ " ",
2285
+ /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: parts[1] })
2286
+ ] });
2287
+ }
2288
+ return /* @__PURE__ */ jsx10(Text10, { bold: isSelected, children: label });
2289
+ };
2290
+ var StatusView = ({ cleanup }) => {
2291
+ const { exit } = useApp6();
2292
+ const [phase, setPhase] = useState7("loading");
2293
+ const [status, setStatus] = useState7(null);
2294
+ const [backups, setBackups] = useState7([]);
2295
+ const [cleanupAction, setCleanupAction] = useState7(null);
2296
+ const [cleanupMessage, setCleanupMessage] = useState7("");
2297
+ const [error, setError] = useState7(null);
2298
+ const [selectedForDeletion, setSelectedForDeletion] = useState7(
2299
+ []
2300
+ );
2301
+ useInput3((_input, key) => {
2302
+ if (!key.escape) return;
2303
+ if (phase === "display") {
2304
+ exit();
2305
+ } else if (phase === "cleanup") {
2306
+ setPhase("display");
2307
+ } else if (phase === "select-delete") {
2308
+ setSelectedForDeletion([]);
2309
+ setPhase("cleanup");
2310
+ }
2311
+ });
2312
+ useEffect6(() => {
2313
+ (async () => {
2314
+ try {
2315
+ const backupStats = getDirStats(getSubDir("backups"));
2316
+ const templateStats = getDirStats(getSubDir("templates"));
2317
+ const scriptStats = getDirStats(getSubDir("scripts"));
2318
+ const logStats = getDirStats(getSubDir("logs"));
2319
+ const backupList = await getBackupList();
2320
+ setBackups(backupList);
2321
+ const lastBackup = backupList.length > 0 ? backupList[0].createdAt : void 0;
2322
+ const oldestBackup = backupList.length > 0 ? backupList[backupList.length - 1].createdAt : void 0;
2323
+ setStatus({
2324
+ backups: backupStats,
2325
+ templates: templateStats,
2326
+ scripts: scriptStats,
2327
+ logs: logStats,
2328
+ lastBackup,
2329
+ oldestBackup
2330
+ });
2331
+ setPhase(cleanup ? "cleanup" : "display");
2332
+ } catch (err) {
2333
+ setError(err instanceof Error ? err.message : String(err));
2334
+ setPhase("error");
2335
+ setTimeout(() => exit(), 100);
2336
+ }
2337
+ })();
2338
+ }, []);
2339
+ const handleDisplayAction = (item) => {
2340
+ if (item.value === "cleanup") {
2341
+ setPhase("cleanup");
2342
+ } else if (item.value === "exit") {
2343
+ exit();
2344
+ }
2345
+ };
2346
+ const handleCleanupSelect = (item) => {
2347
+ const action = item.value;
2348
+ if (action === "cancel") {
2349
+ setPhase("display");
2350
+ return;
2351
+ }
2352
+ if (action === "select-specific") {
2353
+ setSelectedForDeletion([]);
2354
+ setPhase("select-delete");
2355
+ return;
2356
+ }
2357
+ setCleanupAction(action);
2358
+ if (action === "keep-recent-5") {
2359
+ const toDelete = backups.slice(5);
2360
+ setCleanupMessage(
2361
+ `Keep only the 5 most recent backups. ${toDelete.length} to delete, ${formatBytes(toDelete.reduce((s, b) => s + b.size, 0))} freed`
2362
+ );
2363
+ } else if (action === "older-than-30") {
2364
+ const cutoff = /* @__PURE__ */ new Date();
2365
+ cutoff.setDate(cutoff.getDate() - 30);
2366
+ const toDelete = backups.filter((b) => b.createdAt < cutoff);
2367
+ setCleanupMessage(
2368
+ `Remove backups older than 30 days. ${toDelete.length} to delete, ${formatBytes(toDelete.reduce((s, b) => s + b.size, 0))} freed`
2369
+ );
2370
+ } else if (action === "delete-logs") {
2371
+ setCleanupMessage(
2372
+ `Delete all logs. ${formatBytes(status?.logs.totalSize ?? 0)} freed`
2373
+ );
2374
+ }
2375
+ setPhase("confirming");
2376
+ };
2377
+ const handleConfirm = (yes) => {
2378
+ if (!yes) {
2379
+ setPhase("cleanup");
2380
+ return;
2381
+ }
2382
+ try {
2383
+ const backupsDir = getSubDir("backups");
2384
+ if (cleanupAction === "keep-recent-5") {
2385
+ const toDelete = backups.slice(5);
2386
+ for (const b of toDelete) {
2387
+ if (!isInsideDir(b.path, backupsDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
2388
+ unlinkSync2(b.path);
2389
+ }
2390
+ } else if (cleanupAction === "older-than-30") {
2391
+ const cutoff = /* @__PURE__ */ new Date();
2392
+ cutoff.setDate(cutoff.getDate() - 30);
2393
+ const toDelete = backups.filter((b) => b.createdAt < cutoff);
2394
+ for (const b of toDelete) {
2395
+ if (!isInsideDir(b.path, backupsDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
2396
+ unlinkSync2(b.path);
2397
+ }
2398
+ } else if (cleanupAction === "delete-logs") {
2399
+ const logsDir = getSubDir(LOGS_DIR);
2400
+ try {
2401
+ const entries = readdirSync(logsDir);
2402
+ for (const entry of entries) {
2403
+ const logPath = join11(logsDir, entry);
2404
+ if (!isInsideDir(logPath, logsDir)) throw new Error(`Refusing to delete file outside logs directory: ${logPath}`);
2405
+ unlinkSync2(logPath);
2406
+ }
2407
+ } catch {
2408
+ }
2409
+ } else if (cleanupAction === "select-specific") {
2410
+ for (const b of selectedForDeletion) {
2411
+ if (!isInsideDir(b.path, backupsDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
2412
+ unlinkSync2(b.path);
2413
+ }
2414
+ }
2415
+ setPhase("done");
2416
+ setTimeout(() => exit(), 100);
2417
+ } catch (err) {
2418
+ setError(err instanceof Error ? err.message : String(err));
2419
+ setPhase("error");
2420
+ setTimeout(() => exit(), 100);
2421
+ }
2422
+ };
2423
+ const handleSelectBackup = (item) => {
2424
+ if (item.value === "done") {
2425
+ if (selectedForDeletion.length === 0) {
2426
+ setPhase("cleanup");
2427
+ return;
2428
+ }
2429
+ setCleanupAction("select-specific");
2430
+ setCleanupMessage(
2431
+ `Delete ${selectedForDeletion.length} selected backup(s). ${formatBytes(selectedForDeletion.reduce((s, b) => s + b.size, 0))} freed`
2432
+ );
2433
+ setPhase("confirming");
2434
+ return;
2435
+ }
2436
+ const backup = backups.find((b) => b.path === item.value);
2437
+ if (backup) {
2438
+ setSelectedForDeletion((prev) => [...prev, backup]);
2439
+ }
2440
+ };
2441
+ if (phase === "error" || error) {
2442
+ return /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", children: /* @__PURE__ */ jsxs10(Text10, { color: "red", children: [
2443
+ "\u2717 ",
2444
+ error
2445
+ ] }) });
2446
+ }
2447
+ if (phase === "loading") {
2448
+ return /* @__PURE__ */ jsx10(Text10, { color: "cyan", children: "Loading..." });
2449
+ }
2450
+ if (!status) return null;
2451
+ const totalCount = status.backups.count + status.templates.count + status.scripts.count + status.logs.count;
2452
+ const totalSize = status.backups.totalSize + status.templates.totalSize + status.scripts.totalSize + status.logs.totalSize;
2453
+ const statusDisplay = /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2454
+ /* @__PURE__ */ jsxs10(Text10, { bold: true, children: [
2455
+ "\u25B8 ",
2456
+ APP_NAME,
2457
+ " status \u2014 ~/.",
2458
+ APP_NAME,
2459
+ "/"
2460
+ ] }),
2461
+ /* @__PURE__ */ jsx10(Box8, { marginLeft: 2, marginTop: 1, children: /* @__PURE__ */ jsx10(
2462
+ Table,
2463
+ {
2464
+ headers: ["Directory", "Count", "Size"],
2465
+ rows: [
2466
+ [
2467
+ "backups/",
2468
+ `${status.backups.count}`,
2469
+ formatBytes(status.backups.totalSize)
2470
+ ],
2471
+ [
2472
+ "templates/",
2473
+ `${status.templates.count}`,
2474
+ formatBytes(status.templates.totalSize)
2475
+ ],
2476
+ [
2477
+ "scripts/",
2478
+ `${status.scripts.count}`,
2479
+ formatBytes(status.scripts.totalSize)
2480
+ ],
2481
+ [
2482
+ "logs/",
2483
+ `${status.logs.count}`,
2484
+ formatBytes(status.logs.totalSize)
2485
+ ],
2486
+ ["Total", `${totalCount}`, formatBytes(totalSize)]
2487
+ ]
2488
+ }
2489
+ ) }),
2490
+ status.lastBackup && /* @__PURE__ */ jsxs10(Box8, { marginTop: 1, marginLeft: 2, flexDirection: "column", children: [
2491
+ /* @__PURE__ */ jsxs10(Text10, { children: [
2492
+ "Latest backup: ",
2493
+ formatDate(status.lastBackup),
2494
+ " (",
2495
+ formatRelativeTime(status.lastBackup),
2496
+ ")"
2497
+ ] }),
2498
+ status.oldestBackup && /* @__PURE__ */ jsxs10(Text10, { children: [
2499
+ "Oldest backup: ",
2500
+ formatDate(status.oldestBackup),
2501
+ " (",
2502
+ formatRelativeTime(status.oldestBackup),
2503
+ ")"
2504
+ ] })
2505
+ ] })
2506
+ ] });
2507
+ const escHint = (action) => /* @__PURE__ */ jsx10(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2508
+ "Press ",
2509
+ /* @__PURE__ */ jsx10(Text10, { bold: true, children: "ESC" }),
2510
+ " to ",
2511
+ action
2512
+ ] }) });
2513
+ if (phase === "display") {
2514
+ return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2515
+ statusDisplay,
2516
+ /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginTop: 1, children: [
2517
+ /* @__PURE__ */ jsx10(Text10, { bold: true, children: "\u25B8 Actions" }),
2518
+ /* @__PURE__ */ jsx10(
2519
+ SelectInput3,
2520
+ {
2521
+ items: [
2522
+ { label: "Cleanup", value: "cleanup" },
2523
+ { label: "Exit", value: "exit" }
2524
+ ],
2525
+ onSelect: handleDisplayAction,
2526
+ itemComponent: DisplayActionItem
2527
+ }
2528
+ )
2529
+ ] }),
2530
+ escHint("exit")
2531
+ ] });
2532
+ }
2533
+ if (phase === "cleanup") {
2534
+ const keepRecent5 = backups.slice(5);
2535
+ const cutoff30 = /* @__PURE__ */ new Date();
2536
+ cutoff30.setDate(cutoff30.getDate() - 30);
2537
+ const olderThan30 = backups.filter((b) => b.createdAt < cutoff30);
2538
+ const labels = [
2539
+ "Keep only 5 recent backups",
2540
+ "Remove backups older than 30d",
2541
+ "Delete all logs"
2542
+ ];
2543
+ const maxWidth = Math.max(...labels.map((l) => l.length));
2544
+ const cleanupItems = [
2545
+ {
2546
+ label: `${"Keep only 5 recent backups".padEnd(maxWidth)} ${keepRecent5.length} to delete, ${formatBytes(keepRecent5.reduce((s, b) => s + b.size, 0))} freed`,
2547
+ value: "keep-recent-5"
2548
+ },
2549
+ {
2550
+ label: `${"Remove backups older than 30d".padEnd(maxWidth)} ${olderThan30.length} to delete, ${formatBytes(olderThan30.reduce((s, b) => s + b.size, 0))} freed`,
2551
+ value: "older-than-30"
2552
+ },
2553
+ {
2554
+ label: "Select specific backups to delete",
2555
+ value: "select-specific"
2556
+ },
2557
+ {
2558
+ label: `${"Delete all logs".padEnd(maxWidth)} ${formatBytes(status.logs.totalSize)} freed`,
2559
+ value: "delete-logs"
2560
+ },
2561
+ {
2562
+ label: "Cancel",
2563
+ value: "cancel"
2564
+ }
2565
+ ];
2566
+ return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2567
+ statusDisplay,
2568
+ /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginTop: 1, children: [
2569
+ /* @__PURE__ */ jsx10(Text10, { bold: true, children: "\u25B8 Cleanup options" }),
2570
+ /* @__PURE__ */ jsx10(
2571
+ SelectInput3,
2572
+ {
2573
+ items: cleanupItems,
2574
+ onSelect: handleCleanupSelect,
2575
+ itemComponent: CleanupActionItem
2576
+ }
2577
+ )
2578
+ ] }),
2579
+ escHint("go back")
2580
+ ] });
2581
+ }
2582
+ if (phase === "select-delete") {
2583
+ const remaining = backups.filter(
2584
+ (b) => !selectedForDeletion.some((s) => s.path === b.path)
2585
+ );
2586
+ const selectItems = [
2587
+ ...remaining.map((b) => ({
2588
+ label: `${b.filename.replace(".tar.gz", "")} ${formatBytes(b.size)} ${formatDate(b.createdAt)}`,
2589
+ value: b.path
2590
+ })),
2591
+ {
2592
+ label: `Done (${selectedForDeletion.length} selected)`,
2593
+ value: "done"
2594
+ }
2595
+ ];
2596
+ return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2597
+ statusDisplay,
2598
+ /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginTop: 1, children: [
2599
+ /* @__PURE__ */ jsx10(Text10, { bold: true, children: "\u25B8 Select backups to delete" }),
2600
+ selectedForDeletion.length > 0 && /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
2601
+ " ",
2602
+ selectedForDeletion.length,
2603
+ " backup(s) selected (",
2604
+ formatBytes(selectedForDeletion.reduce((s, b) => s + b.size, 0)),
2605
+ ")"
2606
+ ] }),
2607
+ /* @__PURE__ */ jsx10(SelectInput3, { items: selectItems, onSelect: handleSelectBackup })
2608
+ ] }),
2609
+ escHint("go back")
2610
+ ] });
2611
+ }
2612
+ if (phase === "confirming") {
2613
+ return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2614
+ /* @__PURE__ */ jsx10(Text10, { children: cleanupMessage }),
2615
+ /* @__PURE__ */ jsx10(
2616
+ Confirm,
2617
+ {
2618
+ message: "Proceed?",
2619
+ onConfirm: handleConfirm,
2620
+ defaultYes: false
2621
+ }
2622
+ )
2623
+ ] });
2624
+ }
2625
+ if (phase === "done") {
2626
+ return /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", children: /* @__PURE__ */ jsx10(Text10, { color: "green", children: "\u2713 Cleanup complete" }) });
2627
+ }
2628
+ return null;
2629
+ };
2630
+ function registerStatusCommand(program2) {
2631
+ program2.command("status").description(`Show ~/.${APP_NAME}/ status summary`).option("--cleanup", "Interactive cleanup mode", false).action(async (opts) => {
2632
+ const { waitUntilExit } = render6(/* @__PURE__ */ jsx10(StatusView, { cleanup: opts.cleanup }));
2633
+ await waitUntilExit();
2634
+ });
2635
+ }
2636
+
2637
+ // src/cli.ts
2638
+ var program = new Command();
2639
+ program.name("syncpoint").description(
2640
+ "Personal Environment Manager \u2014 Config backup/restore and machine provisioning CLI"
2641
+ ).version(APP_VERSION);
2642
+ registerInitCommand(program);
2643
+ registerBackupCommand(program);
2644
+ registerRestoreCommand(program);
2645
+ registerProvisionCommand(program);
2646
+ registerListCommand(program);
2647
+ registerStatusCommand(program);
2648
+ program.parseAsync(process.argv).catch((error) => {
2649
+ console.error("Fatal error:", error.message);
2650
+ process.exit(1);
2651
+ });