@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/README.md +921 -0
- package/assets/config.default.yml +12 -2
- package/dist/cli.mjs +2750 -1440
- package/dist/commands/Backup.d.ts +1 -1
- package/dist/commands/CreateTemplate.d.ts +2 -0
- package/dist/commands/Help.d.ts +2 -0
- package/dist/commands/Wizard.d.ts +2 -0
- package/dist/constants.d.ts +1 -2
- package/dist/core/backup.d.ts +8 -1
- package/dist/core/config.d.ts +1 -1
- package/dist/core/metadata.d.ts +1 -1
- package/dist/core/provision.d.ts +1 -1
- package/dist/core/restore.d.ts +1 -1
- package/dist/index.cjs +215 -46
- package/dist/index.d.ts +5 -5
- package/dist/index.mjs +219 -50
- package/dist/prompts/wizard-config.d.ts +9 -0
- package/dist/prompts/wizard-template.d.ts +7 -0
- package/dist/schemas/ajv.d.ts +1 -1
- package/dist/utils/claude-code-runner.d.ts +29 -0
- package/dist/utils/command-registry.d.ts +23 -0
- package/dist/utils/error-formatter.d.ts +16 -0
- package/dist/utils/file-scanner.d.ts +25 -0
- package/dist/utils/paths.d.ts +5 -0
- package/dist/utils/pattern.d.ts +46 -0
- package/dist/utils/types.d.ts +4 -3
- package/dist/utils/yaml-parser.d.ts +19 -0
- package/dist/version.d.ts +5 -0
- package/package.json +5 -3
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/
|
|
7
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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/
|
|
472
|
-
import
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
494
|
+
import { mkdir as mkdir3, mkdtemp, readFile as readFile2, rm, writeFile } from "fs/promises";
|
|
612
495
|
import { tmpdir } from "os";
|
|
613
|
-
import { join as
|
|
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(
|
|
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 =
|
|
621
|
-
const parentDir =
|
|
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
|
|
512
|
+
await writeFile(targetPath, file.content);
|
|
627
513
|
} else if (file.sourcePath) {
|
|
628
|
-
const data = await
|
|
629
|
-
await
|
|
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(
|
|
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 =
|
|
560
|
+
const extractedPath = join4(tmpDir, filename);
|
|
675
561
|
try {
|
|
676
|
-
return await
|
|
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
|
-
|
|
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:
|
|
618
|
+
ignore: globExcludes,
|
|
705
619
|
onlyFiles: true
|
|
706
620
|
});
|
|
707
621
|
for (const match of matches) {
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
719
|
-
if (
|
|
720
|
-
|
|
721
|
-
|
|
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 =
|
|
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
|
-
|
|
754
|
-
|
|
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 =
|
|
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/
|
|
792
|
-
import {
|
|
793
|
-
import {
|
|
794
|
-
|
|
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/
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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/
|
|
934
|
-
import {
|
|
935
|
-
import {
|
|
936
|
-
import
|
|
937
|
-
|
|
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/
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
const
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
974
|
-
return backups;
|
|
818
|
+
return cleaned;
|
|
975
819
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
`
|
|
981
|
-
|
|
828
|
+
`Config file not found: ${configPath}
|
|
829
|
+
Run "syncpoint init" first.`
|
|
982
830
|
);
|
|
983
831
|
}
|
|
984
|
-
const
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
991
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1039
|
-
logger.info(`Safety backup created: ${archivePath}`);
|
|
1040
|
-
return archivePath;
|
|
869
|
+
return { created, skipped };
|
|
1041
870
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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/
|
|
1135
|
-
import { jsx as
|
|
1136
|
-
var
|
|
1137
|
-
const { exit } =
|
|
1138
|
-
const [phase, setPhase] =
|
|
1139
|
-
const [
|
|
1140
|
-
const [
|
|
1141
|
-
const [
|
|
1142
|
-
const [
|
|
1143
|
-
const [
|
|
1144
|
-
const [error, setError] =
|
|
1145
|
-
|
|
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
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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__ */
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
] }),
|
|
1243
|
-
(
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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 === "
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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__ */
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
-
"
|
|
1327
|
-
result.
|
|
1328
|
-
" files"
|
|
1134
|
+
"File: ",
|
|
1135
|
+
result.metadata.config.filename
|
|
1329
1136
|
] }),
|
|
1330
|
-
/* @__PURE__ */
|
|
1137
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1331
1138
|
" ",
|
|
1332
|
-
"
|
|
1333
|
-
result.
|
|
1334
|
-
"
|
|
1139
|
+
"Size: ",
|
|
1140
|
+
formatBytes(result.metadata.summary.totalSize),
|
|
1141
|
+
" (",
|
|
1142
|
+
result.metadata.summary.fileCount,
|
|
1143
|
+
" files + metadata)"
|
|
1335
1144
|
] }),
|
|
1336
|
-
|
|
1145
|
+
/* @__PURE__ */ jsxs2(Text2, { children: [
|
|
1337
1146
|
" ",
|
|
1338
|
-
"
|
|
1339
|
-
contractTilde(result.
|
|
1147
|
+
"Path: ",
|
|
1148
|
+
contractTilde(result.archivePath)
|
|
1340
1149
|
] })
|
|
1341
1150
|
] })
|
|
1342
1151
|
] });
|
|
1343
1152
|
};
|
|
1344
|
-
function
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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/
|
|
1360
|
-
import { useState as
|
|
1361
|
-
import { Text as
|
|
1362
|
-
import
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
import {
|
|
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
|
|
1203
|
+
var validate4 = ajv.compile(templateSchema);
|
|
1399
1204
|
function validateTemplate(data) {
|
|
1400
|
-
const valid =
|
|
1205
|
+
const valid = validate4(data);
|
|
1401
1206
|
if (valid) return { valid: true };
|
|
1402
|
-
const errors =
|
|
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/
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
1419
|
-
|
|
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
|
|
1422
|
-
const
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
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
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
|
|
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
|
|
1450
|
-
|
|
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
|
-
|
|
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
|
|
1462
|
-
return
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
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
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
)
|
|
1555
|
-
|
|
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
|
|
1594
|
-
import
|
|
1595
|
-
import { jsx as
|
|
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__ */
|
|
2650
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "green", children: "\u2713" });
|
|
1600
2651
|
case "running":
|
|
1601
|
-
return /* @__PURE__ */
|
|
2652
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "yellow", children: /* @__PURE__ */ jsx9(Spinner2, { type: "dots" }) });
|
|
1602
2653
|
case "skipped":
|
|
1603
|
-
return /* @__PURE__ */
|
|
2654
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "blue", children: "\u23ED" });
|
|
1604
2655
|
case "failed":
|
|
1605
|
-
return /* @__PURE__ */
|
|
2656
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "red", children: "\u2717" });
|
|
1606
2657
|
case "pending":
|
|
1607
2658
|
default:
|
|
1608
|
-
return /* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
2670
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "yellow", children: "Running..." });
|
|
1620
2671
|
case "skipped":
|
|
1621
|
-
return /* @__PURE__ */
|
|
2672
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "blue", children: "Skipped (already installed)" });
|
|
1622
2673
|
case "failed":
|
|
1623
|
-
return /* @__PURE__ */
|
|
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__ */
|
|
1637
|
-
/* @__PURE__ */
|
|
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__ */
|
|
1640
|
-
/* @__PURE__ */
|
|
2690
|
+
/* @__PURE__ */ jsx9(StepIcon, { status: step.status }),
|
|
2691
|
+
/* @__PURE__ */ jsxs9(Text9, { children: [
|
|
1641
2692
|
" ",
|
|
1642
|
-
/* @__PURE__ */
|
|
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__ */
|
|
2703
|
+
step.output && step.status !== "pending" && /* @__PURE__ */ jsxs9(Text9, { color: "gray", children: [
|
|
1653
2704
|
" ",
|
|
1654
2705
|
step.output
|
|
1655
2706
|
] }),
|
|
1656
|
-
/* @__PURE__ */
|
|
2707
|
+
/* @__PURE__ */ jsxs9(Text9, { children: [
|
|
1657
2708
|
" ",
|
|
1658
|
-
/* @__PURE__ */
|
|
2709
|
+
/* @__PURE__ */ jsx9(StepStatusText, { step })
|
|
1659
2710
|
] })
|
|
1660
2711
|
] }, idx)) });
|
|
1661
2712
|
};
|
|
1662
2713
|
|
|
1663
2714
|
// src/commands/Provision.tsx
|
|
1664
|
-
import { jsx as
|
|
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 } =
|
|
1671
|
-
const [phase, setPhase] =
|
|
1672
|
-
const [steps, setSteps] =
|
|
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] =
|
|
1680
|
-
const [error, setError] =
|
|
1681
|
-
|
|
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__ */
|
|
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__ */
|
|
1720
|
-
/* @__PURE__ */
|
|
1721
|
-
/* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
1731
|
-
/* @__PURE__ */
|
|
1732
|
-
template.sudo && /* @__PURE__ */
|
|
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__ */
|
|
1737
|
-
/* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
2799
|
+
step.description && /* @__PURE__ */ jsxs10(Text10, { color: "gray", children: [
|
|
1749
2800
|
" ",
|
|
1750
2801
|
step.description
|
|
1751
2802
|
] }),
|
|
1752
|
-
step.skip_if && /* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
1768
|
-
/* @__PURE__ */
|
|
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__ */
|
|
2823
|
+
/* @__PURE__ */ jsxs10(Text10, { children: [
|
|
1773
2824
|
" ",
|
|
1774
2825
|
"Result: ",
|
|
1775
|
-
/* @__PURE__ */
|
|
2826
|
+
/* @__PURE__ */ jsxs10(Text10, { color: "green", children: [
|
|
1776
2827
|
successCount,
|
|
1777
2828
|
" succeeded"
|
|
1778
2829
|
] }),
|
|
1779
2830
|
" \xB7",
|
|
1780
2831
|
" ",
|
|
1781
|
-
/* @__PURE__ */
|
|
2832
|
+
/* @__PURE__ */ jsxs10(Text10, { color: "blue", children: [
|
|
1782
2833
|
skippedCount,
|
|
1783
2834
|
" skipped"
|
|
1784
2835
|
] }),
|
|
1785
2836
|
" \xB7",
|
|
1786
2837
|
" ",
|
|
1787
|
-
/* @__PURE__ */
|
|
2838
|
+
/* @__PURE__ */ jsxs10(Text10, { color: "red", children: [
|
|
1788
2839
|
failedCount,
|
|
1789
2840
|
" failed"
|
|
1790
2841
|
] })
|
|
1791
2842
|
] }),
|
|
1792
|
-
template.backup && !options.skipRestore && /* @__PURE__ */
|
|
1793
|
-
/* @__PURE__ */
|
|
1794
|
-
/* @__PURE__ */
|
|
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 } =
|
|
1819
|
-
/* @__PURE__ */
|
|
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/
|
|
1837
|
-
import {
|
|
1838
|
-
import {
|
|
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 {
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
const
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
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 (
|
|
1943
|
-
const
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
-
|
|
2920
|
+
exit();
|
|
1949
2921
|
return;
|
|
1950
2922
|
}
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
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
|
-
|
|
2931
|
+
exit();
|
|
1963
2932
|
}
|
|
1964
2933
|
})();
|
|
1965
2934
|
}, []);
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
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
|
-
|
|
1983
|
-
|
|
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
|
-
|
|
2950
|
+
exit();
|
|
1992
2951
|
}
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
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__ */
|
|
2983
|
+
return /* @__PURE__ */ jsx11(Box9, { flexDirection: "column", children: /* @__PURE__ */ jsxs11(Text11, { color: "red", children: [
|
|
2004
2984
|
"\u2717 ",
|
|
2005
2985
|
error
|
|
2006
2986
|
] }) });
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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__ */
|
|
2200
|
-
/* @__PURE__ */
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
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__ */
|
|
2219
|
-
|
|
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
|
-
|
|
2222
|
-
|
|
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
|
|
2248
|
-
import { Box as
|
|
2249
|
-
import { render as
|
|
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
|
|
2252
|
-
import { jsx as
|
|
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
|
|
2261
|
-
if (
|
|
2262
|
-
totalSize +=
|
|
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__ */
|
|
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__ */
|
|
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__ */
|
|
3153
|
+
return /* @__PURE__ */ jsxs12(Text12, { bold: isSelected, children: [
|
|
2283
3154
|
parts[0],
|
|
2284
3155
|
" ",
|
|
2285
|
-
/* @__PURE__ */
|
|
3156
|
+
/* @__PURE__ */ jsx12(Text12, { dimColor: true, children: parts[1] })
|
|
2286
3157
|
] });
|
|
2287
3158
|
}
|
|
2288
|
-
return /* @__PURE__ */
|
|
3159
|
+
return /* @__PURE__ */ jsx12(Text12, { bold: isSelected, children: label });
|
|
2289
3160
|
};
|
|
2290
3161
|
var StatusView = ({ cleanup }) => {
|
|
2291
|
-
const { exit } =
|
|
2292
|
-
const [phase, setPhase] =
|
|
2293
|
-
const [status, setStatus] =
|
|
2294
|
-
const [backups, setBackups] =
|
|
2295
|
-
const [cleanupAction, setCleanupAction] =
|
|
2296
|
-
const [cleanupMessage, setCleanupMessage] =
|
|
2297
|
-
const [error, setError] =
|
|
2298
|
-
const [selectedForDeletion, setSelectedForDeletion] =
|
|
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
|
-
|
|
3184
|
+
useEffect7(() => {
|
|
2313
3185
|
(async () => {
|
|
2314
3186
|
try {
|
|
2315
|
-
const
|
|
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,
|
|
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,
|
|
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 =
|
|
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,
|
|
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__ */
|
|
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__ */
|
|
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__ */
|
|
2454
|
-
/* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
2491
|
-
/* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
3381
|
+
const escHint = (action) => /* @__PURE__ */ jsx12(Box10, { marginTop: 1, children: /* @__PURE__ */ jsxs12(Text12, { dimColor: true, children: [
|
|
2508
3382
|
"Press ",
|
|
2509
|
-
/* @__PURE__ */
|
|
3383
|
+
/* @__PURE__ */ jsx12(Text12, { bold: true, children: "ESC" }),
|
|
2510
3384
|
" to ",
|
|
2511
3385
|
action
|
|
2512
3386
|
] }) });
|
|
2513
3387
|
if (phase === "display") {
|
|
2514
|
-
return /* @__PURE__ */
|
|
3388
|
+
return /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", children: [
|
|
2515
3389
|
statusDisplay,
|
|
2516
|
-
/* @__PURE__ */
|
|
2517
|
-
/* @__PURE__ */
|
|
2518
|
-
/* @__PURE__ */
|
|
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__ */
|
|
3440
|
+
return /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", children: [
|
|
2567
3441
|
statusDisplay,
|
|
2568
|
-
/* @__PURE__ */
|
|
2569
|
-
/* @__PURE__ */
|
|
2570
|
-
/* @__PURE__ */
|
|
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__ */
|
|
3470
|
+
return /* @__PURE__ */ jsxs12(Box10, { flexDirection: "column", children: [
|
|
2597
3471
|
statusDisplay,
|
|
2598
|
-
/* @__PURE__ */
|
|
2599
|
-
/* @__PURE__ */
|
|
2600
|
-
selectedForDeletion.length > 0 && /* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
2614
|
-
/* @__PURE__ */
|
|
2615
|
-
/* @__PURE__ */
|
|
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__ */
|
|
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 } =
|
|
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(
|
|
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);
|