@lumy-pack/syncpoint 0.0.1 → 0.0.2

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 CHANGED
@@ -3,11 +3,36 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
 
6
- // src/commands/Init.tsx
7
- import { useState, useEffect } from "react";
8
- import { Text, Box, useApp } from "ink";
6
+ // src/commands/Backup.tsx
7
+ import { Box, Text as Text2, useApp } from "ink";
9
8
  import { render } from "ink";
10
- import { join as join5 } from "path";
9
+ import { useEffect, useState } from "react";
10
+
11
+ // src/components/ProgressBar.tsx
12
+ import { Text } from "ink";
13
+ import { jsx, jsxs } from "react/jsx-runtime";
14
+ var ProgressBar = ({
15
+ percent,
16
+ width = 30
17
+ }) => {
18
+ const clamped = Math.max(0, Math.min(100, percent));
19
+ const filled = Math.round(width * (clamped / 100));
20
+ const empty = width - filled;
21
+ return /* @__PURE__ */ jsxs(Text, { children: [
22
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "\u2588".repeat(filled) }),
23
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "\u2591".repeat(empty) }),
24
+ /* @__PURE__ */ jsxs(Text, { children: [
25
+ " ",
26
+ clamped,
27
+ "%"
28
+ ] })
29
+ ] });
30
+ };
31
+
32
+ // src/core/backup.ts
33
+ import { readdir } from "fs/promises";
34
+ import { basename, join as join5 } from "path";
35
+ import fg from "fast-glob";
11
36
 
12
37
  // src/constants.ts
13
38
  import { join as join2 } from "path";
@@ -55,6 +80,14 @@ async function fileExists(filePath) {
55
80
  return false;
56
81
  }
57
82
  }
83
+ async function isDirectory(filePath) {
84
+ try {
85
+ const stats = await stat(filePath);
86
+ return stats.isDirectory();
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
58
91
  function isInsideDir(filePath, dir) {
59
92
  const resolvedFile = resolve(filePath);
60
93
  const resolvedDir = resolve(dir);
@@ -75,268 +108,12 @@ var LOGS_DIR = "logs";
75
108
  function getAppDir() {
76
109
  return join2(getHomeDir(), APP_DIR);
77
110
  }
78
- var APP_VERSION = "0.0.1";
79
111
  function getSubDir(sub) {
80
112
  return join2(getAppDir(), sub);
81
113
  }
82
114
 
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
115
  // src/utils/system.ts
339
- import { hostname as osHostname, platform, release, arch } from "os";
116
+ import { arch, hostname as osHostname, platform, release } from "os";
340
117
  function getHostname() {
341
118
  return osHostname();
342
119
  }
@@ -349,7 +126,7 @@ function getSystemInfo() {
349
126
  }
350
127
  function formatHostname(name) {
351
128
  const raw = name ?? getHostname();
352
- return raw.replace(/\s+/g, "-").replace(/\./g, "-").replace(/[^a-zA-Z0-9\-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
129
+ return raw.replace(/\s+/g, "-").replace(/\./g, "-").replace(/[^a-zA-Z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
353
130
  }
354
131
 
355
132
  // src/utils/format.ts
@@ -414,7 +191,7 @@ function generateFilename(pattern, options) {
414
191
 
415
192
  // src/utils/logger.ts
416
193
  import { appendFile, mkdir as mkdir2 } from "fs/promises";
417
- import { join as join6 } from "path";
194
+ import { join as join3 } from "path";
418
195
  import pc from "picocolors";
419
196
  var ANSI_RE = /\x1b\[[0-9;]*m/g;
420
197
  function stripAnsi(str) {
@@ -437,12 +214,12 @@ function dateStamp() {
437
214
  var logDirCreated = false;
438
215
  async function writeToFile(level, message) {
439
216
  try {
440
- const logsDir = join6(getAppDir(), LOGS_DIR);
217
+ const logsDir = join3(getAppDir(), LOGS_DIR);
441
218
  if (!logDirCreated) {
442
219
  await mkdir2(logsDir, { recursive: true });
443
220
  logDirCreated = true;
444
221
  }
445
- const logFile = join6(logsDir, `${dateStamp()}.log`);
222
+ const logFile = join3(logsDir, `${dateStamp()}.log`);
446
223
  const line = `[${timestamp()}] [${level.toUpperCase()}] ${stripAnsi(message)}
447
224
  `;
448
225
  await appendFile(logFile, line, "utf-8");
@@ -468,12 +245,114 @@ var logger = {
468
245
  }
469
246
  };
470
247
 
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 = {
248
+ // src/utils/pattern.ts
249
+ import micromatch from "micromatch";
250
+ function detectPatternType(pattern) {
251
+ if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) {
252
+ const inner = pattern.slice(1, -1);
253
+ const hasUnescapedSlash = /(?<!\\)\//.test(inner);
254
+ if (!hasUnescapedSlash) {
255
+ return "regex";
256
+ }
257
+ }
258
+ if (pattern.includes("*") || pattern.includes("?") || pattern.includes("{")) {
259
+ return "glob";
260
+ }
261
+ return "literal";
262
+ }
263
+ function parseRegexPattern(pattern) {
264
+ if (!pattern.startsWith("/") || !pattern.endsWith("/")) {
265
+ throw new Error(
266
+ `Invalid regex pattern format: ${pattern}. Must be enclosed in slashes like /pattern/`
267
+ );
268
+ }
269
+ const regexBody = pattern.slice(1, -1);
270
+ try {
271
+ return new RegExp(regexBody);
272
+ } catch (error) {
273
+ throw new Error(
274
+ `Invalid regex pattern: ${pattern}. ${error instanceof Error ? error.message : String(error)}`
275
+ );
276
+ }
277
+ }
278
+ function createExcludeMatcher(excludePatterns) {
279
+ if (excludePatterns.length === 0) {
280
+ return () => false;
281
+ }
282
+ const regexPatterns = [];
283
+ const globPatterns = [];
284
+ const literalPatterns = /* @__PURE__ */ new Set();
285
+ for (const pattern of excludePatterns) {
286
+ const type = detectPatternType(pattern);
287
+ if (type === "regex") {
288
+ try {
289
+ regexPatterns.push(parseRegexPattern(pattern));
290
+ } catch {
291
+ console.warn(`Skipping invalid regex pattern: ${pattern}`);
292
+ }
293
+ } else if (type === "glob") {
294
+ globPatterns.push(pattern);
295
+ } else {
296
+ literalPatterns.add(pattern);
297
+ }
298
+ }
299
+ return (filePath) => {
300
+ if (literalPatterns.has(filePath)) {
301
+ return true;
302
+ }
303
+ if (globPatterns.length > 0 && micromatch.isMatch(filePath, globPatterns, {
304
+ dot: true,
305
+ // Match dotfiles
306
+ matchBase: false
307
+ // Don't use basename matching, match full path
308
+ })) {
309
+ return true;
310
+ }
311
+ for (const regex of regexPatterns) {
312
+ if (regex.test(filePath)) {
313
+ return true;
314
+ }
315
+ }
316
+ return false;
317
+ };
318
+ }
319
+ function isValidPattern(pattern) {
320
+ if (typeof pattern !== "string" || pattern.length === 0) {
321
+ return false;
322
+ }
323
+ const type = detectPatternType(pattern);
324
+ if (type === "regex") {
325
+ try {
326
+ parseRegexPattern(pattern);
327
+ return true;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+ return true;
333
+ }
334
+
335
+ // src/core/metadata.ts
336
+ import { createHash } from "crypto";
337
+ import { lstat, readFile } from "fs/promises";
338
+
339
+ // src/schemas/ajv.ts
340
+ import Ajv from "ajv";
341
+ import addFormats from "ajv-formats";
342
+ var ajv = new Ajv({ allErrors: true });
343
+ addFormats(ajv);
344
+ ajv.addKeyword({
345
+ keyword: "validPattern",
346
+ type: "string",
347
+ validate: function validate(schema, data) {
348
+ if (!schema) return true;
349
+ return isValidPattern(data);
350
+ },
351
+ errors: true
352
+ });
353
+
354
+ // src/schemas/metadata.schema.ts
355
+ var metadataSchema = {
477
356
  type: "object",
478
357
  required: [
479
358
  "version",
@@ -546,13 +425,16 @@ function validateMetadata(data) {
546
425
  return { valid: false, errors };
547
426
  }
548
427
 
428
+ // src/version.ts
429
+ var VERSION = "0.0.2";
430
+
549
431
  // src/core/metadata.ts
550
432
  var METADATA_VERSION = "1.0.0";
551
433
  function createMetadata(files, config) {
552
434
  const totalSize = files.reduce((sum, f) => sum + f.size, 0);
553
435
  return {
554
436
  version: METADATA_VERSION,
555
- toolVersion: APP_VERSION,
437
+ toolVersion: VERSION,
556
438
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
557
439
  hostname: getHostname(),
558
440
  system: getSystemInfo(),
@@ -572,15 +454,13 @@ function parseMetadata(data) {
572
454
  const parsed = JSON.parse(str);
573
455
  const result = validateMetadata(parsed);
574
456
  if (!result.valid) {
575
- throw new Error(
576
- `Invalid metadata:
577
- ${(result.errors ?? []).join("\n")}`
578
- );
457
+ throw new Error(`Invalid metadata:
458
+ ${(result.errors ?? []).join("\n")}`);
579
459
  }
580
460
  return parsed;
581
461
  }
582
462
  async function computeFileHash(filePath) {
583
- const content = await readFile2(filePath);
463
+ const content = await readFile(filePath);
584
464
  const hash = createHash("sha256").update(content).digest("hex");
585
465
  return `sha256:${hash}`;
586
466
  }
@@ -591,6 +471,9 @@ async function collectFileInfo(absolutePath, logicalPath) {
591
471
  type = "symlink";
592
472
  } else if (lstats.isDirectory()) {
593
473
  type = "directory";
474
+ throw new Error(
475
+ `Cannot collect file info for directory: ${logicalPath}. Directories should be converted to glob patterns before calling collectFileInfo().`
476
+ );
594
477
  }
595
478
  let hash;
596
479
  if (lstats.isSymbolicLink()) {
@@ -608,25 +491,28 @@ async function collectFileInfo(absolutePath, logicalPath) {
608
491
  }
609
492
 
610
493
  // src/core/storage.ts
611
- import { mkdir as mkdir3, mkdtemp, readFile as readFile3, rm, writeFile as writeFile2 } from "fs/promises";
494
+ import { mkdir as mkdir3, mkdtemp, readFile as readFile2, rm, writeFile } from "fs/promises";
612
495
  import { tmpdir } from "os";
613
- import { join as join7, normalize as normalize2 } from "path";
496
+ import { join as join4, normalize as normalize2 } from "path";
614
497
  import * as tar from "tar";
615
498
  async function createArchive(files, outputPath) {
616
- const tmpDir = await mkdtemp(join7(tmpdir(), "syncpoint-"));
499
+ const tmpDir = await mkdtemp(join4(tmpdir(), "syncpoint-"));
617
500
  try {
618
501
  const fileNames = [];
619
502
  for (const file of files) {
620
- const targetPath = join7(tmpDir, file.name);
621
- const parentDir = join7(tmpDir, file.name.split("/").slice(0, -1).join("/"));
503
+ const targetPath = join4(tmpDir, file.name);
504
+ const parentDir = join4(
505
+ tmpDir,
506
+ file.name.split("/").slice(0, -1).join("/")
507
+ );
622
508
  if (parentDir !== tmpDir) {
623
509
  await mkdir3(parentDir, { recursive: true });
624
510
  }
625
511
  if (file.content !== void 0) {
626
- await writeFile2(targetPath, file.content);
512
+ await writeFile(targetPath, file.content);
627
513
  } else if (file.sourcePath) {
628
- const data = await readFile3(file.sourcePath);
629
- await writeFile2(targetPath, data);
514
+ const data = await readFile2(file.sourcePath);
515
+ await writeFile(targetPath, data);
630
516
  }
631
517
  fileNames.push(file.name);
632
518
  }
@@ -661,7 +547,7 @@ async function readFileFromArchive(archivePath, filename) {
661
547
  if (filename.includes("..") || filename.startsWith("/")) {
662
548
  throw new Error(`Invalid filename: ${filename}`);
663
549
  }
664
- const tmpDir = await mkdtemp(join7(tmpdir(), "syncpoint-read-"));
550
+ const tmpDir = await mkdtemp(join4(tmpdir(), "syncpoint-read-"));
665
551
  try {
666
552
  await tar.extract({
667
553
  file: archivePath,
@@ -671,9 +557,9 @@ async function readFileFromArchive(archivePath, filename) {
671
557
  return normalized === filename;
672
558
  }
673
559
  });
674
- const extractedPath = join7(tmpDir, filename);
560
+ const extractedPath = join4(tmpDir, filename);
675
561
  try {
676
- return await readFile3(extractedPath);
562
+ return await readFile2(extractedPath);
677
563
  } catch {
678
564
  return null;
679
565
  }
@@ -695,18 +581,48 @@ function isSensitiveFile(filePath) {
695
581
  async function scanTargets(config) {
696
582
  const found = [];
697
583
  const missing = [];
584
+ const isExcluded = createExcludeMatcher(config.backup.exclude);
698
585
  for (const target of config.backup.targets) {
699
586
  const expanded = expandTilde(target);
700
- if (expanded.includes("*") || expanded.includes("?") || expanded.includes("{")) {
587
+ const patternType = detectPatternType(expanded);
588
+ if (patternType === "regex") {
589
+ try {
590
+ const regex = parseRegexPattern(expanded);
591
+ const homeDir = expandTilde("~/");
592
+ const homeDirNormalized = homeDir.endsWith("/") ? homeDir : `${homeDir}/`;
593
+ const allFiles = await fg(`${homeDirNormalized}**`, {
594
+ dot: true,
595
+ absolute: true,
596
+ onlyFiles: true,
597
+ deep: 5
598
+ // Limit depth for performance
599
+ });
600
+ for (const match of allFiles) {
601
+ if (regex.test(match) && !isExcluded(match)) {
602
+ const entry = await collectFileInfo(match, match);
603
+ found.push(entry);
604
+ }
605
+ }
606
+ } catch (error) {
607
+ logger.warn(
608
+ `Invalid regex pattern "${target}": ${error instanceof Error ? error.message : String(error)}`
609
+ );
610
+ }
611
+ } else if (patternType === "glob") {
612
+ const globExcludes = config.backup.exclude.filter(
613
+ (p) => detectPatternType(p) === "glob"
614
+ );
701
615
  const matches = await fg(expanded, {
702
616
  dot: true,
703
617
  absolute: true,
704
- ignore: config.backup.exclude,
618
+ ignore: globExcludes,
705
619
  onlyFiles: true
706
620
  });
707
621
  for (const match of matches) {
708
- const entry = await collectFileInfo(match, match);
709
- found.push(entry);
622
+ if (!isExcluded(match)) {
623
+ const entry = await collectFileInfo(match, match);
624
+ found.push(entry);
625
+ }
710
626
  }
711
627
  } else {
712
628
  const absPath = resolveTargetPath(target);
@@ -715,16 +631,43 @@ async function scanTargets(config) {
715
631
  missing.push(target);
716
632
  continue;
717
633
  }
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}`
634
+ const isDir = await isDirectory(absPath);
635
+ if (isDir) {
636
+ const dirGlob = `${expanded}/**/*`;
637
+ const globExcludes = config.backup.exclude.filter(
638
+ (p) => detectPatternType(p) === "glob"
722
639
  );
640
+ const matches = await fg(dirGlob, {
641
+ dot: true,
642
+ absolute: true,
643
+ ignore: globExcludes,
644
+ onlyFiles: true
645
+ // Only include files, not subdirectories
646
+ });
647
+ for (const match of matches) {
648
+ if (!isExcluded(match)) {
649
+ const entry = await collectFileInfo(match, match);
650
+ found.push(entry);
651
+ }
652
+ }
653
+ if (matches.length === 0) {
654
+ logger.warn(`Directory is empty or fully excluded: ${target}`);
655
+ }
656
+ } else {
657
+ if (isExcluded(absPath)) {
658
+ continue;
659
+ }
660
+ const entry = await collectFileInfo(absPath, absPath);
661
+ if (entry.size > LARGE_FILE_THRESHOLD) {
662
+ logger.warn(
663
+ `Large file (>${Math.round(LARGE_FILE_THRESHOLD / 1024 / 1024)}MB): ${target}`
664
+ );
665
+ }
666
+ if (isSensitiveFile(absPath)) {
667
+ logger.warn(`Sensitive file detected: ${target}`);
668
+ }
669
+ found.push(entry);
723
670
  }
724
- if (isSensitiveFile(absPath)) {
725
- logger.warn(`Sensitive file detected: ${target}`);
726
- }
727
- found.push(entry);
728
671
  }
729
672
  }
730
673
  return { found, missing };
@@ -738,7 +681,7 @@ async function collectScripts() {
738
681
  const files = await readdir(scriptsDir, { withFileTypes: true });
739
682
  for (const file of files) {
740
683
  if (file.isFile() && file.name.endsWith(".sh")) {
741
- const absPath = join8(scriptsDir, file.name);
684
+ const absPath = join5(scriptsDir, file.name);
742
685
  const entry = await collectFileInfo(absPath, absPath);
743
686
  entries.push(entry);
744
687
  }
@@ -750,8 +693,10 @@ async function collectScripts() {
750
693
  }
751
694
  async function createBackup(config, options = {}) {
752
695
  const { found, missing } = await scanTargets(config);
753
- for (const m of missing) {
754
- logger.warn(`File not found, skipping: ${m}`);
696
+ if (options.verbose && missing.length > 0) {
697
+ for (const m of missing) {
698
+ logger.warn(`File not found, skipping: ${m}`);
699
+ }
755
700
  }
756
701
  let allFiles = [...found];
757
702
  if (config.scripts.includeInBackup) {
@@ -768,7 +713,7 @@ async function createBackup(config, options = {}) {
768
713
  const archiveFilename = `${filename}.tar.gz`;
769
714
  const destDir = config.backup.destination ? resolveTargetPath(config.backup.destination) : getSubDir(BACKUPS_DIR);
770
715
  await ensureDir(destDir);
771
- const archivePath = join8(destDir, archiveFilename);
716
+ const archivePath = join5(destDir, archiveFilename);
772
717
  if (options.dryRun) {
773
718
  return { archivePath, metadata };
774
719
  }
@@ -788,386 +733,348 @@ async function createBackup(config, options = {}) {
788
733
  return { archivePath, metadata };
789
734
  }
790
735
 
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
- };
736
+ // src/core/config.ts
737
+ import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
738
+ import { join as join7 } from "path";
739
+ import YAML from "yaml";
811
740
 
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;
741
+ // src/schemas/config.schema.ts
742
+ var configSchema = {
743
+ type: "object",
744
+ required: ["backup"],
745
+ properties: {
746
+ backup: {
747
+ type: "object",
748
+ required: ["targets", "exclude", "filename"],
749
+ properties: {
750
+ targets: {
751
+ type: "array",
752
+ items: { type: "string", validPattern: true }
753
+ },
754
+ exclude: {
755
+ type: "array",
756
+ items: { type: "string", validPattern: true }
757
+ },
758
+ filename: {
759
+ type: "string",
760
+ minLength: 1
761
+ },
762
+ destination: {
763
+ type: "string"
835
764
  }
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
- ] });
765
+ },
766
+ additionalProperties: false
767
+ },
768
+ scripts: {
769
+ type: "object",
770
+ properties: {
771
+ includeInBackup: {
772
+ type: "boolean"
773
+ }
774
+ },
775
+ additionalProperties: false
776
+ }
777
+ },
778
+ additionalProperties: false
923
779
  };
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
- });
780
+ var validate3 = ajv.compile(configSchema);
781
+ function validateConfig(data) {
782
+ const valid = validate3(data);
783
+ if (valid) return { valid: true };
784
+ const errors = validate3.errors?.map(
785
+ (e) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`
786
+ );
787
+ return { valid: false, errors };
931
788
  }
932
789
 
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";
790
+ // src/utils/assets.ts
791
+ import { existsSync, readFileSync } from "fs";
792
+ import { dirname, join as join6 } from "path";
793
+ import { fileURLToPath } from "url";
794
+ function getPackageRoot() {
795
+ let dir = dirname(fileURLToPath(import.meta.url));
796
+ while (dir !== dirname(dir)) {
797
+ if (existsSync(join6(dir, "package.json"))) return dir;
798
+ dir = dirname(dir);
799
+ }
800
+ throw new Error("Could not find package root");
801
+ }
802
+ function getAssetPath(filename) {
803
+ return join6(getPackageRoot(), "assets", filename);
804
+ }
805
+ function readAsset(filename) {
806
+ return readFileSync(getAssetPath(filename), "utf-8");
807
+ }
938
808
 
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
- });
809
+ // src/core/config.ts
810
+ function stripDangerousKeys(obj) {
811
+ if (obj === null || typeof obj !== "object") return obj;
812
+ if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
813
+ const cleaned = {};
814
+ for (const [key, value] of Object.entries(obj)) {
815
+ if (["__proto__", "constructor", "prototype"].includes(key)) continue;
816
+ cleaned[key] = stripDangerousKeys(value);
972
817
  }
973
- backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
974
- return backups;
818
+ return cleaned;
975
819
  }
976
- async function getRestorePlan(archivePath) {
977
- const metaBuf = await readFileFromArchive(archivePath, METADATA_FILENAME);
978
- if (!metaBuf) {
820
+ function getConfigPath() {
821
+ return join7(getAppDir(), CONFIG_FILENAME);
822
+ }
823
+ async function loadConfig() {
824
+ const configPath = getConfigPath();
825
+ const exists = await fileExists(configPath);
826
+ if (!exists) {
979
827
  throw new Error(
980
- `No metadata found in archive: ${archivePath}
981
- This may not be a valid syncpoint backup.`
828
+ `Config file not found: ${configPath}
829
+ Run "syncpoint init" first.`
982
830
  );
983
831
  }
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);
832
+ const raw = await readFile3(configPath, "utf-8");
833
+ const data = stripDangerousKeys(YAML.parse(raw));
834
+ const result = validateConfig(data);
835
+ if (!result.valid) {
836
+ throw new Error(`Invalid config:
837
+ ${(result.errors ?? []).join("\n")}`);
838
+ }
839
+ return data;
840
+ }
841
+ async function initDefaultConfig() {
842
+ const created = [];
843
+ const skipped = [];
844
+ const dirs = [
845
+ getAppDir(),
846
+ getSubDir(BACKUPS_DIR),
847
+ getSubDir(TEMPLATES_DIR),
848
+ getSubDir(SCRIPTS_DIR),
849
+ getSubDir(LOGS_DIR)
850
+ ];
851
+ for (const dir of dirs) {
852
+ const exists = await fileExists(dir);
989
853
  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
- });
854
+ await ensureDir(dir);
855
+ created.push(dir);
1008
856
  } 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
- });
857
+ skipped.push(dir);
1016
858
  }
1017
859
  }
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;
860
+ const configPath = getConfigPath();
861
+ const configExists = await fileExists(configPath);
862
+ if (!configExists) {
863
+ const yamlContent = readAsset("config.default.yml");
864
+ await writeFile2(configPath, yamlContent, "utf-8");
865
+ created.push(configPath);
866
+ } else {
867
+ skipped.push(configPath);
1037
868
  }
1038
- await createArchive(files, archivePath);
1039
- logger.info(`Safety backup created: ${archivePath}`);
1040
- return archivePath;
869
+ return { created, skipped };
1041
870
  }
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;
871
+
872
+ // src/utils/command-registry.ts
873
+ var COMMANDS = {
874
+ init: {
875
+ name: "init",
876
+ description: "Initialize ~/.syncpoint/ directory structure and default config",
877
+ usage: "npx @lumy-pack/syncpoint init",
878
+ examples: ["npx @lumy-pack/syncpoint init"]
879
+ },
880
+ wizard: {
881
+ name: "wizard",
882
+ description: "Interactive wizard to generate config.yml with AI",
883
+ usage: "npx @lumy-pack/syncpoint wizard [options]",
884
+ options: [
885
+ {
886
+ flag: "-p, --print",
887
+ description: "Print prompt instead of invoking Claude Code"
1067
888
  }
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;
889
+ ],
890
+ examples: [
891
+ "npx @lumy-pack/syncpoint wizard",
892
+ "npx @lumy-pack/syncpoint wizard --print"
893
+ ]
894
+ },
895
+ backup: {
896
+ name: "backup",
897
+ description: "Create a compressed backup archive of your configuration files",
898
+ usage: "npx @lumy-pack/syncpoint backup [options]",
899
+ options: [
900
+ {
901
+ flag: "--dry-run",
902
+ description: "Preview files to be backed up without creating archive"
903
+ },
904
+ {
905
+ flag: "--tag <name>",
906
+ description: "Add custom tag to backup filename"
907
+ },
908
+ {
909
+ flag: "-v, --verbose",
910
+ description: "Show detailed output including missing files"
1076
911
  }
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;
912
+ ],
913
+ examples: [
914
+ "npx @lumy-pack/syncpoint backup",
915
+ "npx @lumy-pack/syncpoint backup --dry-run",
916
+ 'npx @lumy-pack/syncpoint backup --tag "before-upgrade"'
917
+ ]
918
+ },
919
+ restore: {
920
+ name: "restore",
921
+ description: "Restore configuration files from a backup archive",
922
+ usage: "npx @lumy-pack/syncpoint restore [filename] [options]",
923
+ arguments: [
924
+ {
925
+ name: "filename",
926
+ description: "Backup file to restore (optional, interactive if omitted)",
927
+ required: false
1087
928
  }
1088
- await copyFile(extractedPath, destPath);
1089
- restoredFiles.push(action.path);
1090
- }
1091
- } finally {
1092
- await rm2(tmpDir, { recursive: true, force: true });
929
+ ],
930
+ options: [
931
+ {
932
+ flag: "--dry-run",
933
+ description: "Show restore plan without actually restoring"
934
+ }
935
+ ],
936
+ examples: [
937
+ "npx @lumy-pack/syncpoint restore",
938
+ "npx @lumy-pack/syncpoint restore macbook-pro_2024-01-15.tar.gz",
939
+ "npx @lumy-pack/syncpoint restore --dry-run"
940
+ ]
941
+ },
942
+ provision: {
943
+ name: "provision",
944
+ description: "Run template-based machine provisioning",
945
+ usage: "npx @lumy-pack/syncpoint provision <template> [options]",
946
+ arguments: [
947
+ {
948
+ name: "template",
949
+ description: "Template name to execute",
950
+ required: true
951
+ }
952
+ ],
953
+ options: [
954
+ {
955
+ flag: "--dry-run",
956
+ description: "Show execution plan without running commands"
957
+ },
958
+ {
959
+ flag: "--skip-restore",
960
+ description: "Skip automatic config restore after provisioning"
961
+ }
962
+ ],
963
+ examples: [
964
+ "npx @lumy-pack/syncpoint provision dev-setup",
965
+ "npx @lumy-pack/syncpoint provision dev-setup --dry-run",
966
+ "npx @lumy-pack/syncpoint provision dev-setup --skip-restore"
967
+ ]
968
+ },
969
+ "create-template": {
970
+ name: "create-template",
971
+ description: "Interactive wizard to create a provisioning template with AI",
972
+ usage: "npx @lumy-pack/syncpoint create-template [name] [options]",
973
+ arguments: [
974
+ {
975
+ name: "name",
976
+ description: "Template filename (optional, generated from template name if omitted)",
977
+ required: false
978
+ }
979
+ ],
980
+ options: [
981
+ {
982
+ flag: "-p, --print",
983
+ description: "Print prompt instead of invoking Claude Code"
984
+ }
985
+ ],
986
+ examples: [
987
+ "npx @lumy-pack/syncpoint create-template",
988
+ "npx @lumy-pack/syncpoint create-template my-dev-setup",
989
+ "npx @lumy-pack/syncpoint create-template --print"
990
+ ]
991
+ },
992
+ list: {
993
+ name: "list",
994
+ description: "Browse and manage backups and templates interactively",
995
+ usage: "npx @lumy-pack/syncpoint list [type] [options]",
996
+ arguments: [
997
+ {
998
+ name: "type",
999
+ description: 'Filter by type: "backups" or "templates" (optional)',
1000
+ required: false
1001
+ }
1002
+ ],
1003
+ options: [{ flag: "--delete <n>", description: "Delete item number n" }],
1004
+ examples: [
1005
+ "npx @lumy-pack/syncpoint list",
1006
+ "npx @lumy-pack/syncpoint list backups",
1007
+ "npx @lumy-pack/syncpoint list templates"
1008
+ ]
1009
+ },
1010
+ status: {
1011
+ name: "status",
1012
+ description: "Show ~/.syncpoint/ status summary and manage cleanup",
1013
+ usage: "npx @lumy-pack/syncpoint status [options]",
1014
+ options: [
1015
+ { flag: "--cleanup", description: "Enter interactive cleanup mode" }
1016
+ ],
1017
+ examples: [
1018
+ "npx @lumy-pack/syncpoint status",
1019
+ "npx @lumy-pack/syncpoint status --cleanup"
1020
+ ]
1021
+ },
1022
+ help: {
1023
+ name: "help",
1024
+ description: "Display help information",
1025
+ usage: "npx @lumy-pack/syncpoint help [command]",
1026
+ arguments: [
1027
+ {
1028
+ name: "command",
1029
+ description: "Command to get detailed help for (optional)",
1030
+ required: false
1031
+ }
1032
+ ],
1033
+ examples: [
1034
+ "npx @lumy-pack/syncpoint help",
1035
+ "npx @lumy-pack/syncpoint help backup",
1036
+ "npx @lumy-pack/syncpoint help provision"
1037
+ ]
1093
1038
  }
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
1039
  };
1133
1040
 
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(() => {
1041
+ // src/commands/Backup.tsx
1042
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1043
+ var BackupView = ({ options }) => {
1044
+ const { exit } = useApp();
1045
+ const [phase, setPhase] = useState("scanning");
1046
+ const [, setConfig] = useState(null);
1047
+ const [foundFiles, setFoundFiles] = useState([]);
1048
+ const [missingFiles, setMissingFiles] = useState([]);
1049
+ const [progress, setProgress] = useState(0);
1050
+ const [result, setResult] = useState(null);
1051
+ const [error, setError] = useState(null);
1052
+ useEffect(() => {
1146
1053
  (async () => {
1147
1054
  try {
1148
- const list2 = await getBackupList();
1149
- setBackups(list2);
1150
- if (list2.length === 0) {
1151
- setError("No backups available.");
1152
- setPhase("error");
1153
- exit();
1055
+ const cfg = await loadConfig();
1056
+ setConfig(cfg);
1057
+ const { found, missing } = await scanTargets(cfg);
1058
+ setFoundFiles(found);
1059
+ setMissingFiles(missing);
1060
+ if (options.dryRun) {
1061
+ setPhase("done");
1062
+ setTimeout(() => exit(), 100);
1154
1063
  return;
1155
1064
  }
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
- }
1065
+ setPhase("compressing");
1066
+ const progressInterval = setInterval(() => {
1067
+ setProgress((prev) => {
1068
+ if (prev >= 90) return prev;
1069
+ return prev + 10;
1070
+ });
1071
+ }, 100);
1072
+ const backupResult = await createBackup(cfg, options);
1073
+ clearInterval(progressInterval);
1074
+ setProgress(100);
1075
+ setResult(backupResult);
1076
+ setPhase("done");
1077
+ setTimeout(() => exit(), 100);
1171
1078
  } catch (err) {
1172
1079
  setError(err instanceof Error ? err.message : String(err));
1173
1080
  setPhase("error");
@@ -1175,197 +1082,95 @@ var RestoreView = ({ filename, options }) => {
1175
1082
  }
1176
1083
  })();
1177
1084
  }, []);
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
1085
  if (phase === "error" || error) {
1226
- return /* @__PURE__ */ jsx5(Box3, { flexDirection: "column", children: /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
1227
- "\u2717 ",
1086
+ return /* @__PURE__ */ jsx2(Box, { flexDirection: "column", children: /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
1087
+ "\u2717 Backup failed: ",
1228
1088
  error
1229
1089
  ] }) });
1230
1090
  }
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" })
1091
+ return /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", children: [
1092
+ /* @__PURE__ */ jsx2(Text2, { bold: true, children: "\u25B8 Scanning backup targets..." }),
1093
+ foundFiles.map((file, idx) => /* @__PURE__ */ jsxs2(Text2, { children: [
1094
+ " ",
1095
+ /* @__PURE__ */ jsx2(Text2, { color: "green", children: "\u2713" }),
1096
+ " ",
1097
+ contractTilde(file.absolutePath),
1098
+ /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
1099
+ " ",
1100
+ formatBytes(file.size).padStart(10)
1101
+ ] })
1102
+ ] }, idx)),
1103
+ missingFiles.map((file, idx) => /* @__PURE__ */ jsxs2(Text2, { children: [
1104
+ " ",
1105
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: "\u26A0" }),
1106
+ " ",
1107
+ file,
1108
+ /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
1109
+ " ",
1110
+ "File not found, skipped"
1111
+ ] })
1112
+ ] }, idx)),
1113
+ options.dryRun && phase === "done" && /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", marginTop: 1, children: [
1114
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: "(dry-run) No actual backup was created" }),
1115
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1116
+ "Target files: ",
1117
+ foundFiles.length,
1118
+ " (",
1119
+ formatBytes(foundFiles.reduce((sum, f) => sum + f.size, 0)),
1120
+ ")"
1121
+ ] })
1309
1122
  ] }),
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..." })
1123
+ phase === "compressing" && /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", marginTop: 1, children: [
1124
+ /* @__PURE__ */ jsx2(Text2, { children: "\u25B8 Compressing..." }),
1125
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1126
+ " ",
1127
+ /* @__PURE__ */ jsx2(ProgressBar, { percent: progress })
1128
+ ] })
1317
1129
  ] }),
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: [
1130
+ phase === "done" && result && !options.dryRun && /* @__PURE__ */ jsxs2(Box, { flexDirection: "column", marginTop: 1, children: [
1131
+ /* @__PURE__ */ jsx2(Text2, { color: "green", bold: true, children: "\u2713 Backup complete" }),
1132
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1325
1133
  " ",
1326
- "Restored: ",
1327
- result.restoredFiles.length,
1328
- " files"
1134
+ "File: ",
1135
+ result.metadata.config.filename
1329
1136
  ] }),
1330
- /* @__PURE__ */ jsxs5(Text5, { children: [
1137
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1331
1138
  " ",
1332
- "Skipped: ",
1333
- result.skippedFiles.length,
1334
- " files"
1139
+ "Size: ",
1140
+ formatBytes(result.metadata.summary.totalSize),
1141
+ " (",
1142
+ result.metadata.summary.fileCount,
1143
+ " files + metadata)"
1335
1144
  ] }),
1336
- result.safetyBackupPath && /* @__PURE__ */ jsxs5(Text5, { children: [
1145
+ /* @__PURE__ */ jsxs2(Text2, { children: [
1337
1146
  " ",
1338
- "Safety backup: ",
1339
- contractTilde(result.safetyBackupPath)
1147
+ "Path: ",
1148
+ contractTilde(result.archivePath)
1340
1149
  ] })
1341
1150
  ] })
1342
1151
  ] });
1343
1152
  };
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
- )
1153
+ function registerBackupCommand(program2) {
1154
+ const cmdInfo = COMMANDS.backup;
1155
+ const cmd = program2.command("backup").description(cmdInfo.description);
1156
+ cmdInfo.options?.forEach((opt) => {
1157
+ cmd.option(opt.flag, opt.description);
1158
+ });
1159
+ cmd.action(async (opts) => {
1160
+ const { waitUntilExit } = render(
1161
+ /* @__PURE__ */ jsx2(BackupView, { options: { dryRun: opts.dryRun, tag: opts.tag, verbose: opts.verbose } })
1354
1162
  );
1355
1163
  await waitUntilExit();
1356
1164
  });
1357
1165
  }
1358
1166
 
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";
1167
+ // src/commands/CreateTemplate.tsx
1168
+ import { useState as useState2, useEffect as useEffect2 } from "react";
1169
+ import { Text as Text3, Box as Box2, useApp as useApp2 } from "ink";
1170
+ import Spinner from "ink-spinner";
1171
+ import { render as render2 } from "ink";
1172
+ import { join as join9 } from "path";
1173
+ import { writeFile as writeFile4 } from "fs/promises";
1369
1174
 
1370
1175
  // src/schemas/template.schema.ts
1371
1176
  var templateSchema = {
@@ -1395,168 +1200,1414 @@ var templateSchema = {
1395
1200
  },
1396
1201
  additionalProperties: false
1397
1202
  };
1398
- var validate3 = ajv.compile(templateSchema);
1203
+ var validate4 = ajv.compile(templateSchema);
1399
1204
  function validateTemplate(data) {
1400
- const valid = validate3(data);
1205
+ const valid = validate4(data);
1401
1206
  if (valid) return { valid: true };
1402
- const errors = validate3.errors?.map(
1207
+ const errors = validate4.errors?.map(
1403
1208
  (e) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`
1404
1209
  );
1405
1210
  return { valid: false, errors };
1406
1211
  }
1407
1212
 
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));
1213
+ // src/prompts/wizard-template.ts
1214
+ function generateTemplateWizardPrompt(variables) {
1215
+ return `You are a Syncpoint provisioning template assistant. Your role is to help users create automated environment setup templates.
1216
+
1217
+ **Input:**
1218
+ 1. User's provisioning requirements (described in natural language)
1219
+ 2. Example template structure (YAML)
1220
+
1221
+ **Your Task:**
1222
+ 1. Ask clarifying questions to understand the provisioning workflow:
1223
+ - What software/tools need to be installed?
1224
+ - What dependencies should be checked?
1225
+ - Are there any configuration steps after installation?
1226
+ - Should any steps require sudo privileges?
1227
+ - Should any steps be conditional (skip_if)?
1228
+ 2. Based on user responses, generate a complete provision template
1229
+
1230
+ **Output Requirements:**
1231
+ - Pure YAML format only (no markdown, no code blocks, no explanations)
1232
+ - Must be valid according to Syncpoint template schema
1233
+ - Required fields:
1234
+ - \`name\`: Template name
1235
+ - \`steps\`: Array of provisioning steps (minimum 1)
1236
+ - Each step must include:
1237
+ - \`name\`: Step name (required)
1238
+ - \`command\`: Shell command to execute (required)
1239
+ - \`description\`: Step description (optional)
1240
+ - \`skip_if\`: Condition to skip step (optional)
1241
+ - \`continue_on_error\`: Whether to continue on failure (optional, default: false)
1242
+ - Optional template fields:
1243
+ - \`description\`: Template description
1244
+ - \`backup\`: Backup name to restore after provisioning
1245
+ - \`sudo\`: Whether sudo is required (boolean)
1246
+
1247
+ **Example Template:**
1248
+ ${variables.exampleTemplate}
1249
+
1250
+ Begin by asking the user to describe their provisioning needs.`;
1417
1251
  }
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);
1252
+
1253
+ // src/utils/claude-code-runner.ts
1254
+ import { spawn } from "child_process";
1255
+ import { unlink, writeFile as writeFile3 } from "fs/promises";
1256
+ import { tmpdir as tmpdir2 } from "os";
1257
+ import { join as join8 } from "path";
1258
+ async function isClaudeCodeAvailable() {
1259
+ return new Promise((resolve2) => {
1260
+ const child = spawn("which", ["claude"], { shell: true });
1261
+ child.on("close", (code) => {
1262
+ resolve2(code === 0);
1263
+ });
1264
+ child.on("error", () => {
1265
+ resolve2(false);
1266
+ });
1267
+ });
1420
1268
  }
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
- );
1269
+ async function invokeClaudeCode(prompt, options) {
1270
+ const timeout = options?.timeout ?? 12e4;
1271
+ const promptFile = join8(tmpdir2(), `syncpoint-prompt-${Date.now()}.txt`);
1272
+ await writeFile3(promptFile, prompt, "utf-8");
1273
+ try {
1274
+ return await new Promise((resolve2, reject) => {
1275
+ const args = ["--permission-mode", "acceptEdits", "--model", "sonnet"];
1276
+ if (options?.sessionId) {
1277
+ args.push("--session", options.sessionId);
1278
+ }
1279
+ const child = spawn("claude", args, {
1280
+ stdio: ["pipe", "pipe", "pipe"]
1281
+ });
1282
+ let stdout = "";
1283
+ let stderr = "";
1284
+ child.stdout.on("data", (data) => {
1285
+ stdout += data.toString();
1286
+ });
1287
+ child.stderr.on("data", (data) => {
1288
+ stderr += data.toString();
1289
+ });
1290
+ child.stdin.write(prompt);
1291
+ child.stdin.end();
1292
+ const timer = setTimeout(() => {
1293
+ child.kill();
1294
+ reject(new Error(`Claude Code invocation timeout after ${timeout}ms`));
1295
+ }, timeout);
1296
+ child.on("close", (code) => {
1297
+ clearTimeout(timer);
1298
+ if (code === 0) {
1299
+ resolve2({
1300
+ success: true,
1301
+ output: stdout,
1302
+ sessionId: options?.sessionId
1303
+ });
1304
+ } else {
1305
+ resolve2({
1306
+ success: false,
1307
+ output: stdout,
1308
+ error: stderr || `Process exited with code ${code}`
1309
+ });
1310
+ }
1311
+ });
1312
+ child.on("error", (err) => {
1313
+ clearTimeout(timer);
1314
+ reject(err);
1315
+ });
1316
+ });
1317
+ } finally {
1318
+ try {
1319
+ await unlink(promptFile);
1320
+ } catch {
1321
+ }
1434
1322
  }
1435
- return data;
1436
1323
  }
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;
1324
+ async function resumeClaudeCodeSession(sessionId, prompt, options) {
1325
+ return invokeClaudeCode(prompt, {
1326
+ sessionId,
1327
+ timeout: options?.timeout
1328
+ });
1329
+ }
1330
+ async function invokeClaudeCodeInteractive(prompt) {
1331
+ return await new Promise((resolve2, reject) => {
1332
+ const initialMessage = `${prompt}
1333
+
1334
+ IMPORTANT INSTRUCTIONS:
1335
+ 1. After gathering the user's backup preferences through conversation
1336
+ 2. Use the Write tool to create the file at: ~/.syncpoint/config.yml
1337
+ 3. The file must be valid YAML following the Syncpoint schema
1338
+ 4. Include backup.targets array with recommended files based on user responses
1339
+ 5. Include backup.exclude array with common exclusions
1340
+
1341
+ Start by asking the user about their backup priorities for the home directory structure provided above.`;
1342
+ const args = [
1343
+ "--permission-mode",
1344
+ "acceptEdits",
1345
+ "--model",
1346
+ "sonnet",
1347
+ initialMessage
1348
+ // Include full context and instructions in initial message
1349
+ ];
1350
+ const child = spawn("claude", args, {
1351
+ stdio: "inherit"
1352
+ // Share stdin/stdout/stderr with parent process
1353
+ });
1354
+ child.on("close", (code) => {
1355
+ resolve2({
1356
+ success: code === 0,
1357
+ output: ""
1358
+ // No captured output in interactive mode
1359
+ });
1360
+ });
1361
+ child.on("error", (err) => {
1362
+ reject(err);
1363
+ });
1364
+ });
1365
+ }
1366
+
1367
+ // src/utils/yaml-parser.ts
1368
+ import YAML2 from "yaml";
1369
+ function isStructuredYAML(parsed) {
1370
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
1371
+ }
1372
+ function extractYAML(response) {
1373
+ const codeBlockMatch = response.match(/```ya?ml\s*\n([\s\S]*?)\n```/);
1374
+ if (codeBlockMatch) {
1375
+ const content = codeBlockMatch[1].trim();
1376
+ try {
1377
+ const parsed = YAML2.parse(content);
1378
+ if (isStructuredYAML(parsed)) return content;
1379
+ } catch {
1446
1380
  }
1447
- const fullPath = join10(templatesDir, entry.name);
1381
+ }
1382
+ const genericCodeBlockMatch = response.match(/```\s*\n([\s\S]*?)\n```/);
1383
+ if (genericCodeBlockMatch) {
1384
+ const content = genericCodeBlockMatch[1].trim();
1448
1385
  try {
1449
- const config = await loadTemplate(fullPath);
1450
- templates.push({
1451
- name: entry.name.replace(/\.ya?ml$/, ""),
1452
- path: fullPath,
1453
- config
1454
- });
1386
+ const parsed = YAML2.parse(content);
1387
+ if (isStructuredYAML(parsed)) return content;
1455
1388
  } catch {
1456
- logger.warn(`Skipping invalid template: ${entry.name}`);
1457
1389
  }
1458
1390
  }
1459
- return templates;
1391
+ try {
1392
+ const parsed = YAML2.parse(response);
1393
+ if (isStructuredYAML(parsed)) {
1394
+ return response.trim();
1395
+ }
1396
+ } catch {
1397
+ }
1398
+ return null;
1460
1399
  }
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
- })
1400
+ function parseYAML(yamlString) {
1401
+ return YAML2.parse(yamlString);
1402
+ }
1403
+
1404
+ // src/utils/error-formatter.ts
1405
+ function formatValidationErrors(errors) {
1406
+ if (errors.length === 0) {
1407
+ return "No validation errors.";
1408
+ }
1409
+ const formattedErrors = errors.map((error, index) => {
1410
+ return `${index + 1}. ${error}`;
1411
+ });
1412
+ return `Validation failed with ${errors.length} error(s):
1413
+
1414
+ ${formattedErrors.join("\n")}`;
1415
+ }
1416
+ function createRetryPrompt(originalPrompt, errors, attemptNumber) {
1417
+ const errorSummary = formatValidationErrors(errors);
1418
+ return `${originalPrompt}
1419
+
1420
+ ---
1421
+
1422
+ **VALIDATION FAILED (Attempt ${attemptNumber})**
1423
+
1424
+ The previously generated YAML configuration did not pass validation:
1425
+
1426
+ ${errorSummary}
1427
+
1428
+ Please analyze these errors and generate a corrected YAML configuration that addresses all validation issues.
1429
+
1430
+ Remember:
1431
+ - Output pure YAML only (no markdown, no code blocks, no explanations)
1432
+ - Ensure all required fields are present
1433
+ - Follow the correct schema structure
1434
+ - Validate pattern syntax for targets and exclude arrays`;
1435
+ }
1436
+
1437
+ // src/commands/CreateTemplate.tsx
1438
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1439
+ var MAX_RETRIES = 3;
1440
+ var CreateTemplateView = ({
1441
+ printMode,
1442
+ templateName
1443
+ }) => {
1444
+ const { exit } = useApp2();
1445
+ const [phase, setPhase] = useState2("init");
1446
+ const [message, setMessage] = useState2("");
1447
+ const [error, setError] = useState2(null);
1448
+ const [prompt, setPrompt] = useState2("");
1449
+ const [sessionId, setSessionId] = useState2(void 0);
1450
+ const [attemptNumber, setAttemptNumber] = useState2(1);
1451
+ useEffect2(() => {
1452
+ (async () => {
1453
+ try {
1454
+ const templatesDir = getSubDir("templates");
1455
+ await ensureDir(templatesDir);
1456
+ const exampleTemplate = readAsset("template.example.yml");
1457
+ const generatedPrompt = generateTemplateWizardPrompt({
1458
+ exampleTemplate
1459
+ });
1460
+ setPrompt(generatedPrompt);
1461
+ if (printMode) {
1462
+ setPhase("done");
1463
+ exit();
1464
+ return;
1465
+ }
1466
+ if (!await isClaudeCodeAvailable()) {
1467
+ throw new Error(
1468
+ "Claude Code CLI not found. Install it or use --print mode to get the prompt."
1469
+ );
1470
+ }
1471
+ await invokeLLMWithRetry(generatedPrompt, templatesDir);
1472
+ } catch (err) {
1473
+ setError(err instanceof Error ? err.message : String(err));
1474
+ setPhase("error");
1475
+ setTimeout(() => exit(), 100);
1476
+ }
1477
+ })();
1478
+ }, []);
1479
+ async function invokeLLMWithRetry(initialPrompt, templatesDir) {
1480
+ let currentPrompt = initialPrompt;
1481
+ let currentAttempt = 1;
1482
+ let currentSessionId = sessionId;
1483
+ while (currentAttempt <= MAX_RETRIES) {
1484
+ try {
1485
+ setPhase("llm-invoke");
1486
+ setMessage(`Generating template... (Attempt ${currentAttempt}/${MAX_RETRIES})`);
1487
+ const result = currentSessionId ? await resumeClaudeCodeSession(currentSessionId, currentPrompt) : await invokeClaudeCode(currentPrompt);
1488
+ if (!result.success) {
1489
+ throw new Error(result.error || "Failed to invoke Claude Code");
1490
+ }
1491
+ currentSessionId = result.sessionId;
1492
+ setSessionId(currentSessionId);
1493
+ setPhase("validating");
1494
+ setMessage("Parsing YAML response...");
1495
+ const yamlContent = extractYAML(result.output);
1496
+ if (!yamlContent) {
1497
+ throw new Error("No valid YAML found in LLM response");
1498
+ }
1499
+ const parsedTemplate = parseYAML(yamlContent);
1500
+ setMessage("Validating template...");
1501
+ const validation = validateTemplate(parsedTemplate);
1502
+ if (validation.valid) {
1503
+ setPhase("writing");
1504
+ setMessage("Writing template...");
1505
+ const filename = templateName ? `${templateName}.yml` : `${parsedTemplate.name.toLowerCase().replace(/\s+/g, "-")}.yml`;
1506
+ const templatePath = join9(templatesDir, filename);
1507
+ if (await fileExists(templatePath)) {
1508
+ throw new Error(
1509
+ `Template already exists: ${filename}
1510
+ Please choose a different name or delete the existing template.`
1511
+ );
1512
+ }
1513
+ await writeFile4(templatePath, yamlContent, "utf-8");
1514
+ setPhase("done");
1515
+ setMessage(`\u2713 Template created: ${filename}`);
1516
+ setTimeout(() => exit(), 100);
1517
+ return;
1518
+ }
1519
+ if (currentAttempt >= MAX_RETRIES) {
1520
+ throw new Error(
1521
+ `Validation failed after ${MAX_RETRIES} attempts:
1522
+ ${formatValidationErrors(validation.errors || [])}`
1473
1523
  );
1474
- } else {
1475
- resolve2({
1476
- stdout: stdout?.toString() ?? "",
1477
- stderr: stderr?.toString() ?? ""
1478
- });
1479
1524
  }
1525
+ setPhase("retry");
1526
+ setMessage(`Validation failed. Retrying with error context...`);
1527
+ currentPrompt = createRetryPrompt(
1528
+ initialPrompt,
1529
+ validation.errors || [],
1530
+ currentAttempt + 1
1531
+ );
1532
+ currentAttempt++;
1533
+ setAttemptNumber(currentAttempt);
1534
+ } catch (err) {
1535
+ if (currentAttempt >= MAX_RETRIES) {
1536
+ throw err;
1537
+ }
1538
+ currentAttempt++;
1539
+ setAttemptNumber(currentAttempt);
1480
1540
  }
1541
+ }
1542
+ }
1543
+ if (error) {
1544
+ return /* @__PURE__ */ jsx3(Box2, { flexDirection: "column", children: /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
1545
+ "\u2717 ",
1546
+ error
1547
+ ] }) });
1548
+ }
1549
+ if (printMode && phase === "done") {
1550
+ return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
1551
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: "Create Template Prompt (Copy and paste to your LLM):" }),
1552
+ /* @__PURE__ */ jsx3(Box2, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u2500".repeat(60) }) }),
1553
+ /* @__PURE__ */ jsx3(Text3, { children: prompt }),
1554
+ /* @__PURE__ */ jsx3(Box2, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u2500".repeat(60) }) }),
1555
+ /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "After getting the YAML response, save it to ~/.syncpoint/templates/" })
1556
+ ] });
1557
+ }
1558
+ if (phase === "done") {
1559
+ return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
1560
+ /* @__PURE__ */ jsx3(Text3, { color: "green", children: message }),
1561
+ /* @__PURE__ */ jsxs3(Box2, { marginTop: 1, children: [
1562
+ /* @__PURE__ */ jsx3(Text3, { children: "Next steps:" }),
1563
+ /* @__PURE__ */ jsx3(Text3, { children: " 1. Review your template: syncpoint list templates" }),
1564
+ /* @__PURE__ */ jsx3(Text3, { children: " 2. Run provisioning: syncpoint provision <template-name>" })
1565
+ ] })
1566
+ ] });
1567
+ }
1568
+ return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
1569
+ /* @__PURE__ */ jsxs3(Text3, { children: [
1570
+ /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: /* @__PURE__ */ jsx3(Spinner, { type: "dots" }) }),
1571
+ " ",
1572
+ message
1573
+ ] }),
1574
+ attemptNumber > 1 && /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1575
+ "Attempt ",
1576
+ attemptNumber,
1577
+ "/",
1578
+ MAX_RETRIES
1579
+ ] })
1580
+ ] });
1581
+ };
1582
+ function registerCreateTemplateCommand(program2) {
1583
+ program2.command("create-template [name]").description("Interactive wizard to create a provisioning template").option("-p, --print", "Print prompt instead of invoking Claude Code").action(async (name, opts) => {
1584
+ const { waitUntilExit } = render2(
1585
+ /* @__PURE__ */ jsx3(CreateTemplateView, { printMode: opts.print || false, templateName: name })
1481
1586
  );
1587
+ await waitUntilExit();
1482
1588
  });
1483
1589
  }
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;
1590
+
1591
+ // src/commands/Help.tsx
1592
+ import { Box as Box3, Text as Text4 } from "ink";
1593
+ import { render as render3 } from "ink";
1594
+ import { Fragment, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1595
+ var GeneralHelpView = () => {
1596
+ return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
1597
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "SYNCPOINT - Personal Environment Manager" }),
1598
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1599
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "USAGE" }),
1600
+ /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { children: "npx @lumy-pack/syncpoint <command> [options]" }) }),
1601
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1602
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "AVAILABLE COMMANDS" }),
1603
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1604
+ /* @__PURE__ */ jsxs4(Box3, { marginLeft: 2, flexDirection: "column", children: [
1605
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1606
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "init" }),
1607
+ " ",
1608
+ "Initialize syncpoint directory structure"
1609
+ ] }),
1610
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1611
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "wizard" }),
1612
+ " ",
1613
+ "Generate config.yml with AI assistance"
1614
+ ] }),
1615
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1616
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "backup" }),
1617
+ " ",
1618
+ "Create compressed backup archive"
1619
+ ] }),
1620
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1621
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "restore [filename]" }),
1622
+ " ",
1623
+ "Restore configuration files"
1624
+ ] }),
1625
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1626
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "provision <template>" }),
1627
+ " ",
1628
+ "Run machine provisioning template"
1629
+ ] }),
1630
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1631
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "create-template [name]" }),
1632
+ " ",
1633
+ "Create provisioning template with AI"
1634
+ ] }),
1635
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1636
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "list [type]" }),
1637
+ " ",
1638
+ "Browse backups and templates"
1639
+ ] }),
1640
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1641
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "status" }),
1642
+ " ",
1643
+ "Show status and manage cleanup"
1644
+ ] }),
1645
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1646
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "help [command]" }),
1647
+ " ",
1648
+ "Show help for specific command"
1649
+ ] })
1650
+ ] }),
1651
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1652
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "GLOBAL OPTIONS" }),
1653
+ /* @__PURE__ */ jsxs4(Box3, { marginLeft: 2, flexDirection: "column", children: [
1654
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1655
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "-V, --version" }),
1656
+ " ",
1657
+ "Output the version number"
1658
+ ] }),
1659
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1660
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "-h, --help" }),
1661
+ " ",
1662
+ "Display help for command"
1663
+ ] })
1664
+ ] }),
1665
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1666
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Run 'syncpoint help <command>' for detailed information about a specific command." })
1667
+ ] });
1668
+ };
1669
+ var CommandDetailView = ({ command }) => {
1670
+ return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
1671
+ /* @__PURE__ */ jsxs4(Text4, { children: [
1672
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "COMMAND: " }),
1673
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: command.name })
1674
+ ] }),
1675
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1676
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "DESCRIPTION" }),
1677
+ /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { children: command.description }) }),
1678
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1679
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "USAGE" }),
1680
+ /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, children: /* @__PURE__ */ jsx4(Text4, { children: command.usage }) }),
1681
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1682
+ command.arguments && command.arguments.length > 0 && /* @__PURE__ */ jsxs4(Fragment, { children: [
1683
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "ARGUMENTS" }),
1684
+ /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, flexDirection: "column", children: command.arguments.map((arg, idx) => /* @__PURE__ */ jsxs4(Text4, { children: [
1685
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: arg.required ? `<${arg.name}>` : `[${arg.name}]` }),
1686
+ " ",
1687
+ arg.description
1688
+ ] }, idx)) }),
1689
+ /* @__PURE__ */ jsx4(Text4, { children: "" })
1690
+ ] }),
1691
+ command.options && command.options.length > 0 && /* @__PURE__ */ jsxs4(Fragment, { children: [
1692
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "OPTIONS" }),
1693
+ /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, flexDirection: "column", children: command.options.map((opt, idx) => /* @__PURE__ */ jsxs4(Text4, { children: [
1694
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: opt.flag }),
1695
+ opt.flag.length < 20 && " ".repeat(20 - opt.flag.length),
1696
+ opt.description,
1697
+ opt.default && /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1698
+ " (default: ",
1699
+ opt.default,
1700
+ ")"
1701
+ ] })
1702
+ ] }, idx)) }),
1703
+ /* @__PURE__ */ jsx4(Text4, { children: "" })
1704
+ ] }),
1705
+ command.examples && command.examples.length > 0 && /* @__PURE__ */ jsxs4(Fragment, { children: [
1706
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "EXAMPLES" }),
1707
+ /* @__PURE__ */ jsx4(Box3, { marginLeft: 2, flexDirection: "column", children: command.examples.map((example, idx) => /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: example }, idx)) })
1708
+ ] })
1709
+ ] });
1710
+ };
1711
+ var HelpView = ({ commandName }) => {
1712
+ if (commandName) {
1713
+ const commandInfo = COMMANDS[commandName];
1714
+ if (!commandInfo) {
1715
+ return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
1716
+ /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
1717
+ "\u2717 Unknown command: ",
1718
+ commandName
1719
+ ] }),
1720
+ /* @__PURE__ */ jsx4(Text4, { children: "" }),
1721
+ /* @__PURE__ */ jsx4(Text4, { children: "Run 'syncpoint help' to see all available commands." })
1722
+ ] });
1723
+ }
1724
+ return /* @__PURE__ */ jsx4(CommandDetailView, { command: commandInfo });
1493
1725
  }
1726
+ return /* @__PURE__ */ jsx4(GeneralHelpView, {});
1727
+ };
1728
+ function registerHelpCommand(program2) {
1729
+ program2.command("help [command]").description("Display help information").action(async (commandName) => {
1730
+ const { waitUntilExit } = render3(/* @__PURE__ */ jsx4(HelpView, { commandName }));
1731
+ await waitUntilExit();
1732
+ });
1494
1733
  }
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
- }
1734
+
1735
+ // src/commands/Init.tsx
1736
+ import { useState as useState3, useEffect as useEffect3 } from "react";
1737
+ import { Text as Text5, Box as Box4, useApp as useApp3 } from "ink";
1738
+ import { render as render4 } from "ink";
1739
+ import { join as join10 } from "path";
1740
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1741
+ var InitView = () => {
1742
+ const { exit } = useApp3();
1743
+ const [steps, setSteps] = useState3([]);
1744
+ const [error, setError] = useState3(null);
1745
+ const [complete, setComplete] = useState3(false);
1746
+ useEffect3(() => {
1747
+ (async () => {
1748
+ try {
1749
+ const appDir = getAppDir();
1750
+ if (await fileExists(join10(appDir, CONFIG_FILENAME))) {
1751
+ setError(`Already initialized: ${appDir}`);
1752
+ exit();
1753
+ return;
1754
+ }
1755
+ const dirs = [
1756
+ { name: appDir, label: `~/.${APP_NAME}/` },
1757
+ {
1758
+ name: getSubDir(BACKUPS_DIR),
1759
+ label: `~/.${APP_NAME}/${BACKUPS_DIR}/`
1760
+ },
1761
+ {
1762
+ name: getSubDir(TEMPLATES_DIR),
1763
+ label: `~/.${APP_NAME}/${TEMPLATES_DIR}/`
1764
+ },
1765
+ {
1766
+ name: getSubDir(SCRIPTS_DIR),
1767
+ label: `~/.${APP_NAME}/${SCRIPTS_DIR}/`
1768
+ },
1769
+ {
1770
+ name: getSubDir(LOGS_DIR),
1771
+ label: `~/.${APP_NAME}/${LOGS_DIR}/`
1772
+ }
1773
+ ];
1774
+ const completed = [];
1775
+ for (const dir of dirs) {
1776
+ await ensureDir(dir.name);
1777
+ completed.push({ name: `Created ${dir.label}`, done: true });
1778
+ setSteps([...completed]);
1779
+ }
1780
+ await initDefaultConfig();
1781
+ completed.push({ name: `Created ${CONFIG_FILENAME} (defaults)`, done: true });
1782
+ setSteps([...completed]);
1783
+ const exampleTemplatePath = join10(getSubDir(TEMPLATES_DIR), "example.yml");
1784
+ if (!await fileExists(exampleTemplatePath)) {
1785
+ const { writeFile: writeFile6 } = await import("fs/promises");
1786
+ const exampleYaml = readAsset("template.example.yml");
1787
+ await writeFile6(exampleTemplatePath, exampleYaml, "utf-8");
1788
+ completed.push({ name: `Created templates/example.yml`, done: true });
1789
+ setSteps([...completed]);
1790
+ }
1791
+ setComplete(true);
1792
+ setTimeout(() => exit(), 100);
1793
+ } catch (err) {
1794
+ setError(err instanceof Error ? err.message : String(err));
1795
+ exit();
1796
+ }
1797
+ })();
1798
+ }, []);
1799
+ if (error) {
1800
+ return /* @__PURE__ */ jsx5(Box4, { flexDirection: "column", children: /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
1801
+ "\u2717 ",
1802
+ error
1803
+ ] }) });
1509
1804
  }
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
1805
+ return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
1806
+ steps.map((step, idx) => /* @__PURE__ */ jsxs5(Text5, { children: [
1807
+ /* @__PURE__ */ jsx5(Text5, { color: "green", children: "\u2713" }),
1808
+ " ",
1809
+ step.name
1810
+ ] }, idx)),
1811
+ complete && /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", marginTop: 1, children: [
1812
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Initialization complete! Next steps:" }),
1813
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1814
+ " ",
1815
+ "1. Edit config.yml to specify backup targets"
1816
+ ] }),
1817
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1818
+ " ",
1819
+ "\u2192 ~/.",
1820
+ APP_NAME,
1821
+ "/",
1822
+ CONFIG_FILENAME
1823
+ ] }),
1824
+ /* @__PURE__ */ jsxs5(Text5, { children: [
1825
+ " ",
1826
+ "2. Run ",
1827
+ APP_NAME,
1828
+ " backup to create your first snapshot"
1829
+ ] })
1830
+ ] })
1831
+ ] });
1832
+ };
1833
+ function registerInitCommand(program2) {
1834
+ program2.command("init").description(`Initialize ~/.${APP_NAME}/ directory structure and default config`).action(async () => {
1835
+ const { waitUntilExit } = render4(/* @__PURE__ */ jsx5(InitView, {}));
1836
+ await waitUntilExit();
1837
+ });
1838
+ }
1839
+
1840
+ // src/commands/List.tsx
1841
+ import { unlinkSync } from "fs";
1842
+ import { Box as Box6, Text as Text8, useApp as useApp4, useInput as useInput2 } from "ink";
1843
+ import { render as render5 } from "ink";
1844
+ import SelectInput from "ink-select-input";
1845
+ import { useEffect as useEffect4, useState as useState5 } from "react";
1846
+
1847
+ // src/components/Confirm.tsx
1848
+ import { Text as Text6, useInput } from "ink";
1849
+ import { useState as useState4 } from "react";
1850
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1851
+ var Confirm = ({
1852
+ message,
1853
+ onConfirm,
1854
+ defaultYes = true
1855
+ }) => {
1856
+ const [answered, setAnswered] = useState4(false);
1857
+ useInput((input, key) => {
1858
+ if (answered) return;
1859
+ if (input === "y" || input === "Y") {
1860
+ setAnswered(true);
1861
+ onConfirm(true);
1862
+ } else if (input === "n" || input === "N") {
1863
+ setAnswered(true);
1864
+ onConfirm(false);
1865
+ } else if (key.return) {
1866
+ setAnswered(true);
1867
+ onConfirm(defaultYes);
1868
+ }
1869
+ });
1870
+ const yText = defaultYes ? /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Y" }) : /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "y" });
1871
+ const nText = defaultYes ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "n" }) : /* @__PURE__ */ jsx6(Text6, { bold: true, children: "N" });
1872
+ return /* @__PURE__ */ jsxs6(Text6, { children: [
1873
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: "? " }),
1874
+ message,
1875
+ " ",
1876
+ /* @__PURE__ */ jsx6(Text6, { color: "gray", children: "[" }),
1877
+ yText,
1878
+ /* @__PURE__ */ jsx6(Text6, { color: "gray", children: "/" }),
1879
+ nText,
1880
+ /* @__PURE__ */ jsx6(Text6, { color: "gray", children: "]" })
1881
+ ] });
1882
+ };
1883
+
1884
+ // src/components/Table.tsx
1885
+ import { Text as Text7, Box as Box5 } from "ink";
1886
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1887
+ var Table = ({
1888
+ headers,
1889
+ rows,
1890
+ columnWidths
1891
+ }) => {
1892
+ const widths = columnWidths ?? headers.map((header, colIdx) => {
1893
+ const dataMax = rows.reduce(
1894
+ (max, row) => Math.max(max, (row[colIdx] ?? "").length),
1895
+ 0
1896
+ );
1897
+ return Math.max(header.length, dataMax) + 2;
1898
+ });
1899
+ const padCell = (text, width) => {
1900
+ return text.padEnd(width);
1901
+ };
1902
+ const separator = widths.map((w) => "\u2500".repeat(w)).join(" ");
1903
+ return /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", children: [
1904
+ /* @__PURE__ */ jsx7(Text7, { children: headers.map((h, i) => /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
1905
+ padCell(h, widths[i]),
1906
+ i < headers.length - 1 ? " " : ""
1907
+ ] }, i)) }),
1908
+ /* @__PURE__ */ jsx7(Text7, { color: "gray", children: separator }),
1909
+ rows.map((row, rowIdx) => /* @__PURE__ */ jsx7(Text7, { children: row.map((cell, colIdx) => /* @__PURE__ */ jsxs7(Text7, { children: [
1910
+ padCell(cell, widths[colIdx]),
1911
+ colIdx < row.length - 1 ? " " : ""
1912
+ ] }, colIdx)) }, rowIdx))
1913
+ ] });
1914
+ };
1915
+
1916
+ // src/core/provision.ts
1917
+ import { exec } from "child_process";
1918
+ import { readFile as readFile4, readdir as readdir2 } from "fs/promises";
1919
+ import { join as join11 } from "path";
1920
+ import YAML3 from "yaml";
1921
+ var REMOTE_SCRIPT_PATTERNS = [
1922
+ /curl\s.*\|\s*(ba)?sh/,
1923
+ /wget\s.*\|\s*(ba)?sh/,
1924
+ /curl\s.*\|\s*python/,
1925
+ /wget\s.*\|\s*python/
1926
+ ];
1927
+ function containsRemoteScriptPattern(command) {
1928
+ return REMOTE_SCRIPT_PATTERNS.some((p) => p.test(command));
1929
+ }
1930
+ function sanitizeErrorOutput(output) {
1931
+ return output.replace(/\/Users\/[^\s/]+/g, "/Users/***").replace(/\/home\/[^\s/]+/g, "/home/***").replace(/(password|token|key|secret)[=:]\s*\S+/gi, "$1=***").slice(0, 500);
1932
+ }
1933
+ async function loadTemplate(templatePath) {
1934
+ const exists = await fileExists(templatePath);
1935
+ if (!exists) {
1936
+ throw new Error(`Template not found: ${templatePath}`);
1937
+ }
1938
+ const raw = await readFile4(templatePath, "utf-8");
1939
+ const data = YAML3.parse(raw);
1940
+ const result = validateTemplate(data);
1941
+ if (!result.valid) {
1942
+ throw new Error(
1943
+ `Invalid template ${templatePath}:
1944
+ ${(result.errors ?? []).join("\n")}`
1945
+ );
1946
+ }
1947
+ return data;
1948
+ }
1949
+ async function listTemplates() {
1950
+ const templatesDir = getSubDir(TEMPLATES_DIR);
1951
+ const exists = await fileExists(templatesDir);
1952
+ if (!exists) return [];
1953
+ const entries = await readdir2(templatesDir, { withFileTypes: true });
1954
+ const templates = [];
1955
+ for (const entry of entries) {
1956
+ if (!entry.isFile() || !entry.name.endsWith(".yml") && !entry.name.endsWith(".yaml")) {
1957
+ continue;
1958
+ }
1959
+ const fullPath = join11(templatesDir, entry.name);
1960
+ try {
1961
+ const config = await loadTemplate(fullPath);
1962
+ templates.push({
1963
+ name: entry.name.replace(/\.ya?ml$/, ""),
1964
+ path: fullPath,
1965
+ config
1966
+ });
1967
+ } catch {
1968
+ logger.warn(`Skipping invalid template: ${entry.name}`);
1969
+ }
1970
+ }
1971
+ return templates;
1972
+ }
1973
+ function execAsync(command) {
1974
+ return new Promise((resolve2, reject) => {
1975
+ exec(
1976
+ command,
1977
+ { shell: "/bin/bash", timeout: 3e5 },
1978
+ (error, stdout, stderr) => {
1979
+ if (error) {
1980
+ reject(
1981
+ Object.assign(error, {
1982
+ stdout: stdout?.toString() ?? "",
1983
+ stderr: stderr?.toString() ?? ""
1984
+ })
1985
+ );
1986
+ } else {
1987
+ resolve2({
1988
+ stdout: stdout?.toString() ?? "",
1989
+ stderr: stderr?.toString() ?? ""
1990
+ });
1991
+ }
1992
+ }
1993
+ );
1994
+ });
1995
+ }
1996
+ async function evaluateSkipIf(command, stepName) {
1997
+ if (containsRemoteScriptPattern(command)) {
1998
+ throw new Error(
1999
+ `Blocked dangerous remote script pattern in skip_if: ${stepName}`
2000
+ );
2001
+ }
2002
+ try {
2003
+ await execAsync(command);
2004
+ return true;
2005
+ } catch {
2006
+ return false;
2007
+ }
2008
+ }
2009
+ async function executeStep(step) {
2010
+ const startTime = Date.now();
2011
+ if (containsRemoteScriptPattern(step.command)) {
2012
+ throw new Error(
2013
+ `Blocked dangerous remote script pattern in command: ${step.name}`
2014
+ );
2015
+ }
2016
+ if (step.skip_if) {
2017
+ const shouldSkip = await evaluateSkipIf(step.skip_if, step.name);
2018
+ if (shouldSkip) {
2019
+ return {
2020
+ name: step.name,
2021
+ status: "skipped",
2022
+ duration: Date.now() - startTime
2023
+ };
2024
+ }
2025
+ }
2026
+ try {
2027
+ const { stdout, stderr } = await execAsync(step.command);
2028
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
2029
+ return {
2030
+ name: step.name,
2031
+ status: "success",
2032
+ duration: Date.now() - startTime,
2033
+ output: output || void 0
2034
+ };
2035
+ } catch (err) {
2036
+ const error = err;
2037
+ const errorOutput = [error.stdout, error.stderr, error.message].filter(Boolean).join("\n").trim();
2038
+ return {
2039
+ name: step.name,
2040
+ status: "failed",
2041
+ duration: Date.now() - startTime,
2042
+ error: sanitizeErrorOutput(errorOutput)
2043
+ };
2044
+ }
2045
+ }
2046
+ async function* runProvision(templatePath, options = {}) {
2047
+ const template = await loadTemplate(templatePath);
2048
+ for (const step of template.steps) {
2049
+ if (options.dryRun) {
2050
+ let status = "pending";
2051
+ if (step.skip_if) {
2052
+ const shouldSkip = await evaluateSkipIf(step.skip_if, step.name);
2053
+ if (shouldSkip) status = "skipped";
2054
+ }
2055
+ yield {
2056
+ name: step.name,
2057
+ status
2058
+ };
2059
+ continue;
2060
+ }
2061
+ yield {
2062
+ name: step.name,
2063
+ status: "running"
2064
+ };
2065
+ const result = await executeStep(step);
2066
+ yield result;
2067
+ if (result.status === "failed" && !step.continue_on_error) {
2068
+ logger.error(`Step "${step.name}" failed. Stopping provisioning.`);
2069
+ return;
2070
+ }
2071
+ }
2072
+ }
2073
+
2074
+ // src/core/restore.ts
2075
+ import { copyFile, lstat as lstat2, readdir as readdir3, stat as stat2 } from "fs/promises";
2076
+ import { dirname as dirname2, join as join12 } from "path";
2077
+ async function getBackupList(config) {
2078
+ const backupDir = config?.backup.destination ? resolveTargetPath(config.backup.destination) : getSubDir(BACKUPS_DIR);
2079
+ const exists = await fileExists(backupDir);
2080
+ if (!exists) return [];
2081
+ const entries = await readdir3(backupDir, { withFileTypes: true });
2082
+ const backups = [];
2083
+ for (const entry of entries) {
2084
+ if (!entry.isFile() || !entry.name.endsWith(".tar.gz")) continue;
2085
+ const fullPath = join12(backupDir, entry.name);
2086
+ const fileStat = await stat2(fullPath);
2087
+ let hostname;
2088
+ let fileCount;
2089
+ try {
2090
+ const metaBuf = await readFileFromArchive(fullPath, METADATA_FILENAME);
2091
+ if (metaBuf) {
2092
+ const meta = parseMetadata(metaBuf);
2093
+ hostname = meta.hostname;
2094
+ fileCount = meta.summary.fileCount;
2095
+ }
2096
+ } catch {
2097
+ logger.info(`Could not read metadata from: ${entry.name}`);
2098
+ }
2099
+ backups.push({
2100
+ filename: entry.name,
2101
+ path: fullPath,
2102
+ size: fileStat.size,
2103
+ createdAt: fileStat.mtime,
2104
+ hostname,
2105
+ fileCount
2106
+ });
2107
+ }
2108
+ backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
2109
+ return backups;
2110
+ }
2111
+ async function getRestorePlan(archivePath) {
2112
+ const metaBuf = await readFileFromArchive(archivePath, METADATA_FILENAME);
2113
+ if (!metaBuf) {
2114
+ throw new Error(
2115
+ `No metadata found in archive: ${archivePath}
2116
+ This may not be a valid syncpoint backup.`
2117
+ );
2118
+ }
2119
+ const metadata = parseMetadata(metaBuf);
2120
+ const actions = [];
2121
+ for (const file of metadata.files) {
2122
+ const absPath = resolveTargetPath(file.path);
2123
+ const exists = await fileExists(absPath);
2124
+ if (!exists) {
2125
+ actions.push({
2126
+ path: file.path,
2127
+ action: "create",
2128
+ backupSize: file.size,
2129
+ reason: "File does not exist on this machine"
2130
+ });
2131
+ continue;
2132
+ }
2133
+ const currentHash = await computeFileHash(absPath);
2134
+ const currentStat = await stat2(absPath);
2135
+ if (currentHash === file.hash) {
2136
+ actions.push({
2137
+ path: file.path,
2138
+ action: "skip",
2139
+ currentSize: currentStat.size,
2140
+ backupSize: file.size,
2141
+ reason: "File is identical (same hash)"
2142
+ });
2143
+ } else {
2144
+ actions.push({
2145
+ path: file.path,
2146
+ action: "overwrite",
2147
+ currentSize: currentStat.size,
2148
+ backupSize: file.size,
2149
+ reason: "File has been modified"
2150
+ });
2151
+ }
2152
+ }
2153
+ return { metadata, actions };
2154
+ }
2155
+ async function createSafetyBackup(filePaths) {
2156
+ const now = /* @__PURE__ */ new Date();
2157
+ const filename = `_pre-restore_${formatDatetime(now)}.tar.gz`;
2158
+ const backupDir = getSubDir(BACKUPS_DIR);
2159
+ await ensureDir(backupDir);
2160
+ const archivePath = join12(backupDir, filename);
2161
+ const files = [];
2162
+ for (const fp of filePaths) {
2163
+ const absPath = resolveTargetPath(fp);
2164
+ const exists = await fileExists(absPath);
2165
+ if (!exists) continue;
2166
+ const archiveName = fp.startsWith("~/") ? fp.slice(2) : fp;
2167
+ files.push({ name: archiveName, sourcePath: absPath });
2168
+ }
2169
+ if (files.length === 0) {
2170
+ logger.info("No existing files to safety-backup.");
2171
+ return archivePath;
2172
+ }
2173
+ await createArchive(files, archivePath);
2174
+ logger.info(`Safety backup created: ${archivePath}`);
2175
+ return archivePath;
2176
+ }
2177
+ async function restoreBackup(archivePath, options = {}) {
2178
+ const plan = await getRestorePlan(archivePath);
2179
+ const restoredFiles = [];
2180
+ const skippedFiles = [];
2181
+ const overwritePaths = plan.actions.filter((a) => a.action === "overwrite").map((a) => a.path);
2182
+ let safetyBackupPath;
2183
+ if (overwritePaths.length > 0 && !options.dryRun) {
2184
+ safetyBackupPath = await createSafetyBackup(overwritePaths);
2185
+ }
2186
+ if (options.dryRun) {
2187
+ return {
2188
+ restoredFiles: plan.actions.filter((a) => a.action !== "skip").map((a) => a.path),
2189
+ skippedFiles: plan.actions.filter((a) => a.action === "skip").map((a) => a.path),
2190
+ safetyBackupPath
2191
+ };
2192
+ }
2193
+ const { mkdtemp: mkdtemp2, rm: rm2 } = await import("fs/promises");
2194
+ const { tmpdir: tmpdir3 } = await import("os");
2195
+ const tmpDir = await mkdtemp2(join12(tmpdir3(), "syncpoint-restore-"));
2196
+ try {
2197
+ await extractArchive(archivePath, tmpDir);
2198
+ for (const action of plan.actions) {
2199
+ if (action.action === "skip") {
2200
+ skippedFiles.push(action.path);
2201
+ continue;
2202
+ }
2203
+ const archiveName = action.path.startsWith("~/") ? action.path.slice(2) : action.path;
2204
+ const extractedPath = join12(tmpDir, archiveName);
2205
+ const destPath = resolveTargetPath(action.path);
2206
+ const extractedExists = await fileExists(extractedPath);
2207
+ if (!extractedExists) {
2208
+ logger.warn(`File not found in archive: ${archiveName}`);
2209
+ skippedFiles.push(action.path);
2210
+ continue;
2211
+ }
2212
+ await ensureDir(dirname2(destPath));
2213
+ try {
2214
+ const destStat = await lstat2(destPath);
2215
+ if (destStat.isSymbolicLink()) {
2216
+ logger.warn(`Skipping symlink target: ${action.path}`);
2217
+ skippedFiles.push(action.path);
2218
+ continue;
2219
+ }
2220
+ } catch (err) {
2221
+ if (err.code !== "ENOENT") throw err;
2222
+ }
2223
+ await copyFile(extractedPath, destPath);
2224
+ restoredFiles.push(action.path);
2225
+ }
2226
+ } finally {
2227
+ await rm2(tmpDir, { recursive: true, force: true });
2228
+ }
2229
+ return { restoredFiles, skippedFiles, safetyBackupPath };
2230
+ }
2231
+
2232
+ // src/commands/List.tsx
2233
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
2234
+ var MenuItem = ({ isSelected = false, label }) => {
2235
+ if (label === "Delete") {
2236
+ return /* @__PURE__ */ jsx8(Text8, { bold: isSelected, color: "red", children: label });
2237
+ }
2238
+ const match = label.match(/^(.+?)\s+\((\d+)\)$/);
2239
+ if (match) {
2240
+ const [, name, count] = match;
2241
+ return /* @__PURE__ */ jsxs8(Text8, { children: [
2242
+ /* @__PURE__ */ jsx8(Text8, { bold: isSelected, children: name }),
2243
+ " ",
2244
+ /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2245
+ "(",
2246
+ count,
2247
+ ")"
2248
+ ] })
2249
+ ] });
2250
+ }
2251
+ return /* @__PURE__ */ jsx8(Text8, { bold: isSelected, children: label });
2252
+ };
2253
+ var ListView = ({ type, deleteIndex }) => {
2254
+ const { exit } = useApp4();
2255
+ const [phase, setPhase] = useState5("loading");
2256
+ const [backups, setBackups] = useState5([]);
2257
+ const [templates, setTemplates] = useState5([]);
2258
+ const [selectedTemplate, setSelectedTemplate] = useState5(
2259
+ null
2260
+ );
2261
+ const [selectedBackup, setSelectedBackup] = useState5(null);
2262
+ const [deleteTarget, setDeleteTarget] = useState5(null);
2263
+ const [error, setError] = useState5(null);
2264
+ const [backupDir, setBackupDir] = useState5(getSubDir("backups"));
2265
+ useInput2((_input, key) => {
2266
+ if (!key.escape) return;
2267
+ switch (phase) {
2268
+ case "main-menu":
2269
+ exit();
2270
+ break;
2271
+ case "backup-list":
2272
+ case "template-list":
2273
+ setSelectedBackup(null);
2274
+ setSelectedTemplate(null);
2275
+ setPhase("main-menu");
2276
+ break;
2277
+ case "backup-detail":
2278
+ setSelectedBackup(null);
2279
+ setPhase("backup-list");
2280
+ break;
2281
+ case "template-detail":
2282
+ setSelectedTemplate(null);
2283
+ setPhase("template-list");
2284
+ break;
2285
+ }
2286
+ });
2287
+ useEffect4(() => {
2288
+ (async () => {
2289
+ try {
2290
+ const config = await loadConfig();
2291
+ const backupDirectory = config.backup.destination ? resolveTargetPath(config.backup.destination) : getSubDir("backups");
2292
+ setBackupDir(backupDirectory);
2293
+ const showBackups = !type || type === "backups";
2294
+ const showTemplates = !type || type === "templates";
2295
+ if (showBackups) {
2296
+ const list2 = await getBackupList(config);
2297
+ setBackups(list2);
2298
+ }
2299
+ if (showTemplates) {
2300
+ const tmpls = await listTemplates();
2301
+ setTemplates(tmpls);
2302
+ }
2303
+ if (deleteIndex != null && type === "backups") {
2304
+ const list2 = await getBackupList(config);
2305
+ const idx = deleteIndex - 1;
2306
+ if (idx < 0 || idx >= list2.length) {
2307
+ setError(`Invalid index: ${deleteIndex}`);
2308
+ setPhase("error");
2309
+ setTimeout(() => exit(), 100);
2310
+ return;
2311
+ }
2312
+ setDeleteTarget({
2313
+ name: list2[idx].filename,
2314
+ path: list2[idx].path
2315
+ });
2316
+ setPhase("deleting");
2317
+ return;
2318
+ }
2319
+ setPhase("main-menu");
2320
+ } catch (err) {
2321
+ setError(err instanceof Error ? err.message : String(err));
2322
+ setPhase("error");
2323
+ setTimeout(() => exit(), 100);
2324
+ }
2325
+ })();
2326
+ }, []);
2327
+ const goBackToMainMenu = () => {
2328
+ setSelectedBackup(null);
2329
+ setSelectedTemplate(null);
2330
+ setPhase("main-menu");
2331
+ };
2332
+ const goBackToBackupList = () => {
2333
+ setSelectedBackup(null);
2334
+ setPhase("backup-list");
2335
+ };
2336
+ const goBackToTemplateList = () => {
2337
+ setSelectedTemplate(null);
2338
+ setPhase("template-list");
2339
+ };
2340
+ const handleDeleteConfirm = (yes) => {
2341
+ if (yes && deleteTarget) {
2342
+ try {
2343
+ if (!isInsideDir(deleteTarget.path, backupDir)) {
2344
+ throw new Error(`Refusing to delete file outside backups directory: ${deleteTarget.path}`);
2345
+ }
2346
+ unlinkSync(deleteTarget.path);
2347
+ setPhase("done");
2348
+ setTimeout(() => exit(), 100);
2349
+ } catch (err) {
2350
+ setError(err instanceof Error ? err.message : String(err));
2351
+ setPhase("error");
2352
+ setTimeout(() => exit(), 100);
2353
+ }
2354
+ } else {
2355
+ setDeleteTarget(null);
2356
+ if (selectedBackup) {
2357
+ setPhase("backup-detail");
2358
+ } else {
2359
+ goBackToMainMenu();
2360
+ }
2361
+ }
2362
+ };
2363
+ if (phase === "error" || error) {
2364
+ return /* @__PURE__ */ jsx8(Box6, { flexDirection: "column", children: /* @__PURE__ */ jsxs8(Text8, { color: "red", children: [
2365
+ "\u2717 ",
2366
+ error
2367
+ ] }) });
2368
+ }
2369
+ if (phase === "loading") {
2370
+ return /* @__PURE__ */ jsx8(Text8, { color: "cyan", children: "Loading..." });
2371
+ }
2372
+ if (phase === "deleting" && deleteTarget) {
2373
+ return /* @__PURE__ */ jsx8(Box6, { flexDirection: "column", children: /* @__PURE__ */ jsx8(
2374
+ Confirm,
2375
+ {
2376
+ message: `Delete ${deleteTarget.name}?`,
2377
+ onConfirm: handleDeleteConfirm,
2378
+ defaultYes: false
2379
+ }
2380
+ ) });
2381
+ }
2382
+ if (phase === "done" && deleteTarget) {
2383
+ return /* @__PURE__ */ jsxs8(Text8, { color: "green", children: [
2384
+ "\u2713 ",
2385
+ deleteTarget.name,
2386
+ " deleted"
2387
+ ] });
2388
+ }
2389
+ if (phase === "main-menu") {
2390
+ const showBackups = !type || type === "backups";
2391
+ const showTemplates = !type || type === "templates";
2392
+ const menuItems = [];
2393
+ if (showBackups) {
2394
+ menuItems.push({
2395
+ label: `Backups (${backups.length})`,
2396
+ value: "backups"
2397
+ });
2398
+ }
2399
+ if (showTemplates) {
2400
+ menuItems.push({
2401
+ label: `Templates (${templates.length})`,
2402
+ value: "templates"
2403
+ });
2404
+ }
2405
+ menuItems.push({ label: "Exit", value: "exit" });
2406
+ const handleMainMenu = (item) => {
2407
+ if (item.value === "exit") {
2408
+ exit();
2409
+ } else if (item.value === "backups") {
2410
+ setPhase("backup-list");
2411
+ } else if (item.value === "templates") {
2412
+ setPhase("template-list");
2413
+ }
2414
+ };
2415
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
2416
+ /* @__PURE__ */ jsx8(
2417
+ SelectInput,
2418
+ {
2419
+ items: menuItems,
2420
+ onSelect: handleMainMenu,
2421
+ itemComponent: MenuItem
2422
+ }
2423
+ ),
2424
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2425
+ "Press ",
2426
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "ESC" }),
2427
+ " to exit"
2428
+ ] }) })
2429
+ ] });
2430
+ }
2431
+ if (phase === "backup-list") {
2432
+ const items = backups.map((b) => ({
2433
+ label: `${b.filename.replace(".tar.gz", "")} \u2022 ${formatBytes(b.size)} \u2022 ${formatDate(b.createdAt)}`,
2434
+ value: b.path
2435
+ }));
2436
+ const handleBackupSelect = (item) => {
2437
+ const backup = backups.find((b) => b.path === item.value);
2438
+ if (backup) {
2439
+ setSelectedBackup(backup);
2440
+ setPhase("backup-detail");
2441
+ }
2442
+ };
2443
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
2444
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "\u25B8 Backups" }),
2445
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: backups.length === 0 ? /* @__PURE__ */ jsx8(Text8, { color: "gray", children: " No backups found." }) : /* @__PURE__ */ jsx8(
2446
+ SelectInput,
2447
+ {
2448
+ items,
2449
+ onSelect: handleBackupSelect,
2450
+ itemComponent: MenuItem
2451
+ }
2452
+ ) }),
2453
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2454
+ "Press ",
2455
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "ESC" }),
2456
+ " to go back"
2457
+ ] }) })
2458
+ ] });
2459
+ }
2460
+ if (phase === "template-list") {
2461
+ const items = templates.map((t) => ({
2462
+ label: t.config.description ? `${t.config.name} \u2014 ${t.config.description}` : t.config.name,
2463
+ value: t.path
2464
+ }));
2465
+ const handleTemplateSelect = (item) => {
2466
+ const tmpl = templates.find((t) => t.path === item.value);
2467
+ if (tmpl) {
2468
+ setSelectedTemplate(tmpl);
2469
+ setPhase("template-detail");
2470
+ }
1518
2471
  };
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)
2472
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
2473
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "\u25B8 Templates" }),
2474
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: templates.length === 0 ? /* @__PURE__ */ jsx8(Text8, { color: "gray", children: " No templates found." }) : /* @__PURE__ */ jsx8(
2475
+ SelectInput,
2476
+ {
2477
+ items,
2478
+ onSelect: handleTemplateSelect,
2479
+ itemComponent: MenuItem
2480
+ }
2481
+ ) }),
2482
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2483
+ "Press ",
2484
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "ESC" }),
2485
+ " to go back"
2486
+ ] }) })
2487
+ ] });
2488
+ }
2489
+ if (phase === "backup-detail" && selectedBackup) {
2490
+ const sections = [
2491
+ { label: "Filename", value: selectedBackup.filename },
2492
+ { label: "Date", value: formatDate(selectedBackup.createdAt) },
2493
+ { label: "Size", value: formatBytes(selectedBackup.size) },
2494
+ ...selectedBackup.hostname ? [{ label: "Hostname", value: selectedBackup.hostname }] : [],
2495
+ ...selectedBackup.fileCount != null ? [{ label: "Files", value: String(selectedBackup.fileCount) }] : []
2496
+ ];
2497
+ const actionItems = [
2498
+ { label: "Delete", value: "delete" },
2499
+ { label: "Cancel", value: "cancel" }
2500
+ ];
2501
+ const handleDetailAction = (item) => {
2502
+ if (item.value === "delete") {
2503
+ setDeleteTarget({
2504
+ name: selectedBackup.filename,
2505
+ path: selectedBackup.path
2506
+ });
2507
+ setPhase("deleting");
2508
+ } else if (item.value === "cancel") {
2509
+ goBackToBackupList();
2510
+ }
1527
2511
  };
2512
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
2513
+ /* @__PURE__ */ jsxs8(Text8, { bold: true, children: [
2514
+ "\u25B8 ",
2515
+ selectedBackup.filename.replace(".tar.gz", "")
2516
+ ] }),
2517
+ /* @__PURE__ */ jsx8(Box6, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: sections.map((section, idx) => {
2518
+ const labelWidth = Math.max(...sections.map((s) => s.label.length)) + 1;
2519
+ return /* @__PURE__ */ jsxs8(Box6, { children: [
2520
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: section.label.padEnd(labelWidth) }),
2521
+ /* @__PURE__ */ jsx8(Text8, { children: " " }),
2522
+ /* @__PURE__ */ jsx8(Text8, { children: section.value })
2523
+ ] }, idx);
2524
+ }) }),
2525
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx8(
2526
+ SelectInput,
2527
+ {
2528
+ items: actionItems,
2529
+ onSelect: handleDetailAction,
2530
+ itemComponent: MenuItem
2531
+ }
2532
+ ) }),
2533
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2534
+ "Press ",
2535
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "ESC" }),
2536
+ " to go back"
2537
+ ] }) })
2538
+ ] });
1528
2539
  }
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";
2540
+ if (phase === "template-detail" && selectedTemplate) {
2541
+ const t = selectedTemplate.config;
2542
+ const sections = [
2543
+ { label: "Name", value: t.name },
2544
+ ...t.description ? [{ label: "Description", value: t.description }] : [],
2545
+ ...t.backup ? [{ label: "Backup link", value: t.backup }] : [],
2546
+ { label: "Steps", value: String(t.steps.length) }
2547
+ ];
2548
+ const actionItems = [{ label: "Cancel", value: "cancel" }];
2549
+ const handleDetailAction = (item) => {
2550
+ if (item.value === "cancel") {
2551
+ goBackToTemplateList();
1538
2552
  }
1539
- yield {
1540
- name: step.name,
1541
- status
1542
- };
1543
- continue;
1544
- }
1545
- yield {
1546
- name: step.name,
1547
- status: "running"
1548
2553
  };
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
- }
2554
+ const labelWidth = Math.max(...sections.map((s) => s.label.length)) + 1;
2555
+ return /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", children: [
2556
+ /* @__PURE__ */ jsxs8(Text8, { bold: true, children: [
2557
+ "\u25B8 ",
2558
+ selectedTemplate.name
2559
+ ] }),
2560
+ /* @__PURE__ */ jsx8(Box6, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: sections.map((section, idx) => /* @__PURE__ */ jsxs8(Box6, { children: [
2561
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: section.label.padEnd(labelWidth) }),
2562
+ /* @__PURE__ */ jsx8(Text8, { children: " " }),
2563
+ /* @__PURE__ */ jsx8(Text8, { children: section.value })
2564
+ ] }, idx)) }),
2565
+ t.steps.length > 0 && /* @__PURE__ */ jsxs8(Box6, { flexDirection: "column", marginTop: 1, children: [
2566
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " Provisioning Steps" }),
2567
+ /* @__PURE__ */ jsx8(Box6, { marginLeft: 2, children: /* @__PURE__ */ jsx8(
2568
+ Table,
2569
+ {
2570
+ headers: ["#", "Step", "Description"],
2571
+ rows: t.steps.map((s, idx) => [
2572
+ String(idx + 1),
2573
+ s.name,
2574
+ s.description ?? "\u2014"
2575
+ ])
2576
+ }
2577
+ ) })
2578
+ ] }),
2579
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx8(
2580
+ SelectInput,
2581
+ {
2582
+ items: actionItems,
2583
+ onSelect: handleDetailAction,
2584
+ itemComponent: MenuItem
2585
+ }
2586
+ ) }),
2587
+ /* @__PURE__ */ jsx8(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
2588
+ "Press ",
2589
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "ESC" }),
2590
+ " to go back"
2591
+ ] }) })
2592
+ ] });
1557
2593
  }
2594
+ return null;
2595
+ };
2596
+ function registerListCommand(program2) {
2597
+ program2.command("list [type]").description("List backups and templates").option("--delete <n>", "Delete item #n").action(async (type, opts) => {
2598
+ const deleteIndex = opts.delete ? parseInt(opts.delete, 10) : void 0;
2599
+ const { waitUntilExit } = render5(
2600
+ /* @__PURE__ */ jsx8(ListView, { type, deleteIndex })
2601
+ );
2602
+ await waitUntilExit();
2603
+ });
1558
2604
  }
1559
2605
 
2606
+ // src/commands/Provision.tsx
2607
+ import { useState as useState6, useEffect as useEffect5 } from "react";
2608
+ import { Text as Text10, Box as Box8, useApp as useApp5 } from "ink";
2609
+ import { render as render6 } from "ink";
2610
+
1560
2611
  // src/utils/sudo.ts
1561
2612
  import { execSync } from "child_process";
1562
2613
  import pc2 from "picocolors";
@@ -1590,37 +2641,37 @@ ${pc2.red("\u2717")} Sudo authentication failed or was cancelled. Aborting.`
1590
2641
  }
1591
2642
 
1592
2643
  // 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";
2644
+ import { Text as Text9, Box as Box7 } from "ink";
2645
+ import Spinner2 from "ink-spinner";
2646
+ import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
1596
2647
  var StepIcon = ({ status }) => {
1597
2648
  switch (status) {
1598
2649
  case "success":
1599
- return /* @__PURE__ */ jsx6(Text6, { color: "green", children: "\u2713" });
2650
+ return /* @__PURE__ */ jsx9(Text9, { color: "green", children: "\u2713" });
1600
2651
  case "running":
1601
- return /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: /* @__PURE__ */ jsx6(Spinner, { type: "dots" }) });
2652
+ return /* @__PURE__ */ jsx9(Text9, { color: "yellow", children: /* @__PURE__ */ jsx9(Spinner2, { type: "dots" }) });
1602
2653
  case "skipped":
1603
- return /* @__PURE__ */ jsx6(Text6, { color: "blue", children: "\u23ED" });
2654
+ return /* @__PURE__ */ jsx9(Text9, { color: "blue", children: "\u23ED" });
1604
2655
  case "failed":
1605
- return /* @__PURE__ */ jsx6(Text6, { color: "red", children: "\u2717" });
2656
+ return /* @__PURE__ */ jsx9(Text9, { color: "red", children: "\u2717" });
1606
2657
  case "pending":
1607
2658
  default:
1608
- return /* @__PURE__ */ jsx6(Text6, { color: "gray", children: "\u25CB" });
2659
+ return /* @__PURE__ */ jsx9(Text9, { color: "gray", children: "\u25CB" });
1609
2660
  }
1610
2661
  };
1611
2662
  var StepStatusText = ({ step }) => {
1612
2663
  switch (step.status) {
1613
2664
  case "success":
1614
- return /* @__PURE__ */ jsxs6(Text6, { color: "green", children: [
2665
+ return /* @__PURE__ */ jsxs9(Text9, { color: "green", children: [
1615
2666
  "Done",
1616
2667
  step.duration != null ? ` (${Math.round(step.duration / 1e3)}s)` : ""
1617
2668
  ] });
1618
2669
  case "running":
1619
- return /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "Running..." });
2670
+ return /* @__PURE__ */ jsx9(Text9, { color: "yellow", children: "Running..." });
1620
2671
  case "skipped":
1621
- return /* @__PURE__ */ jsx6(Text6, { color: "blue", children: "Skipped (already installed)" });
2672
+ return /* @__PURE__ */ jsx9(Text9, { color: "blue", children: "Skipped (already installed)" });
1622
2673
  case "failed":
1623
- return /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
2674
+ return /* @__PURE__ */ jsxs9(Text9, { color: "red", children: [
1624
2675
  "Failed",
1625
2676
  step.error ? `: ${step.error}` : ""
1626
2677
  ] });
@@ -1633,13 +2684,13 @@ var StepRunner = ({
1633
2684
  steps,
1634
2685
  total
1635
2686
  }) => {
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: [
2687
+ return /* @__PURE__ */ jsx9(Box7, { flexDirection: "column", children: steps.map((step, idx) => /* @__PURE__ */ jsxs9(Box7, { flexDirection: "column", marginBottom: idx < steps.length - 1 ? 1 : 0, children: [
2688
+ /* @__PURE__ */ jsxs9(Text9, { children: [
1638
2689
  " ",
1639
- /* @__PURE__ */ jsx6(StepIcon, { status: step.status }),
1640
- /* @__PURE__ */ jsxs6(Text6, { children: [
2690
+ /* @__PURE__ */ jsx9(StepIcon, { status: step.status }),
2691
+ /* @__PURE__ */ jsxs9(Text9, { children: [
1641
2692
  " ",
1642
- /* @__PURE__ */ jsxs6(Text6, { bold: true, children: [
2693
+ /* @__PURE__ */ jsxs9(Text9, { bold: true, children: [
1643
2694
  "Step ",
1644
2695
  idx + 1,
1645
2696
  "/",
@@ -1649,36 +2700,36 @@ var StepRunner = ({
1649
2700
  step.name
1650
2701
  ] })
1651
2702
  ] }),
1652
- step.output && step.status !== "pending" && /* @__PURE__ */ jsxs6(Text6, { color: "gray", children: [
2703
+ step.output && step.status !== "pending" && /* @__PURE__ */ jsxs9(Text9, { color: "gray", children: [
1653
2704
  " ",
1654
2705
  step.output
1655
2706
  ] }),
1656
- /* @__PURE__ */ jsxs6(Text6, { children: [
2707
+ /* @__PURE__ */ jsxs9(Text9, { children: [
1657
2708
  " ",
1658
- /* @__PURE__ */ jsx6(StepStatusText, { step })
2709
+ /* @__PURE__ */ jsx9(StepStatusText, { step })
1659
2710
  ] })
1660
2711
  ] }, idx)) });
1661
2712
  };
1662
2713
 
1663
2714
  // src/commands/Provision.tsx
1664
- import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
2715
+ import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
1665
2716
  var ProvisionView = ({
1666
2717
  template,
1667
2718
  templatePath,
1668
2719
  options
1669
2720
  }) => {
1670
- const { exit } = useApp4();
1671
- const [phase, setPhase] = useState5(options.dryRun ? "done" : "running");
1672
- const [steps, setSteps] = useState5(
2721
+ const { exit } = useApp5();
2722
+ const [phase, setPhase] = useState6(options.dryRun ? "done" : "running");
2723
+ const [steps, setSteps] = useState6(
1673
2724
  template.steps.map((s) => ({
1674
2725
  name: s.name,
1675
2726
  status: "pending",
1676
2727
  output: s.description
1677
2728
  }))
1678
2729
  );
1679
- const [currentStep, setCurrentStep] = useState5(0);
1680
- const [error, setError] = useState5(null);
1681
- useEffect4(() => {
2730
+ const [currentStep, setCurrentStep] = useState6(0);
2731
+ const [error, setError] = useState6(null);
2732
+ useEffect5(() => {
1682
2733
  if (options.dryRun) {
1683
2734
  setTimeout(() => exit(), 100);
1684
2735
  return;
@@ -1708,7 +2759,7 @@ var ProvisionView = ({
1708
2759
  })();
1709
2760
  }, []);
1710
2761
  if (phase === "error" || error) {
1711
- return /* @__PURE__ */ jsx7(Box5, { flexDirection: "column", children: /* @__PURE__ */ jsxs7(Text7, { color: "red", children: [
2762
+ return /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", children: /* @__PURE__ */ jsxs10(Text10, { color: "red", children: [
1712
2763
  "\u2717 ",
1713
2764
  error
1714
2765
  ] }) });
@@ -1716,27 +2767,27 @@ var ProvisionView = ({
1716
2767
  const successCount = steps.filter((s) => s.status === "success").length;
1717
2768
  const skippedCount = steps.filter((s) => s.status === "skipped").length;
1718
2769
  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: [
2770
+ return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2771
+ /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginBottom: 1, children: [
2772
+ /* @__PURE__ */ jsxs10(Text10, { bold: true, children: [
1722
2773
  "\u25B8 ",
1723
2774
  template.name
1724
2775
  ] }),
1725
- template.description && /* @__PURE__ */ jsxs7(Text7, { color: "gray", children: [
2776
+ template.description && /* @__PURE__ */ jsxs10(Text10, { color: "gray", children: [
1726
2777
  " ",
1727
2778
  template.description
1728
2779
  ] })
1729
2780
  ] }),
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: [
2781
+ options.dryRun && phase === "done" && /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2782
+ /* @__PURE__ */ jsx10(Text10, { color: "yellow", children: "(dry-run) Showing execution plan only" }),
2783
+ template.sudo && /* @__PURE__ */ jsxs10(Text10, { color: "yellow", children: [
1733
2784
  " ",
1734
2785
  "\u26A0 This template requires sudo privileges (will prompt on actual run)"
1735
2786
  ] }),
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: [
2787
+ /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", marginTop: 1, children: template.steps.map((step, idx) => /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginBottom: 1, children: [
2788
+ /* @__PURE__ */ jsxs10(Text10, { children: [
1738
2789
  " ",
1739
- /* @__PURE__ */ jsxs7(Text7, { bold: true, children: [
2790
+ /* @__PURE__ */ jsxs10(Text10, { bold: true, children: [
1740
2791
  "Step ",
1741
2792
  idx + 1,
1742
2793
  "/",
@@ -1745,18 +2796,18 @@ var ProvisionView = ({
1745
2796
  " ",
1746
2797
  step.name
1747
2798
  ] }),
1748
- step.description && /* @__PURE__ */ jsxs7(Text7, { color: "gray", children: [
2799
+ step.description && /* @__PURE__ */ jsxs10(Text10, { color: "gray", children: [
1749
2800
  " ",
1750
2801
  step.description
1751
2802
  ] }),
1752
- step.skip_if && /* @__PURE__ */ jsxs7(Text7, { color: "blue", children: [
2803
+ step.skip_if && /* @__PURE__ */ jsxs10(Text10, { color: "blue", children: [
1753
2804
  " ",
1754
2805
  "Skip condition: ",
1755
2806
  step.skip_if
1756
2807
  ] })
1757
2808
  ] }, idx)) })
1758
2809
  ] }),
1759
- (phase === "running" || phase === "done" && !options.dryRun) && /* @__PURE__ */ jsx7(
2810
+ (phase === "running" || phase === "done" && !options.dryRun) && /* @__PURE__ */ jsx10(
1760
2811
  StepRunner,
1761
2812
  {
1762
2813
  steps,
@@ -1764,34 +2815,34 @@ var ProvisionView = ({
1764
2815
  total: template.steps.length
1765
2816
  }
1766
2817
  ),
1767
- phase === "done" && !options.dryRun && /* @__PURE__ */ jsxs7(Box5, { flexDirection: "column", marginTop: 1, children: [
1768
- /* @__PURE__ */ jsxs7(Text7, { color: "gray", children: [
2818
+ phase === "done" && !options.dryRun && /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginTop: 1, children: [
2819
+ /* @__PURE__ */ jsxs10(Text10, { color: "gray", children: [
1769
2820
  " ",
1770
2821
  "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
1771
2822
  ] }),
1772
- /* @__PURE__ */ jsxs7(Text7, { children: [
2823
+ /* @__PURE__ */ jsxs10(Text10, { children: [
1773
2824
  " ",
1774
2825
  "Result: ",
1775
- /* @__PURE__ */ jsxs7(Text7, { color: "green", children: [
2826
+ /* @__PURE__ */ jsxs10(Text10, { color: "green", children: [
1776
2827
  successCount,
1777
2828
  " succeeded"
1778
2829
  ] }),
1779
2830
  " \xB7",
1780
2831
  " ",
1781
- /* @__PURE__ */ jsxs7(Text7, { color: "blue", children: [
2832
+ /* @__PURE__ */ jsxs10(Text10, { color: "blue", children: [
1782
2833
  skippedCount,
1783
2834
  " skipped"
1784
2835
  ] }),
1785
2836
  " \xB7",
1786
2837
  " ",
1787
- /* @__PURE__ */ jsxs7(Text7, { color: "red", children: [
2838
+ /* @__PURE__ */ jsxs10(Text10, { color: "red", children: [
1788
2839
  failedCount,
1789
2840
  " failed"
1790
2841
  ] })
1791
2842
  ] }),
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: [
2843
+ template.backup && !options.skipRestore && /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginTop: 1, children: [
2844
+ /* @__PURE__ */ jsx10(Text10, { bold: true, children: "\u25B8 Proceeding with config file restore..." }),
2845
+ /* @__PURE__ */ jsxs10(Text10, { color: "gray", children: [
1795
2846
  " ",
1796
2847
  "Backup link: ",
1797
2848
  template.backup
@@ -1815,8 +2866,8 @@ function registerProvisionCommand(program2) {
1815
2866
  if (tmpl.sudo && !opts.dryRun) {
1816
2867
  ensureSudo(tmpl.name);
1817
2868
  }
1818
- const { waitUntilExit } = render4(
1819
- /* @__PURE__ */ jsx7(
2869
+ const { waitUntilExit } = render6(
2870
+ /* @__PURE__ */ jsx10(
1820
2871
  ProvisionView,
1821
2872
  {
1822
2873
  template: tmpl,
@@ -1833,410 +2884,230 @@ function registerProvisionCommand(program2) {
1833
2884
  );
1834
2885
  }
1835
2886
 
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";
2887
+ // src/commands/Restore.tsx
2888
+ import { useState as useState7, useEffect as useEffect6 } from "react";
2889
+ import { Text as Text11, Box as Box9, useApp as useApp6 } from "ink";
1840
2890
  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);
2891
+ import { render as render7 } from "ink";
2892
+ import { jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
2893
+ var RestoreView = ({ filename, options }) => {
2894
+ const { exit } = useApp6();
2895
+ const [phase, setPhase] = useState7("loading");
2896
+ const [backups, setBackups] = useState7([]);
2897
+ const [selectedPath, setSelectedPath] = useState7(null);
2898
+ const [plan, setPlan] = useState7(null);
2899
+ const [result, setResult] = useState7(null);
2900
+ const [safetyDone, setSafetyDone] = useState7(false);
2901
+ const [error, setError] = useState7(null);
2902
+ useEffect6(() => {
2903
+ (async () => {
2904
+ try {
2905
+ const list2 = await getBackupList();
2906
+ setBackups(list2);
2907
+ if (list2.length === 0) {
2908
+ setError("No backups available.");
2909
+ setPhase("error");
2910
+ exit();
2911
+ return;
1941
2912
  }
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}`);
2913
+ if (filename) {
2914
+ const match = list2.find(
2915
+ (b) => b.filename === filename || b.filename.startsWith(filename)
2916
+ );
2917
+ if (!match) {
2918
+ setError(`Backup not found: ${filename}`);
1947
2919
  setPhase("error");
1948
- setTimeout(() => exit(), 100);
2920
+ exit();
1949
2921
  return;
1950
2922
  }
1951
- setDeleteTarget({
1952
- name: list2[idx].filename,
1953
- path: list2[idx].path
1954
- });
1955
- setPhase("deleting");
1956
- return;
2923
+ setSelectedPath(match.path);
2924
+ setPhase("planning");
2925
+ } else {
2926
+ setPhase("selecting");
1957
2927
  }
1958
- setPhase("main-menu");
1959
2928
  } catch (err) {
1960
2929
  setError(err instanceof Error ? err.message : String(err));
1961
2930
  setPhase("error");
1962
- setTimeout(() => exit(), 100);
2931
+ exit();
1963
2932
  }
1964
2933
  })();
1965
2934
  }, []);
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) {
2935
+ useEffect6(() => {
2936
+ if (phase !== "planning" || !selectedPath) return;
2937
+ (async () => {
1981
2938
  try {
1982
- if (!isInsideDir(deleteTarget.path, getSubDir("backups"))) {
1983
- throw new Error(`Refusing to delete file outside backups directory: ${deleteTarget.path}`);
2939
+ const restorePlan = await getRestorePlan(selectedPath);
2940
+ setPlan(restorePlan);
2941
+ if (options.dryRun) {
2942
+ setPhase("done");
2943
+ setTimeout(() => exit(), 100);
2944
+ } else {
2945
+ setPhase("confirming");
1984
2946
  }
1985
- unlinkSync(deleteTarget.path);
1986
- setPhase("done");
1987
- setTimeout(() => exit(), 100);
1988
2947
  } catch (err) {
1989
2948
  setError(err instanceof Error ? err.message : String(err));
1990
2949
  setPhase("error");
1991
- setTimeout(() => exit(), 100);
2950
+ exit();
1992
2951
  }
1993
- } else {
1994
- setDeleteTarget(null);
1995
- if (selectedBackup) {
1996
- setPhase("backup-detail");
1997
- } else {
1998
- goBackToMainMenu();
2952
+ })();
2953
+ }, [phase, selectedPath]);
2954
+ const handleSelect = (item) => {
2955
+ setSelectedPath(item.value);
2956
+ setPhase("planning");
2957
+ };
2958
+ const handleConfirm = async (yes) => {
2959
+ if (!yes || !selectedPath) {
2960
+ setPhase("done");
2961
+ setTimeout(() => exit(), 100);
2962
+ return;
2963
+ }
2964
+ try {
2965
+ setPhase("restoring");
2966
+ try {
2967
+ const config = await loadConfig();
2968
+ await createBackup(config, { tag: "pre-restore" });
2969
+ setSafetyDone(true);
2970
+ } catch {
1999
2971
  }
2972
+ const restoreResult = await restoreBackup(selectedPath, options);
2973
+ setResult(restoreResult);
2974
+ setPhase("done");
2975
+ setTimeout(() => exit(), 100);
2976
+ } catch (err) {
2977
+ setError(err instanceof Error ? err.message : String(err));
2978
+ setPhase("error");
2979
+ exit();
2000
2980
  }
2001
2981
  };
2002
2982
  if (phase === "error" || error) {
2003
- return /* @__PURE__ */ jsx9(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsxs9(Text9, { color: "red", children: [
2983
+ return /* @__PURE__ */ jsx11(Box9, { flexDirection: "column", children: /* @__PURE__ */ jsxs11(Text11, { color: "red", children: [
2004
2984
  "\u2717 ",
2005
2985
  error
2006
2986
  ] }) });
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
2987
+ }
2988
+ const selectItems = backups.map((b, idx) => ({
2989
+ label: `${String(idx + 1).padStart(2)} ${b.filename.replace(".tar.gz", "").padEnd(40)} ${formatBytes(b.size).padStart(8)} ${formatDate(b.createdAt)}`,
2990
+ value: b.path
2991
+ }));
2992
+ const currentHostname = getHostname();
2993
+ const isRemoteBackup = plan?.metadata.hostname && plan.metadata.hostname !== currentHostname;
2994
+ return /* @__PURE__ */ jsxs11(Box9, { flexDirection: "column", children: [
2995
+ phase === "loading" && /* @__PURE__ */ jsx11(Text11, { children: "\u25B8 Loading backup list..." }),
2996
+ phase === "selecting" && /* @__PURE__ */ jsxs11(Box9, { flexDirection: "column", children: [
2997
+ /* @__PURE__ */ jsx11(Text11, { bold: true, children: "\u25B8 Select backup" }),
2998
+ /* @__PURE__ */ jsx11(SelectInput2, { items: selectItems, onSelect: handleSelect })
2999
+ ] }),
3000
+ (phase === "planning" || phase === "confirming" || phase === "restoring" || phase === "done" && plan) && plan && /* @__PURE__ */ jsxs11(Box9, { flexDirection: "column", children: [
3001
+ /* @__PURE__ */ jsxs11(Box9, { flexDirection: "column", marginBottom: 1, children: [
3002
+ /* @__PURE__ */ jsxs11(Text11, { bold: true, children: [
3003
+ "\u25B8 Metadata (",
3004
+ plan.metadata.config.filename ?? "",
3005
+ ")"
3006
+ ] }),
3007
+ /* @__PURE__ */ jsxs11(Text11, { children: [
3008
+ " ",
3009
+ "Host: ",
3010
+ plan.metadata.hostname
3011
+ ] }),
3012
+ /* @__PURE__ */ jsxs11(Text11, { children: [
3013
+ " ",
3014
+ "Created: ",
3015
+ plan.metadata.createdAt
3016
+ ] }),
3017
+ /* @__PURE__ */ jsxs11(Text11, { children: [
3018
+ " ",
3019
+ "Files: ",
3020
+ plan.metadata.summary.fileCount,
3021
+ " (",
3022
+ formatBytes(plan.metadata.summary.totalSize),
3023
+ ")"
3024
+ ] }),
3025
+ isRemoteBackup && /* @__PURE__ */ jsxs11(Text11, { color: "yellow", children: [
3026
+ " ",
3027
+ "\u26A0 This backup was created on a different machine (",
3028
+ plan.metadata.hostname,
3029
+ ")"
3030
+ ] })
2198
3031
  ] }),
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
- ])
3032
+ /* @__PURE__ */ jsxs11(Box9, { flexDirection: "column", marginBottom: 1, children: [
3033
+ /* @__PURE__ */ jsx11(Text11, { bold: true, children: "\u25B8 Restore plan:" }),
3034
+ plan.actions.map((action, idx) => {
3035
+ let icon;
3036
+ let color;
3037
+ let label;
3038
+ switch (action.action) {
3039
+ case "overwrite":
3040
+ icon = "Overwrite";
3041
+ color = "yellow";
3042
+ label = `(${formatBytes(action.currentSize ?? 0)} \u2192 ${formatBytes(action.backupSize ?? 0)}, ${action.reason})`;
3043
+ break;
3044
+ case "skip":
3045
+ icon = "Skip";
3046
+ color = "gray";
3047
+ label = `(${action.reason})`;
3048
+ break;
3049
+ case "create":
3050
+ icon = "Create";
3051
+ color = "green";
3052
+ label = "(not present)";
3053
+ break;
2215
3054
  }
2216
- ) })
3055
+ return /* @__PURE__ */ jsxs11(Text11, { children: [
3056
+ " ",
3057
+ /* @__PURE__ */ jsx11(Text11, { color, children: icon.padEnd(8) }),
3058
+ " ",
3059
+ contractTilde(action.path),
3060
+ " ",
3061
+ /* @__PURE__ */ jsx11(Text11, { color: "gray", children: label })
3062
+ ] }, idx);
3063
+ })
3064
+ ] }),
3065
+ options.dryRun && phase === "done" && /* @__PURE__ */ jsx11(Text11, { color: "yellow", children: "(dry-run) No actual restore was performed" })
3066
+ ] }),
3067
+ phase === "confirming" && /* @__PURE__ */ jsx11(Box9, { flexDirection: "column", children: /* @__PURE__ */ jsx11(Confirm, { message: "Proceed with restore?", onConfirm: handleConfirm }) }),
3068
+ phase === "restoring" && /* @__PURE__ */ jsxs11(Box9, { flexDirection: "column", children: [
3069
+ safetyDone && /* @__PURE__ */ jsxs11(Text11, { children: [
3070
+ /* @__PURE__ */ jsx11(Text11, { color: "green", children: "\u2713" }),
3071
+ " Safety backup of current files complete"
3072
+ ] }),
3073
+ /* @__PURE__ */ jsx11(Text11, { children: "\u25B8 Restoring..." })
3074
+ ] }),
3075
+ phase === "done" && result && !options.dryRun && /* @__PURE__ */ jsxs11(Box9, { flexDirection: "column", marginTop: 1, children: [
3076
+ safetyDone && /* @__PURE__ */ jsxs11(Text11, { children: [
3077
+ /* @__PURE__ */ jsx11(Text11, { color: "green", children: "\u2713" }),
3078
+ " Safety backup of current files complete"
3079
+ ] }),
3080
+ /* @__PURE__ */ jsx11(Text11, { color: "green", bold: true, children: "\u2713 Restore complete" }),
3081
+ /* @__PURE__ */ jsxs11(Text11, { children: [
3082
+ " ",
3083
+ "Restored: ",
3084
+ result.restoredFiles.length,
3085
+ " files"
3086
+ ] }),
3087
+ /* @__PURE__ */ jsxs11(Text11, { children: [
3088
+ " ",
3089
+ "Skipped: ",
3090
+ result.skippedFiles.length,
3091
+ " files"
2217
3092
  ] }),
2218
- /* @__PURE__ */ jsx9(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx9(
2219
- SelectInput2,
3093
+ result.safetyBackupPath && /* @__PURE__ */ jsxs11(Text11, { children: [
3094
+ " ",
3095
+ "Safety backup: ",
3096
+ contractTilde(result.safetyBackupPath)
3097
+ ] })
3098
+ ] })
3099
+ ] });
3100
+ };
3101
+ function registerRestoreCommand(program2) {
3102
+ 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) => {
3103
+ const { waitUntilExit } = render7(
3104
+ /* @__PURE__ */ jsx11(
3105
+ RestoreView,
2220
3106
  {
2221
- items: actionItems,
2222
- onSelect: handleDetailAction,
2223
- itemComponent: MenuItem
3107
+ filename,
3108
+ options: { dryRun: opts.dryRun }
2224
3109
  }
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 })
3110
+ )
2240
3111
  );
2241
3112
  await waitUntilExit();
2242
3113
  });
@@ -2244,12 +3115,12 @@ function registerListCommand(program2) {
2244
3115
 
2245
3116
  // src/commands/Status.tsx
2246
3117
  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";
3118
+ import { join as join13 } from "path";
3119
+ import { Box as Box10, Text as Text12, useApp as useApp7, useInput as useInput3 } from "ink";
3120
+ import { render as render8 } from "ink";
2250
3121
  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";
3122
+ import { useEffect as useEffect7, useState as useState8 } from "react";
3123
+ import { jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
2253
3124
  function getDirStats(dirPath) {
2254
3125
  try {
2255
3126
  const entries = readdirSync(dirPath);
@@ -2257,9 +3128,9 @@ function getDirStats(dirPath) {
2257
3128
  let count = 0;
2258
3129
  for (const entry of entries) {
2259
3130
  try {
2260
- const stat3 = statSync(join11(dirPath, entry));
2261
- if (stat3.isFile()) {
2262
- totalSize += stat3.size;
3131
+ const stat4 = statSync(join13(dirPath, entry));
3132
+ if (stat4.isFile()) {
3133
+ totalSize += stat4.size;
2263
3134
  count++;
2264
3135
  }
2265
3136
  } catch {
@@ -2271,33 +3142,34 @@ function getDirStats(dirPath) {
2271
3142
  }
2272
3143
  }
2273
3144
  var DisplayActionItem = ({ isSelected = false, label }) => {
2274
- return /* @__PURE__ */ jsx10(Text10, { bold: isSelected, children: label });
3145
+ return /* @__PURE__ */ jsx12(Text12, { bold: isSelected, children: label });
2275
3146
  };
2276
3147
  var CleanupActionItem = ({ isSelected = false, label }) => {
2277
3148
  if (label === "Cancel" || label === "Select specific backups to delete") {
2278
- return /* @__PURE__ */ jsx10(Text10, { bold: isSelected, children: label });
3149
+ return /* @__PURE__ */ jsx12(Text12, { bold: isSelected, children: label });
2279
3150
  }
2280
3151
  const parts = label.split(/\s{2,}/);
2281
3152
  if (parts.length === 2) {
2282
- return /* @__PURE__ */ jsxs10(Text10, { bold: isSelected, children: [
3153
+ return /* @__PURE__ */ jsxs12(Text12, { bold: isSelected, children: [
2283
3154
  parts[0],
2284
3155
  " ",
2285
- /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: parts[1] })
3156
+ /* @__PURE__ */ jsx12(Text12, { dimColor: true, children: parts[1] })
2286
3157
  ] });
2287
3158
  }
2288
- return /* @__PURE__ */ jsx10(Text10, { bold: isSelected, children: label });
3159
+ return /* @__PURE__ */ jsx12(Text12, { bold: isSelected, children: label });
2289
3160
  };
2290
3161
  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(
3162
+ const { exit } = useApp7();
3163
+ const [phase, setPhase] = useState8("loading");
3164
+ const [status, setStatus] = useState8(null);
3165
+ const [backups, setBackups] = useState8([]);
3166
+ const [cleanupAction, setCleanupAction] = useState8(null);
3167
+ const [cleanupMessage, setCleanupMessage] = useState8("");
3168
+ const [error, setError] = useState8(null);
3169
+ const [selectedForDeletion, setSelectedForDeletion] = useState8(
2299
3170
  []
2300
3171
  );
3172
+ const [backupDir, setBackupDir] = useState8(getSubDir("backups"));
2301
3173
  useInput3((_input, key) => {
2302
3174
  if (!key.escape) return;
2303
3175
  if (phase === "display") {
@@ -2309,14 +3181,17 @@ var StatusView = ({ cleanup }) => {
2309
3181
  setPhase("cleanup");
2310
3182
  }
2311
3183
  });
2312
- useEffect6(() => {
3184
+ useEffect7(() => {
2313
3185
  (async () => {
2314
3186
  try {
2315
- const backupStats = getDirStats(getSubDir("backups"));
3187
+ const config = await loadConfig();
3188
+ const backupDirectory = config.backup.destination ? resolveTargetPath(config.backup.destination) : getSubDir("backups");
3189
+ setBackupDir(backupDirectory);
3190
+ const backupStats = getDirStats(backupDirectory);
2316
3191
  const templateStats = getDirStats(getSubDir("templates"));
2317
3192
  const scriptStats = getDirStats(getSubDir("scripts"));
2318
3193
  const logStats = getDirStats(getSubDir("logs"));
2319
- const backupList = await getBackupList();
3194
+ const backupList = await getBackupList(config);
2320
3195
  setBackups(backupList);
2321
3196
  const lastBackup = backupList.length > 0 ? backupList[0].createdAt : void 0;
2322
3197
  const oldestBackup = backupList.length > 0 ? backupList[backupList.length - 1].createdAt : void 0;
@@ -2380,11 +3255,10 @@ var StatusView = ({ cleanup }) => {
2380
3255
  return;
2381
3256
  }
2382
3257
  try {
2383
- const backupsDir = getSubDir("backups");
2384
3258
  if (cleanupAction === "keep-recent-5") {
2385
3259
  const toDelete = backups.slice(5);
2386
3260
  for (const b of toDelete) {
2387
- if (!isInsideDir(b.path, backupsDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
3261
+ if (!isInsideDir(b.path, backupDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
2388
3262
  unlinkSync2(b.path);
2389
3263
  }
2390
3264
  } else if (cleanupAction === "older-than-30") {
@@ -2392,7 +3266,7 @@ var StatusView = ({ cleanup }) => {
2392
3266
  cutoff.setDate(cutoff.getDate() - 30);
2393
3267
  const toDelete = backups.filter((b) => b.createdAt < cutoff);
2394
3268
  for (const b of toDelete) {
2395
- if (!isInsideDir(b.path, backupsDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
3269
+ if (!isInsideDir(b.path, backupDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
2396
3270
  unlinkSync2(b.path);
2397
3271
  }
2398
3272
  } else if (cleanupAction === "delete-logs") {
@@ -2400,7 +3274,7 @@ var StatusView = ({ cleanup }) => {
2400
3274
  try {
2401
3275
  const entries = readdirSync(logsDir);
2402
3276
  for (const entry of entries) {
2403
- const logPath = join11(logsDir, entry);
3277
+ const logPath = join13(logsDir, entry);
2404
3278
  if (!isInsideDir(logPath, logsDir)) throw new Error(`Refusing to delete file outside logs directory: ${logPath}`);
2405
3279
  unlinkSync2(logPath);
2406
3280
  }
@@ -2408,7 +3282,7 @@ var StatusView = ({ cleanup }) => {
2408
3282
  }
2409
3283
  } else if (cleanupAction === "select-specific") {
2410
3284
  for (const b of selectedForDeletion) {
2411
- if (!isInsideDir(b.path, backupsDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
3285
+ if (!isInsideDir(b.path, backupDir)) throw new Error(`Refusing to delete file outside backups directory: ${b.path}`);
2412
3286
  unlinkSync2(b.path);
2413
3287
  }
2414
3288
  }
@@ -2439,26 +3313,26 @@ var StatusView = ({ cleanup }) => {
2439
3313
  }
2440
3314
  };
2441
3315
  if (phase === "error" || error) {
2442
- return /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", children: /* @__PURE__ */ jsxs10(Text10, { color: "red", children: [
3316
+ return /* @__PURE__ */ jsx12(Box10, { flexDirection: "column", children: /* @__PURE__ */ jsxs12(Text12, { color: "red", children: [
2443
3317
  "\u2717 ",
2444
3318
  error
2445
3319
  ] }) });
2446
3320
  }
2447
3321
  if (phase === "loading") {
2448
- return /* @__PURE__ */ jsx10(Text10, { color: "cyan", children: "Loading..." });
3322
+ return /* @__PURE__ */ jsx12(Text12, { color: "cyan", children: "Loading..." });
2449
3323
  }
2450
3324
  if (!status) return null;
2451
3325
  const totalCount = status.backups.count + status.templates.count + status.scripts.count + status.logs.count;
2452
3326
  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: [
3327
+ const statusDisplay = /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", children: [
3328
+ /* @__PURE__ */ jsxs12(Text12, { bold: true, children: [
2455
3329
  "\u25B8 ",
2456
3330
  APP_NAME,
2457
3331
  " status \u2014 ~/.",
2458
3332
  APP_NAME,
2459
3333
  "/"
2460
3334
  ] }),
2461
- /* @__PURE__ */ jsx10(Box8, { marginLeft: 2, marginTop: 1, children: /* @__PURE__ */ jsx10(
3335
+ /* @__PURE__ */ jsx12(Box10, { marginLeft: 2, marginTop: 1, children: /* @__PURE__ */ jsx12(
2462
3336
  Table,
2463
3337
  {
2464
3338
  headers: ["Directory", "Count", "Size"],
@@ -2487,15 +3361,15 @@ var StatusView = ({ cleanup }) => {
2487
3361
  ]
2488
3362
  }
2489
3363
  ) }),
2490
- status.lastBackup && /* @__PURE__ */ jsxs10(Box8, { marginTop: 1, marginLeft: 2, flexDirection: "column", children: [
2491
- /* @__PURE__ */ jsxs10(Text10, { children: [
3364
+ status.lastBackup && /* @__PURE__ */ jsxs12(Box10, { marginTop: 1, marginLeft: 2, flexDirection: "column", children: [
3365
+ /* @__PURE__ */ jsxs12(Text12, { children: [
2492
3366
  "Latest backup: ",
2493
3367
  formatDate(status.lastBackup),
2494
3368
  " (",
2495
3369
  formatRelativeTime(status.lastBackup),
2496
3370
  ")"
2497
3371
  ] }),
2498
- status.oldestBackup && /* @__PURE__ */ jsxs10(Text10, { children: [
3372
+ status.oldestBackup && /* @__PURE__ */ jsxs12(Text12, { children: [
2499
3373
  "Oldest backup: ",
2500
3374
  formatDate(status.oldestBackup),
2501
3375
  " (",
@@ -2504,18 +3378,18 @@ var StatusView = ({ cleanup }) => {
2504
3378
  ] })
2505
3379
  ] })
2506
3380
  ] });
2507
- const escHint = (action) => /* @__PURE__ */ jsx10(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text10, { dimColor: true, children: [
3381
+ const escHint = (action) => /* @__PURE__ */ jsx12(Box10, { marginTop: 1, children: /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
2508
3382
  "Press ",
2509
- /* @__PURE__ */ jsx10(Text10, { bold: true, children: "ESC" }),
3383
+ /* @__PURE__ */ jsx12(Text12, { bold: true, children: "ESC" }),
2510
3384
  " to ",
2511
3385
  action
2512
3386
  ] }) });
2513
3387
  if (phase === "display") {
2514
- return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
3388
+ return /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", children: [
2515
3389
  statusDisplay,
2516
- /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginTop: 1, children: [
2517
- /* @__PURE__ */ jsx10(Text10, { bold: true, children: "\u25B8 Actions" }),
2518
- /* @__PURE__ */ jsx10(
3390
+ /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", marginTop: 1, children: [
3391
+ /* @__PURE__ */ jsx12(Text12, { bold: true, children: "\u25B8 Actions" }),
3392
+ /* @__PURE__ */ jsx12(
2519
3393
  SelectInput3,
2520
3394
  {
2521
3395
  items: [
@@ -2563,11 +3437,11 @@ var StatusView = ({ cleanup }) => {
2563
3437
  value: "cancel"
2564
3438
  }
2565
3439
  ];
2566
- return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
3440
+ return /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", children: [
2567
3441
  statusDisplay,
2568
- /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", marginTop: 1, children: [
2569
- /* @__PURE__ */ jsx10(Text10, { bold: true, children: "\u25B8 Cleanup options" }),
2570
- /* @__PURE__ */ jsx10(
3442
+ /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", marginTop: 1, children: [
3443
+ /* @__PURE__ */ jsx12(Text12, { bold: true, children: "\u25B8 Cleanup options" }),
3444
+ /* @__PURE__ */ jsx12(
2571
3445
  SelectInput3,
2572
3446
  {
2573
3447
  items: cleanupItems,
@@ -2593,26 +3467,26 @@ var StatusView = ({ cleanup }) => {
2593
3467
  value: "done"
2594
3468
  }
2595
3469
  ];
2596
- return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
3470
+ return /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", children: [
2597
3471
  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: [
3472
+ /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", marginTop: 1, children: [
3473
+ /* @__PURE__ */ jsx12(Text12, { bold: true, children: "\u25B8 Select backups to delete" }),
3474
+ selectedForDeletion.length > 0 && /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
2601
3475
  " ",
2602
3476
  selectedForDeletion.length,
2603
3477
  " backup(s) selected (",
2604
3478
  formatBytes(selectedForDeletion.reduce((s, b) => s + b.size, 0)),
2605
3479
  ")"
2606
3480
  ] }),
2607
- /* @__PURE__ */ jsx10(SelectInput3, { items: selectItems, onSelect: handleSelectBackup })
3481
+ /* @__PURE__ */ jsx12(SelectInput3, { items: selectItems, onSelect: handleSelectBackup })
2608
3482
  ] }),
2609
3483
  escHint("go back")
2610
3484
  ] });
2611
3485
  }
2612
3486
  if (phase === "confirming") {
2613
- return /* @__PURE__ */ jsxs10(Box8, { flexDirection: "column", children: [
2614
- /* @__PURE__ */ jsx10(Text10, { children: cleanupMessage }),
2615
- /* @__PURE__ */ jsx10(
3487
+ return /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", children: [
3488
+ /* @__PURE__ */ jsx12(Text12, { children: cleanupMessage }),
3489
+ /* @__PURE__ */ jsx12(
2616
3490
  Confirm,
2617
3491
  {
2618
3492
  message: "Proceed?",
@@ -2623,28 +3497,464 @@ var StatusView = ({ cleanup }) => {
2623
3497
  ] });
2624
3498
  }
2625
3499
  if (phase === "done") {
2626
- return /* @__PURE__ */ jsx10(Box8, { flexDirection: "column", children: /* @__PURE__ */ jsx10(Text10, { color: "green", children: "\u2713 Cleanup complete" }) });
3500
+ return /* @__PURE__ */ jsx12(Box10, { flexDirection: "column", children: /* @__PURE__ */ jsx12(Text12, { color: "green", children: "\u2713 Cleanup complete" }) });
2627
3501
  }
2628
3502
  return null;
2629
3503
  };
2630
3504
  function registerStatusCommand(program2) {
2631
3505
  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 }));
3506
+ const { waitUntilExit } = render8(/* @__PURE__ */ jsx12(StatusView, { cleanup: opts.cleanup }));
2633
3507
  await waitUntilExit();
2634
3508
  });
2635
3509
  }
2636
3510
 
3511
+ // src/commands/Wizard.tsx
3512
+ import { copyFile as copyFile2, readFile as readFile5, rename, unlink as unlink2, writeFile as writeFile5 } from "fs/promises";
3513
+ import { join as join15 } from "path";
3514
+ import { Box as Box11, Text as Text13, useApp as useApp8 } from "ink";
3515
+ import { render as render9 } from "ink";
3516
+ import Spinner3 from "ink-spinner";
3517
+ import { useEffect as useEffect8, useState as useState9 } from "react";
3518
+
3519
+ // src/prompts/wizard-config.ts
3520
+ function generateConfigWizardPrompt(variables) {
3521
+ const fileStructureJSON = JSON.stringify(variables.fileStructure, null, 2);
3522
+ return `You are a Syncpoint configuration assistant running in **INTERACTIVE MODE**. Your role is to have a conversation with the user to understand their backup needs, then create a personalized configuration file.
3523
+
3524
+ **INTERACTIVE WORKFLOW:**
3525
+ 1. Analyze the home directory structure provided below
3526
+ 2. **Ask the user clarifying questions** to understand their backup priorities:
3527
+ - Which development environments do they use? (Node.js, Python, Go, etc.)
3528
+ - Do they want to backup shell customizations? (zsh, bash, fish)
3529
+ - Which application settings are important to them?
3530
+ - Should SSH keys and Git configs be included?
3531
+ 3. After gathering information, **write the config file directly** using the Write tool
3532
+
3533
+ **CRITICAL - File Creation:**
3534
+ - **File path**: ~/.syncpoint/config.yml
3535
+ - **Use the Write tool** to create this file with the generated YAML
3536
+ - After writing the file, confirm to the user that it has been created
3537
+
3538
+ **Output Format Requirements:**
3539
+ - Pure YAML format only (no markdown, no code blocks, no explanations outside the file)
3540
+ - Must be valid according to Syncpoint config schema
3541
+ - Include \`backup.targets\` array with recommended files/patterns based on user responses
3542
+ - Include \`backup.exclude\` array with common exclusions (node_modules, .git, etc.)
3543
+ - Use appropriate pattern types:
3544
+ - Literal paths: ~/.zshrc
3545
+ - Glob patterns: ~/.config/*.conf
3546
+ - Regex patterns: /\\.toml$/ (for scanning with depth limit)
3547
+
3548
+ **Home Directory Structure:**
3549
+ ${fileStructureJSON}
3550
+
3551
+ **Default Config Template (for reference):**
3552
+ ${variables.defaultConfig}
3553
+
3554
+ **Start by greeting the user and asking about their backup priorities. After understanding their needs, write the config.yml file directly.**`;
3555
+ }
3556
+
3557
+ // src/utils/file-scanner.ts
3558
+ import { stat as stat3 } from "fs/promises";
3559
+ import { join as join14 } from "path";
3560
+ import glob from "fast-glob";
3561
+ var FILE_CATEGORIES = {
3562
+ shell: {
3563
+ name: "Shell Configuration",
3564
+ patterns: [".zshrc", ".bashrc", ".bash_profile", ".profile", ".zprofile"]
3565
+ },
3566
+ git: {
3567
+ name: "Git Configuration",
3568
+ patterns: [".gitconfig", ".gitignore_global", ".git-credentials"]
3569
+ },
3570
+ ssh: {
3571
+ name: "SSH Configuration",
3572
+ patterns: [".ssh/config", ".ssh/known_hosts"]
3573
+ },
3574
+ editors: {
3575
+ name: "Editor Configuration",
3576
+ patterns: [".vimrc", ".vim/**", ".emacs", ".emacs.d/**"]
3577
+ },
3578
+ terminal: {
3579
+ name: "Terminal & Multiplexer",
3580
+ patterns: [".tmux.conf", ".tmux/**", ".screenrc", ".alacritty.yml"]
3581
+ },
3582
+ appConfigs: {
3583
+ name: "Application Configs",
3584
+ patterns: [
3585
+ ".config/**/*.conf",
3586
+ ".config/**/*.toml",
3587
+ ".config/**/*.yml",
3588
+ ".config/**/*.yaml",
3589
+ ".config/**/*.json"
3590
+ ]
3591
+ },
3592
+ dotfiles: {
3593
+ name: "Other Dotfiles",
3594
+ patterns: [".*rc", ".*profile", ".*.conf"]
3595
+ }
3596
+ };
3597
+ async function scanHomeDirectory(options) {
3598
+ const homeDir = getHomeDir();
3599
+ const maxDepth = options?.maxDepth ?? 3;
3600
+ const maxFiles = options?.maxFiles ?? 500;
3601
+ const ignorePatterns = options?.ignorePatterns ?? [
3602
+ "**/node_modules/**",
3603
+ "**/.git/**",
3604
+ "**/Library/**",
3605
+ "**/Downloads/**",
3606
+ "**/Desktop/**",
3607
+ "**/Documents/**",
3608
+ "**/Pictures/**",
3609
+ "**/Music/**",
3610
+ "**/Videos/**",
3611
+ "**/Movies/**",
3612
+ "**/.Trash/**",
3613
+ "**/.cache/**",
3614
+ "**/.npm/**",
3615
+ "**/.yarn/**",
3616
+ "**/.vscode-server/**",
3617
+ "**/.*_history",
3618
+ "**/.local/share/**"
3619
+ ];
3620
+ const categories = [];
3621
+ const categorizedFiles = /* @__PURE__ */ new Set();
3622
+ let totalFiles = 0;
3623
+ for (const [, category] of Object.entries(FILE_CATEGORIES)) {
3624
+ const patterns = category.patterns;
3625
+ try {
3626
+ const files = await glob(patterns, {
3627
+ ignore: ignorePatterns,
3628
+ dot: true,
3629
+ onlyFiles: true,
3630
+ deep: maxDepth,
3631
+ absolute: false,
3632
+ cwd: homeDir
3633
+ });
3634
+ const validFiles = [];
3635
+ for (const file of files) {
3636
+ try {
3637
+ const fullPath = join14(homeDir, file);
3638
+ await stat3(fullPath);
3639
+ validFiles.push(file);
3640
+ categorizedFiles.add(file);
3641
+ } catch {
3642
+ continue;
3643
+ }
3644
+ }
3645
+ if (validFiles.length > 0) {
3646
+ categories.push({
3647
+ category: category.name,
3648
+ files: validFiles.sort()
3649
+ });
3650
+ totalFiles += validFiles.length;
3651
+ }
3652
+ } catch {
3653
+ continue;
3654
+ }
3655
+ }
3656
+ if (totalFiles < maxFiles) {
3657
+ try {
3658
+ const allFiles = await glob(["**/*", "**/.*"], {
3659
+ ignore: ignorePatterns,
3660
+ dot: true,
3661
+ onlyFiles: true,
3662
+ deep: maxDepth,
3663
+ absolute: false,
3664
+ cwd: homeDir
3665
+ });
3666
+ const uncategorizedFiles = [];
3667
+ for (const file of allFiles) {
3668
+ if (categorizedFiles.has(file)) continue;
3669
+ if (totalFiles >= maxFiles) break;
3670
+ try {
3671
+ const fullPath = join14(homeDir, file);
3672
+ await stat3(fullPath);
3673
+ uncategorizedFiles.push(file);
3674
+ totalFiles++;
3675
+ } catch {
3676
+ continue;
3677
+ }
3678
+ }
3679
+ if (uncategorizedFiles.length > 0) {
3680
+ categories.push({
3681
+ category: "Other Files",
3682
+ files: uncategorizedFiles.sort().slice(0, maxFiles - totalFiles)
3683
+ });
3684
+ }
3685
+ } catch {
3686
+ }
3687
+ }
3688
+ return {
3689
+ homeDir,
3690
+ categories,
3691
+ totalFiles
3692
+ };
3693
+ }
3694
+
3695
+ // src/commands/Wizard.tsx
3696
+ import { jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
3697
+ var MAX_RETRIES2 = 3;
3698
+ async function restoreBackup2(configPath) {
3699
+ const bakPath = `${configPath}.bak`;
3700
+ if (await fileExists(bakPath)) {
3701
+ await copyFile2(bakPath, configPath);
3702
+ }
3703
+ }
3704
+ async function runScanPhase() {
3705
+ const fileStructure = await scanHomeDirectory();
3706
+ const defaultConfig = readAsset("config.default.yml");
3707
+ const prompt = generateConfigWizardPrompt({
3708
+ fileStructure,
3709
+ defaultConfig
3710
+ });
3711
+ return { fileStructure, prompt };
3712
+ }
3713
+ async function runInteractivePhase(prompt) {
3714
+ console.log("\n\u{1F916} Launching Claude Code in interactive mode...");
3715
+ console.log(
3716
+ "Claude Code will start the conversation automatically. Just respond to the questions!\n"
3717
+ );
3718
+ await invokeClaudeCodeInteractive(prompt);
3719
+ }
3720
+ async function runValidationPhase(configPath) {
3721
+ try {
3722
+ if (!await fileExists(configPath)) {
3723
+ await restoreBackup2(configPath);
3724
+ console.log("\u26A0\uFE0F Config file was not created. Restored backup.");
3725
+ return;
3726
+ }
3727
+ const content = await readFile5(configPath, "utf-8");
3728
+ const parsed = parseYAML(content);
3729
+ const validation = validateConfig(parsed);
3730
+ if (!validation.valid) {
3731
+ await restoreBackup2(configPath);
3732
+ console.log(
3733
+ `\u274C Validation failed:
3734
+ ${formatValidationErrors(validation.errors || [])}`
3735
+ );
3736
+ console.log("Restored previous config from backup.");
3737
+ return;
3738
+ }
3739
+ console.log("\u2705 Config wizard complete! Your config.yml has been created.");
3740
+ } catch (err) {
3741
+ await restoreBackup2(configPath);
3742
+ throw err;
3743
+ }
3744
+ }
3745
+ var WizardView = ({ printMode }) => {
3746
+ const { exit } = useApp8();
3747
+ const [phase, setPhase] = useState9("init");
3748
+ const [message, setMessage] = useState9("");
3749
+ const [error, setError] = useState9(null);
3750
+ const [prompt, setPrompt] = useState9("");
3751
+ const [sessionId, setSessionId] = useState9(void 0);
3752
+ const [attemptNumber, setAttemptNumber] = useState9(1);
3753
+ useEffect8(() => {
3754
+ (async () => {
3755
+ try {
3756
+ const configPath = join15(getAppDir(), CONFIG_FILENAME);
3757
+ if (await fileExists(configPath)) {
3758
+ setMessage(
3759
+ `Config already exists: ${configPath}
3760
+ Would you like to backup and overwrite? (Backup will be saved as config.yml.bak)`
3761
+ );
3762
+ await rename(configPath, `${configPath}.bak`);
3763
+ setMessage(`Backed up existing config to config.yml.bak`);
3764
+ }
3765
+ setPhase("scanning");
3766
+ setMessage("Scanning home directory for backup targets...");
3767
+ const fileStructure = await scanHomeDirectory();
3768
+ setMessage(
3769
+ `Found ${fileStructure.totalFiles} files in ${fileStructure.categories.length} categories`
3770
+ );
3771
+ const defaultConfig = readAsset("config.default.yml");
3772
+ const generatedPrompt = generateConfigWizardPrompt({
3773
+ fileStructure,
3774
+ defaultConfig
3775
+ });
3776
+ setPrompt(generatedPrompt);
3777
+ if (printMode) {
3778
+ setPhase("done");
3779
+ exit();
3780
+ return;
3781
+ }
3782
+ if (!await isClaudeCodeAvailable()) {
3783
+ throw new Error(
3784
+ "Claude Code CLI not found. Install it or use --print mode to get the prompt."
3785
+ );
3786
+ }
3787
+ await invokeLLMWithRetry(generatedPrompt, configPath);
3788
+ } catch (err) {
3789
+ setError(err instanceof Error ? err.message : String(err));
3790
+ setPhase("error");
3791
+ setTimeout(() => exit(), 100);
3792
+ }
3793
+ })();
3794
+ }, []);
3795
+ async function invokeLLMWithRetry(initialPrompt, configPath) {
3796
+ let currentPrompt = initialPrompt;
3797
+ let currentAttempt = 1;
3798
+ let currentSessionId = sessionId;
3799
+ try {
3800
+ while (currentAttempt <= MAX_RETRIES2) {
3801
+ try {
3802
+ setPhase("llm-invoke");
3803
+ setMessage(
3804
+ `Generating config... (Attempt ${currentAttempt}/${MAX_RETRIES2})`
3805
+ );
3806
+ const result = currentSessionId ? await resumeClaudeCodeSession(currentSessionId, currentPrompt) : await invokeClaudeCode(currentPrompt);
3807
+ if (!result.success) {
3808
+ throw new Error(result.error || "Failed to invoke Claude Code");
3809
+ }
3810
+ currentSessionId = result.sessionId;
3811
+ setSessionId(currentSessionId);
3812
+ setPhase("validating");
3813
+ setMessage("Parsing YAML response...");
3814
+ const yamlContent = extractYAML(result.output);
3815
+ if (!yamlContent) {
3816
+ throw new Error("No valid YAML found in LLM response");
3817
+ }
3818
+ const parsedConfig = parseYAML(yamlContent);
3819
+ setMessage("Validating config...");
3820
+ const validation = validateConfig(parsedConfig);
3821
+ if (validation.valid) {
3822
+ setPhase("writing");
3823
+ setMessage("Writing config.yml...");
3824
+ const tmpPath = `${configPath}.tmp`;
3825
+ await writeFile5(tmpPath, yamlContent, "utf-8");
3826
+ const verification = validateConfig(parseYAML(yamlContent));
3827
+ if (verification.valid) {
3828
+ await rename(tmpPath, configPath);
3829
+ } else {
3830
+ await unlink2(tmpPath);
3831
+ throw new Error("Final validation failed");
3832
+ }
3833
+ setPhase("done");
3834
+ setMessage(
3835
+ "\u2713 Config wizard complete! Your config.yml has been created."
3836
+ );
3837
+ setTimeout(() => exit(), 100);
3838
+ return;
3839
+ }
3840
+ if (currentAttempt >= MAX_RETRIES2) {
3841
+ throw new Error(
3842
+ `Validation failed after ${MAX_RETRIES2} attempts:
3843
+ ${formatValidationErrors(validation.errors || [])}`
3844
+ );
3845
+ }
3846
+ setPhase("retry");
3847
+ setMessage(`Validation failed. Retrying with error context...`);
3848
+ currentPrompt = createRetryPrompt(
3849
+ initialPrompt,
3850
+ validation.errors || [],
3851
+ currentAttempt + 1
3852
+ );
3853
+ currentAttempt++;
3854
+ setAttemptNumber(currentAttempt);
3855
+ } catch (err) {
3856
+ if (currentAttempt >= MAX_RETRIES2) {
3857
+ throw err;
3858
+ }
3859
+ currentAttempt++;
3860
+ setAttemptNumber(currentAttempt);
3861
+ }
3862
+ }
3863
+ } catch (err) {
3864
+ await restoreBackup2(configPath);
3865
+ throw err;
3866
+ }
3867
+ }
3868
+ if (error) {
3869
+ return /* @__PURE__ */ jsx13(Box11, { flexDirection: "column", children: /* @__PURE__ */ jsxs13(Text13, { color: "red", children: [
3870
+ "\u2717 ",
3871
+ error
3872
+ ] }) });
3873
+ }
3874
+ if (printMode && phase === "done") {
3875
+ return /* @__PURE__ */ jsxs13(Box11, { flexDirection: "column", children: [
3876
+ /* @__PURE__ */ jsx13(Text13, { bold: true, children: "Config Wizard Prompt (Copy and paste to your LLM):" }),
3877
+ /* @__PURE__ */ jsx13(Box11, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: "\u2500".repeat(60) }) }),
3878
+ /* @__PURE__ */ jsx13(Text13, { children: prompt }),
3879
+ /* @__PURE__ */ jsx13(Box11, { marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: "\u2500".repeat(60) }) }),
3880
+ /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: "After getting the YAML response, save it to ~/.syncpoint/config.yml" })
3881
+ ] });
3882
+ }
3883
+ if (phase === "done") {
3884
+ return /* @__PURE__ */ jsxs13(Box11, { flexDirection: "column", children: [
3885
+ /* @__PURE__ */ jsx13(Text13, { color: "green", children: message }),
3886
+ /* @__PURE__ */ jsxs13(Box11, { marginTop: 1, children: [
3887
+ /* @__PURE__ */ jsx13(Text13, { children: "Next steps:" }),
3888
+ /* @__PURE__ */ jsx13(Text13, { children: " 1. Review your config: ~/.syncpoint/config.yml" }),
3889
+ /* @__PURE__ */ jsx13(Text13, { children: " 2. Run: syncpoint backup" })
3890
+ ] })
3891
+ ] });
3892
+ }
3893
+ return /* @__PURE__ */ jsxs13(Box11, { flexDirection: "column", children: [
3894
+ /* @__PURE__ */ jsxs13(Text13, { children: [
3895
+ /* @__PURE__ */ jsx13(Text13, { color: "cyan", children: /* @__PURE__ */ jsx13(Spinner3, { type: "dots" }) }),
3896
+ " ",
3897
+ message
3898
+ ] }),
3899
+ attemptNumber > 1 && /* @__PURE__ */ jsxs13(Text13, { dimColor: true, children: [
3900
+ "Attempt ",
3901
+ attemptNumber,
3902
+ "/",
3903
+ MAX_RETRIES2
3904
+ ] })
3905
+ ] });
3906
+ };
3907
+ function registerWizardCommand(program2) {
3908
+ const cmdInfo = COMMANDS.wizard;
3909
+ const cmd = program2.command("wizard").description(cmdInfo.description);
3910
+ cmdInfo.options?.forEach((opt) => {
3911
+ cmd.option(opt.flag, opt.description);
3912
+ });
3913
+ cmd.action(async (opts) => {
3914
+ if (opts.print) {
3915
+ const { waitUntilExit } = render9(/* @__PURE__ */ jsx13(WizardView, { printMode: true }));
3916
+ await waitUntilExit();
3917
+ return;
3918
+ }
3919
+ const configPath = join15(getAppDir(), CONFIG_FILENAME);
3920
+ try {
3921
+ if (await fileExists(configPath)) {
3922
+ console.log(`\u{1F4CB} Backing up existing config to ${configPath}.bak`);
3923
+ await rename(configPath, `${configPath}.bak`);
3924
+ }
3925
+ if (!await isClaudeCodeAvailable()) {
3926
+ throw new Error(
3927
+ "Claude Code CLI not found. Please install it or use --print mode."
3928
+ );
3929
+ }
3930
+ console.log("\u{1F50D} Scanning home directory...");
3931
+ const scanResult = await runScanPhase();
3932
+ console.log(
3933
+ `Found ${scanResult.fileStructure.totalFiles} files in ${scanResult.fileStructure.categories.length} categories`
3934
+ );
3935
+ await runInteractivePhase(scanResult.prompt);
3936
+ await runValidationPhase(configPath);
3937
+ } catch (err) {
3938
+ console.error("\u274C Error:", err instanceof Error ? err.message : err);
3939
+ process.exit(1);
3940
+ }
3941
+ });
3942
+ }
3943
+
2637
3944
  // src/cli.ts
2638
3945
  var program = new Command();
2639
3946
  program.name("syncpoint").description(
2640
3947
  "Personal Environment Manager \u2014 Config backup/restore and machine provisioning CLI"
2641
- ).version(APP_VERSION);
3948
+ ).version(VERSION);
2642
3949
  registerInitCommand(program);
3950
+ registerWizardCommand(program);
2643
3951
  registerBackupCommand(program);
2644
3952
  registerRestoreCommand(program);
2645
3953
  registerProvisionCommand(program);
3954
+ registerCreateTemplateCommand(program);
2646
3955
  registerListCommand(program);
2647
3956
  registerStatusCommand(program);
3957
+ registerHelpCommand(program);
2648
3958
  program.parseAsync(process.argv).catch((error) => {
2649
3959
  console.error("Fatal error:", error.message);
2650
3960
  process.exit(1);