@lumy-pack/syncpoint 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/config.default.yml +29 -0
- package/assets/template.example.yml +28 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +2651 -0
- package/dist/commands/Backup.d.ts +2 -0
- package/dist/commands/Init.d.ts +2 -0
- package/dist/commands/List.d.ts +2 -0
- package/dist/commands/Provision.d.ts +2 -0
- package/dist/commands/Restore.d.ts +2 -0
- package/dist/commands/Status.d.ts +2 -0
- package/dist/components/Confirm.d.ts +8 -0
- package/dist/components/ProgressBar.d.ts +7 -0
- package/dist/components/StepRunner.d.ts +9 -0
- package/dist/components/Table.d.ts +8 -0
- package/dist/components/Viewer.d.ts +16 -0
- package/dist/constants.d.ts +16 -0
- package/dist/core/backup.d.ts +21 -0
- package/dist/core/config.d.ts +22 -0
- package/dist/core/metadata.d.ts +20 -0
- package/dist/core/provision.d.ts +28 -0
- package/dist/core/restore.d.ts +24 -0
- package/dist/core/storage.d.ts +22 -0
- package/dist/index.cjs +1069 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.mjs +1018 -0
- package/dist/schemas/ajv.d.ts +3 -0
- package/dist/schemas/config.schema.d.ts +4 -0
- package/dist/schemas/metadata.schema.d.ts +4 -0
- package/dist/schemas/template.schema.d.ts +4 -0
- package/dist/utils/assets.d.ts +2 -0
- package/dist/utils/format.d.ts +27 -0
- package/dist/utils/logger.d.ts +6 -0
- package/dist/utils/paths.d.ts +22 -0
- package/dist/utils/sudo.d.ts +16 -0
- package/dist/utils/system.d.ts +17 -0
- package/dist/utils/types.d.ts +136 -0
- package/package.json +77 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
// src/core/config.ts
|
|
2
|
+
import { readFile, writeFile } from "fs/promises";
|
|
3
|
+
import { join as join4 } from "path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
|
|
6
|
+
// src/constants.ts
|
|
7
|
+
import { join as join2 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/utils/paths.ts
|
|
10
|
+
import { mkdir, stat } from "fs/promises";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join, normalize, resolve } from "path";
|
|
13
|
+
function getHomeDir() {
|
|
14
|
+
const envHome = process.env.SYNCPOINT_HOME;
|
|
15
|
+
if (envHome) {
|
|
16
|
+
const normalized = normalize(envHome);
|
|
17
|
+
if (normalized.includes("..")) {
|
|
18
|
+
throw new Error(`SYNCPOINT_HOME contains path traversal: ${envHome}`);
|
|
19
|
+
}
|
|
20
|
+
if (!resolve(normalized).startsWith("/")) {
|
|
21
|
+
throw new Error(`SYNCPOINT_HOME must be an absolute path: ${envHome}`);
|
|
22
|
+
}
|
|
23
|
+
return normalized;
|
|
24
|
+
}
|
|
25
|
+
return homedir();
|
|
26
|
+
}
|
|
27
|
+
function expandTilde(p) {
|
|
28
|
+
if (p === "~") return getHomeDir();
|
|
29
|
+
if (p.startsWith("~/")) return join(getHomeDir(), p.slice(2));
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
function contractTilde(p) {
|
|
33
|
+
const home = getHomeDir();
|
|
34
|
+
if (p === home) return "~";
|
|
35
|
+
if (p.startsWith(home + "/")) return "~" + p.slice(home.length);
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
function resolveTargetPath(p) {
|
|
39
|
+
return resolve(expandTilde(p));
|
|
40
|
+
}
|
|
41
|
+
async function ensureDir(dirPath) {
|
|
42
|
+
await mkdir(dirPath, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
async function fileExists(filePath) {
|
|
45
|
+
try {
|
|
46
|
+
await stat(filePath);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/constants.ts
|
|
54
|
+
var APP_NAME = "syncpoint";
|
|
55
|
+
var APP_DIR = `.${APP_NAME}`;
|
|
56
|
+
var CONFIG_FILENAME = "config.yml";
|
|
57
|
+
var METADATA_FILENAME = "_metadata.json";
|
|
58
|
+
var LARGE_FILE_THRESHOLD = 10 * 1024 * 1024;
|
|
59
|
+
var SENSITIVE_PATTERNS = ["id_rsa", "id_ed25519", "*.pem", "*.key"];
|
|
60
|
+
var BACKUPS_DIR = "backups";
|
|
61
|
+
var TEMPLATES_DIR = "templates";
|
|
62
|
+
var SCRIPTS_DIR = "scripts";
|
|
63
|
+
var LOGS_DIR = "logs";
|
|
64
|
+
function getAppDir() {
|
|
65
|
+
return join2(getHomeDir(), APP_DIR);
|
|
66
|
+
}
|
|
67
|
+
var APP_VERSION = "0.0.1";
|
|
68
|
+
function getSubDir(sub) {
|
|
69
|
+
return join2(getAppDir(), sub);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/schemas/ajv.ts
|
|
73
|
+
import Ajv from "ajv";
|
|
74
|
+
import addFormats from "ajv-formats";
|
|
75
|
+
var ajv = new Ajv({ allErrors: true });
|
|
76
|
+
addFormats(ajv);
|
|
77
|
+
|
|
78
|
+
// src/schemas/config.schema.ts
|
|
79
|
+
var configSchema = {
|
|
80
|
+
type: "object",
|
|
81
|
+
required: ["backup"],
|
|
82
|
+
properties: {
|
|
83
|
+
backup: {
|
|
84
|
+
type: "object",
|
|
85
|
+
required: ["targets", "exclude", "filename"],
|
|
86
|
+
properties: {
|
|
87
|
+
targets: {
|
|
88
|
+
type: "array",
|
|
89
|
+
items: { type: "string" }
|
|
90
|
+
},
|
|
91
|
+
exclude: {
|
|
92
|
+
type: "array",
|
|
93
|
+
items: { type: "string" }
|
|
94
|
+
},
|
|
95
|
+
filename: {
|
|
96
|
+
type: "string",
|
|
97
|
+
minLength: 1
|
|
98
|
+
},
|
|
99
|
+
destination: {
|
|
100
|
+
type: "string"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
additionalProperties: false
|
|
104
|
+
},
|
|
105
|
+
scripts: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
includeInBackup: {
|
|
109
|
+
type: "boolean"
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
additionalProperties: false
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
additionalProperties: false
|
|
116
|
+
};
|
|
117
|
+
var validate = ajv.compile(configSchema);
|
|
118
|
+
function validateConfig(data) {
|
|
119
|
+
const valid = validate(data);
|
|
120
|
+
if (valid) return { valid: true };
|
|
121
|
+
const errors = validate.errors?.map(
|
|
122
|
+
(e) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`
|
|
123
|
+
);
|
|
124
|
+
return { valid: false, errors };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/utils/assets.ts
|
|
128
|
+
import { existsSync, readFileSync } from "fs";
|
|
129
|
+
import { dirname, join as join3 } from "path";
|
|
130
|
+
import { fileURLToPath } from "url";
|
|
131
|
+
function getPackageRoot() {
|
|
132
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
133
|
+
while (dir !== dirname(dir)) {
|
|
134
|
+
if (existsSync(join3(dir, "package.json"))) return dir;
|
|
135
|
+
dir = dirname(dir);
|
|
136
|
+
}
|
|
137
|
+
throw new Error("Could not find package root");
|
|
138
|
+
}
|
|
139
|
+
function getAssetPath(filename) {
|
|
140
|
+
return join3(getPackageRoot(), "assets", filename);
|
|
141
|
+
}
|
|
142
|
+
function readAsset(filename) {
|
|
143
|
+
return readFileSync(getAssetPath(filename), "utf-8");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/core/config.ts
|
|
147
|
+
function stripDangerousKeys(obj) {
|
|
148
|
+
if (obj === null || typeof obj !== "object") return obj;
|
|
149
|
+
if (Array.isArray(obj)) return obj.map(stripDangerousKeys);
|
|
150
|
+
const cleaned = {};
|
|
151
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
152
|
+
if (["__proto__", "constructor", "prototype"].includes(key)) continue;
|
|
153
|
+
cleaned[key] = stripDangerousKeys(value);
|
|
154
|
+
}
|
|
155
|
+
return cleaned;
|
|
156
|
+
}
|
|
157
|
+
function getConfigPath() {
|
|
158
|
+
return join4(getAppDir(), CONFIG_FILENAME);
|
|
159
|
+
}
|
|
160
|
+
async function loadConfig() {
|
|
161
|
+
const configPath = getConfigPath();
|
|
162
|
+
const exists = await fileExists(configPath);
|
|
163
|
+
if (!exists) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Config file not found: ${configPath}
|
|
166
|
+
Run "syncpoint init" first.`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const raw = await readFile(configPath, "utf-8");
|
|
170
|
+
const data = stripDangerousKeys(YAML.parse(raw));
|
|
171
|
+
const result = validateConfig(data);
|
|
172
|
+
if (!result.valid) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Invalid config:
|
|
175
|
+
${(result.errors ?? []).join("\n")}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return data;
|
|
179
|
+
}
|
|
180
|
+
async function saveConfig(config) {
|
|
181
|
+
const result = validateConfig(config);
|
|
182
|
+
if (!result.valid) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Invalid config:
|
|
185
|
+
${(result.errors ?? []).join("\n")}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const configPath = getConfigPath();
|
|
189
|
+
await ensureDir(getAppDir());
|
|
190
|
+
const yamlStr = YAML.stringify(config, { indent: 2 });
|
|
191
|
+
await writeFile(configPath, yamlStr, "utf-8");
|
|
192
|
+
}
|
|
193
|
+
async function initDefaultConfig() {
|
|
194
|
+
const created = [];
|
|
195
|
+
const skipped = [];
|
|
196
|
+
const dirs = [
|
|
197
|
+
getAppDir(),
|
|
198
|
+
getSubDir(BACKUPS_DIR),
|
|
199
|
+
getSubDir(TEMPLATES_DIR),
|
|
200
|
+
getSubDir(SCRIPTS_DIR),
|
|
201
|
+
getSubDir(LOGS_DIR)
|
|
202
|
+
];
|
|
203
|
+
for (const dir of dirs) {
|
|
204
|
+
const exists = await fileExists(dir);
|
|
205
|
+
if (!exists) {
|
|
206
|
+
await ensureDir(dir);
|
|
207
|
+
created.push(dir);
|
|
208
|
+
} else {
|
|
209
|
+
skipped.push(dir);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const configPath = getConfigPath();
|
|
213
|
+
const configExists = await fileExists(configPath);
|
|
214
|
+
if (!configExists) {
|
|
215
|
+
const yamlContent = readAsset("config.default.yml");
|
|
216
|
+
await writeFile(configPath, yamlContent, "utf-8");
|
|
217
|
+
created.push(configPath);
|
|
218
|
+
} else {
|
|
219
|
+
skipped.push(configPath);
|
|
220
|
+
}
|
|
221
|
+
return { created, skipped };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/core/backup.ts
|
|
225
|
+
import { readdir } from "fs/promises";
|
|
226
|
+
import { join as join7, basename } from "path";
|
|
227
|
+
import fg from "fast-glob";
|
|
228
|
+
|
|
229
|
+
// src/utils/system.ts
|
|
230
|
+
import { hostname as osHostname, platform, release, arch } from "os";
|
|
231
|
+
function getHostname() {
|
|
232
|
+
return osHostname();
|
|
233
|
+
}
|
|
234
|
+
function getSystemInfo() {
|
|
235
|
+
return {
|
|
236
|
+
platform: platform(),
|
|
237
|
+
release: release(),
|
|
238
|
+
arch: arch()
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function formatHostname(name) {
|
|
242
|
+
const raw = name ?? getHostname();
|
|
243
|
+
return raw.replace(/\s+/g, "-").replace(/\./g, "-").replace(/[^a-zA-Z0-9\-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/utils/format.ts
|
|
247
|
+
function formatDatetime(date) {
|
|
248
|
+
const y = date.getFullYear();
|
|
249
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
250
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
251
|
+
const h = String(date.getHours()).padStart(2, "0");
|
|
252
|
+
const min = String(date.getMinutes()).padStart(2, "0");
|
|
253
|
+
const s = String(date.getSeconds()).padStart(2, "0");
|
|
254
|
+
return `${y}-${m}-${d}_${h}${min}${s}`;
|
|
255
|
+
}
|
|
256
|
+
function generateFilename(pattern, options) {
|
|
257
|
+
const now = options?.date ?? /* @__PURE__ */ new Date();
|
|
258
|
+
const host = formatHostname(options?.hostname);
|
|
259
|
+
const y = now.getFullYear();
|
|
260
|
+
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
261
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
262
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
263
|
+
const min = String(now.getMinutes()).padStart(2, "0");
|
|
264
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
265
|
+
let result = pattern.replace(/\{hostname\}/g, host).replace(/\{date\}/g, `${y}-${m}-${d}`).replace(/\{time\}/g, `${h}${min}${s}`).replace(/\{datetime\}/g, `${y}-${m}-${d}_${h}${min}${s}`);
|
|
266
|
+
if (options?.tag) {
|
|
267
|
+
result = result.replace(/\{tag\}/g, options.tag);
|
|
268
|
+
if (!pattern.includes("{tag}")) {
|
|
269
|
+
result += `_${options.tag}`;
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
result = result.replace(/\{tag\}/g, "").replace(/_+/g, "_").replace(/_$/, "");
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/utils/logger.ts
|
|
278
|
+
import { appendFile, mkdir as mkdir2 } from "fs/promises";
|
|
279
|
+
import { join as join5 } from "path";
|
|
280
|
+
import pc from "picocolors";
|
|
281
|
+
var ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
282
|
+
function stripAnsi(str) {
|
|
283
|
+
return str.replace(ANSI_RE, "");
|
|
284
|
+
}
|
|
285
|
+
function timestamp() {
|
|
286
|
+
const now = /* @__PURE__ */ new Date();
|
|
287
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
288
|
+
const m = String(now.getMinutes()).padStart(2, "0");
|
|
289
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
290
|
+
return `${h}:${m}:${s}`;
|
|
291
|
+
}
|
|
292
|
+
function dateStamp() {
|
|
293
|
+
const now = /* @__PURE__ */ new Date();
|
|
294
|
+
const y = now.getFullYear();
|
|
295
|
+
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
296
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
297
|
+
return `${y}-${m}-${d}`;
|
|
298
|
+
}
|
|
299
|
+
var logDirCreated = false;
|
|
300
|
+
async function writeToFile(level, message) {
|
|
301
|
+
try {
|
|
302
|
+
const logsDir = join5(getAppDir(), LOGS_DIR);
|
|
303
|
+
if (!logDirCreated) {
|
|
304
|
+
await mkdir2(logsDir, { recursive: true });
|
|
305
|
+
logDirCreated = true;
|
|
306
|
+
}
|
|
307
|
+
const logFile = join5(logsDir, `${dateStamp()}.log`);
|
|
308
|
+
const line = `[${timestamp()}] [${level.toUpperCase()}] ${stripAnsi(message)}
|
|
309
|
+
`;
|
|
310
|
+
await appendFile(logFile, line, "utf-8");
|
|
311
|
+
} catch {
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
var logger = {
|
|
315
|
+
info(message) {
|
|
316
|
+
console.log(`${pc.blue("info")} ${message}`);
|
|
317
|
+
void writeToFile("info", message);
|
|
318
|
+
},
|
|
319
|
+
success(message) {
|
|
320
|
+
console.log(`${pc.green("success")} ${message}`);
|
|
321
|
+
void writeToFile("success", message);
|
|
322
|
+
},
|
|
323
|
+
warn(message) {
|
|
324
|
+
console.warn(`${pc.yellow("warn")} ${message}`);
|
|
325
|
+
void writeToFile("warn", message);
|
|
326
|
+
},
|
|
327
|
+
error(message) {
|
|
328
|
+
console.error(`${pc.red("error")} ${message}`);
|
|
329
|
+
void writeToFile("error", message);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// src/core/metadata.ts
|
|
334
|
+
import { createHash } from "crypto";
|
|
335
|
+
import { readFile as readFile2, lstat } from "fs/promises";
|
|
336
|
+
|
|
337
|
+
// src/schemas/metadata.schema.ts
|
|
338
|
+
var metadataSchema = {
|
|
339
|
+
type: "object",
|
|
340
|
+
required: [
|
|
341
|
+
"version",
|
|
342
|
+
"toolVersion",
|
|
343
|
+
"createdAt",
|
|
344
|
+
"hostname",
|
|
345
|
+
"system",
|
|
346
|
+
"config",
|
|
347
|
+
"files",
|
|
348
|
+
"summary"
|
|
349
|
+
],
|
|
350
|
+
properties: {
|
|
351
|
+
version: { type: "string" },
|
|
352
|
+
toolVersion: { type: "string" },
|
|
353
|
+
createdAt: { type: "string" },
|
|
354
|
+
hostname: { type: "string" },
|
|
355
|
+
system: {
|
|
356
|
+
type: "object",
|
|
357
|
+
required: ["platform", "release", "arch"],
|
|
358
|
+
properties: {
|
|
359
|
+
platform: { type: "string" },
|
|
360
|
+
release: { type: "string" },
|
|
361
|
+
arch: { type: "string" }
|
|
362
|
+
},
|
|
363
|
+
additionalProperties: false
|
|
364
|
+
},
|
|
365
|
+
config: {
|
|
366
|
+
type: "object",
|
|
367
|
+
required: ["filename"],
|
|
368
|
+
properties: {
|
|
369
|
+
filename: { type: "string" },
|
|
370
|
+
destination: { type: "string" }
|
|
371
|
+
},
|
|
372
|
+
additionalProperties: false
|
|
373
|
+
},
|
|
374
|
+
files: {
|
|
375
|
+
type: "array",
|
|
376
|
+
items: {
|
|
377
|
+
type: "object",
|
|
378
|
+
required: ["path", "absolutePath", "size", "hash"],
|
|
379
|
+
properties: {
|
|
380
|
+
path: { type: "string" },
|
|
381
|
+
absolutePath: { type: "string" },
|
|
382
|
+
size: { type: "number", minimum: 0 },
|
|
383
|
+
hash: { type: "string" },
|
|
384
|
+
type: { type: "string" }
|
|
385
|
+
},
|
|
386
|
+
additionalProperties: false
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
summary: {
|
|
390
|
+
type: "object",
|
|
391
|
+
required: ["fileCount", "totalSize"],
|
|
392
|
+
properties: {
|
|
393
|
+
fileCount: { type: "integer", minimum: 0 },
|
|
394
|
+
totalSize: { type: "number", minimum: 0 }
|
|
395
|
+
},
|
|
396
|
+
additionalProperties: false
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
additionalProperties: false
|
|
400
|
+
};
|
|
401
|
+
var validate2 = ajv.compile(metadataSchema);
|
|
402
|
+
function validateMetadata(data) {
|
|
403
|
+
const valid = validate2(data);
|
|
404
|
+
if (valid) return { valid: true };
|
|
405
|
+
const errors = validate2.errors?.map(
|
|
406
|
+
(e) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`
|
|
407
|
+
);
|
|
408
|
+
return { valid: false, errors };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/core/metadata.ts
|
|
412
|
+
var METADATA_VERSION = "1.0.0";
|
|
413
|
+
function createMetadata(files, config) {
|
|
414
|
+
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
415
|
+
return {
|
|
416
|
+
version: METADATA_VERSION,
|
|
417
|
+
toolVersion: APP_VERSION,
|
|
418
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
419
|
+
hostname: getHostname(),
|
|
420
|
+
system: getSystemInfo(),
|
|
421
|
+
config: {
|
|
422
|
+
filename: config.backup.filename,
|
|
423
|
+
destination: config.backup.destination
|
|
424
|
+
},
|
|
425
|
+
files,
|
|
426
|
+
summary: {
|
|
427
|
+
fileCount: files.length,
|
|
428
|
+
totalSize
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function parseMetadata(data) {
|
|
433
|
+
const str = typeof data === "string" ? data : data.toString("utf-8");
|
|
434
|
+
const parsed = JSON.parse(str);
|
|
435
|
+
const result = validateMetadata(parsed);
|
|
436
|
+
if (!result.valid) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
`Invalid metadata:
|
|
439
|
+
${(result.errors ?? []).join("\n")}`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return parsed;
|
|
443
|
+
}
|
|
444
|
+
async function computeFileHash(filePath) {
|
|
445
|
+
const content = await readFile2(filePath);
|
|
446
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
447
|
+
return `sha256:${hash}`;
|
|
448
|
+
}
|
|
449
|
+
async function collectFileInfo(absolutePath, logicalPath) {
|
|
450
|
+
const lstats = await lstat(absolutePath);
|
|
451
|
+
let type;
|
|
452
|
+
if (lstats.isSymbolicLink()) {
|
|
453
|
+
type = "symlink";
|
|
454
|
+
} else if (lstats.isDirectory()) {
|
|
455
|
+
type = "directory";
|
|
456
|
+
}
|
|
457
|
+
let hash;
|
|
458
|
+
if (lstats.isSymbolicLink()) {
|
|
459
|
+
hash = `sha256:${createHash("sha256").update(absolutePath).digest("hex")}`;
|
|
460
|
+
} else {
|
|
461
|
+
hash = await computeFileHash(absolutePath);
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
path: contractTilde(logicalPath),
|
|
465
|
+
absolutePath,
|
|
466
|
+
size: lstats.size,
|
|
467
|
+
hash,
|
|
468
|
+
type
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/core/storage.ts
|
|
473
|
+
import { mkdir as mkdir3, mkdtemp, readFile as readFile3, rm, writeFile as writeFile2 } from "fs/promises";
|
|
474
|
+
import { tmpdir } from "os";
|
|
475
|
+
import { join as join6, normalize as normalize2 } from "path";
|
|
476
|
+
import * as tar from "tar";
|
|
477
|
+
async function createArchive(files, outputPath) {
|
|
478
|
+
const tmpDir = await mkdtemp(join6(tmpdir(), "syncpoint-"));
|
|
479
|
+
try {
|
|
480
|
+
const fileNames = [];
|
|
481
|
+
for (const file of files) {
|
|
482
|
+
const targetPath = join6(tmpDir, file.name);
|
|
483
|
+
const parentDir = join6(tmpDir, file.name.split("/").slice(0, -1).join("/"));
|
|
484
|
+
if (parentDir !== tmpDir) {
|
|
485
|
+
await mkdir3(parentDir, { recursive: true });
|
|
486
|
+
}
|
|
487
|
+
if (file.content !== void 0) {
|
|
488
|
+
await writeFile2(targetPath, file.content);
|
|
489
|
+
} else if (file.sourcePath) {
|
|
490
|
+
const data = await readFile3(file.sourcePath);
|
|
491
|
+
await writeFile2(targetPath, data);
|
|
492
|
+
}
|
|
493
|
+
fileNames.push(file.name);
|
|
494
|
+
}
|
|
495
|
+
await tar.create(
|
|
496
|
+
{
|
|
497
|
+
gzip: true,
|
|
498
|
+
file: outputPath,
|
|
499
|
+
cwd: tmpDir
|
|
500
|
+
},
|
|
501
|
+
fileNames
|
|
502
|
+
);
|
|
503
|
+
} finally {
|
|
504
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function extractArchive(archivePath, destDir) {
|
|
508
|
+
await mkdir3(destDir, { recursive: true });
|
|
509
|
+
await tar.extract({
|
|
510
|
+
file: archivePath,
|
|
511
|
+
cwd: destDir,
|
|
512
|
+
preservePaths: false,
|
|
513
|
+
filter: (path, entry) => {
|
|
514
|
+
const normalizedPath = normalize2(path);
|
|
515
|
+
if (normalizedPath.includes("..")) return false;
|
|
516
|
+
if (normalizedPath.startsWith("/")) return false;
|
|
517
|
+
if (entry.type === "SymbolicLink" || entry.type === "Link") return false;
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
async function readFileFromArchive(archivePath, filename) {
|
|
523
|
+
if (filename.includes("..") || filename.startsWith("/")) {
|
|
524
|
+
throw new Error(`Invalid filename: ${filename}`);
|
|
525
|
+
}
|
|
526
|
+
const tmpDir = await mkdtemp(join6(tmpdir(), "syncpoint-read-"));
|
|
527
|
+
try {
|
|
528
|
+
await tar.extract({
|
|
529
|
+
file: archivePath,
|
|
530
|
+
cwd: tmpDir,
|
|
531
|
+
filter: (path) => {
|
|
532
|
+
const normalized = path.replace(/^\.\//, "");
|
|
533
|
+
return normalized === filename;
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
const extractedPath = join6(tmpDir, filename);
|
|
537
|
+
try {
|
|
538
|
+
return await readFile3(extractedPath);
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
} finally {
|
|
543
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/core/backup.ts
|
|
548
|
+
function isSensitiveFile(filePath) {
|
|
549
|
+
const name = basename(filePath);
|
|
550
|
+
return SENSITIVE_PATTERNS.some((pattern) => {
|
|
551
|
+
if (pattern.startsWith("*")) {
|
|
552
|
+
return name.endsWith(pattern.slice(1));
|
|
553
|
+
}
|
|
554
|
+
return name === pattern || filePath.includes(pattern);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
async function scanTargets(config) {
|
|
558
|
+
const found = [];
|
|
559
|
+
const missing = [];
|
|
560
|
+
for (const target of config.backup.targets) {
|
|
561
|
+
const expanded = expandTilde(target);
|
|
562
|
+
if (expanded.includes("*") || expanded.includes("?") || expanded.includes("{")) {
|
|
563
|
+
const matches = await fg(expanded, {
|
|
564
|
+
dot: true,
|
|
565
|
+
absolute: true,
|
|
566
|
+
ignore: config.backup.exclude,
|
|
567
|
+
onlyFiles: true
|
|
568
|
+
});
|
|
569
|
+
for (const match of matches) {
|
|
570
|
+
const entry = await collectFileInfo(match, match);
|
|
571
|
+
found.push(entry);
|
|
572
|
+
}
|
|
573
|
+
} else {
|
|
574
|
+
const absPath = resolveTargetPath(target);
|
|
575
|
+
const exists = await fileExists(absPath);
|
|
576
|
+
if (!exists) {
|
|
577
|
+
missing.push(target);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const entry = await collectFileInfo(absPath, absPath);
|
|
581
|
+
if (entry.size > LARGE_FILE_THRESHOLD) {
|
|
582
|
+
logger.warn(
|
|
583
|
+
`Large file (>${Math.round(LARGE_FILE_THRESHOLD / 1024 / 1024)}MB): ${target}`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (isSensitiveFile(absPath)) {
|
|
587
|
+
logger.warn(`Sensitive file detected: ${target}`);
|
|
588
|
+
}
|
|
589
|
+
found.push(entry);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return { found, missing };
|
|
593
|
+
}
|
|
594
|
+
async function collectScripts() {
|
|
595
|
+
const scriptsDir = getSubDir(SCRIPTS_DIR);
|
|
596
|
+
const exists = await fileExists(scriptsDir);
|
|
597
|
+
if (!exists) return [];
|
|
598
|
+
const entries = [];
|
|
599
|
+
try {
|
|
600
|
+
const files = await readdir(scriptsDir, { withFileTypes: true });
|
|
601
|
+
for (const file of files) {
|
|
602
|
+
if (file.isFile() && file.name.endsWith(".sh")) {
|
|
603
|
+
const absPath = join7(scriptsDir, file.name);
|
|
604
|
+
const entry = await collectFileInfo(absPath, absPath);
|
|
605
|
+
entries.push(entry);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
609
|
+
logger.info("Skipping unreadable scripts directory");
|
|
610
|
+
}
|
|
611
|
+
return entries;
|
|
612
|
+
}
|
|
613
|
+
async function createBackup(config, options = {}) {
|
|
614
|
+
const { found, missing } = await scanTargets(config);
|
|
615
|
+
for (const m of missing) {
|
|
616
|
+
logger.warn(`File not found, skipping: ${m}`);
|
|
617
|
+
}
|
|
618
|
+
let allFiles = [...found];
|
|
619
|
+
if (config.scripts.includeInBackup) {
|
|
620
|
+
const scripts = await collectScripts();
|
|
621
|
+
allFiles = [...allFiles, ...scripts];
|
|
622
|
+
}
|
|
623
|
+
if (allFiles.length === 0) {
|
|
624
|
+
throw new Error("No files found to backup.");
|
|
625
|
+
}
|
|
626
|
+
const metadata = createMetadata(allFiles, config);
|
|
627
|
+
const filename = generateFilename(config.backup.filename, {
|
|
628
|
+
tag: options.tag
|
|
629
|
+
});
|
|
630
|
+
const archiveFilename = `${filename}.tar.gz`;
|
|
631
|
+
const destDir = config.backup.destination ? resolveTargetPath(config.backup.destination) : getSubDir(BACKUPS_DIR);
|
|
632
|
+
await ensureDir(destDir);
|
|
633
|
+
const archivePath = join7(destDir, archiveFilename);
|
|
634
|
+
if (options.dryRun) {
|
|
635
|
+
return { archivePath, metadata };
|
|
636
|
+
}
|
|
637
|
+
const archiveFiles = [];
|
|
638
|
+
archiveFiles.push({
|
|
639
|
+
name: METADATA_FILENAME,
|
|
640
|
+
content: JSON.stringify(metadata, null, 2)
|
|
641
|
+
});
|
|
642
|
+
for (const file of allFiles) {
|
|
643
|
+
archiveFiles.push({
|
|
644
|
+
name: file.path.startsWith("~/") ? file.path.slice(2) : file.path,
|
|
645
|
+
sourcePath: file.absolutePath
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
await createArchive(archiveFiles, archivePath);
|
|
649
|
+
logger.success(`Backup created: ${archivePath}`);
|
|
650
|
+
return { archivePath, metadata };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/core/restore.ts
|
|
654
|
+
import { copyFile, lstat as lstat2, readdir as readdir2, stat as stat2 } from "fs/promises";
|
|
655
|
+
import { join as join8, dirname as dirname2 } from "path";
|
|
656
|
+
async function getBackupList(config) {
|
|
657
|
+
const backupDir = config?.backup.destination ? resolveTargetPath(config.backup.destination) : getSubDir(BACKUPS_DIR);
|
|
658
|
+
const exists = await fileExists(backupDir);
|
|
659
|
+
if (!exists) return [];
|
|
660
|
+
const entries = await readdir2(backupDir, { withFileTypes: true });
|
|
661
|
+
const backups = [];
|
|
662
|
+
for (const entry of entries) {
|
|
663
|
+
if (!entry.isFile() || !entry.name.endsWith(".tar.gz")) continue;
|
|
664
|
+
const fullPath = join8(backupDir, entry.name);
|
|
665
|
+
const fileStat = await stat2(fullPath);
|
|
666
|
+
let hostname;
|
|
667
|
+
let fileCount;
|
|
668
|
+
try {
|
|
669
|
+
const metaBuf = await readFileFromArchive(fullPath, METADATA_FILENAME);
|
|
670
|
+
if (metaBuf) {
|
|
671
|
+
const meta = parseMetadata(metaBuf);
|
|
672
|
+
hostname = meta.hostname;
|
|
673
|
+
fileCount = meta.summary.fileCount;
|
|
674
|
+
}
|
|
675
|
+
} catch {
|
|
676
|
+
logger.info(`Could not read metadata from: ${entry.name}`);
|
|
677
|
+
}
|
|
678
|
+
backups.push({
|
|
679
|
+
filename: entry.name,
|
|
680
|
+
path: fullPath,
|
|
681
|
+
size: fileStat.size,
|
|
682
|
+
createdAt: fileStat.mtime,
|
|
683
|
+
hostname,
|
|
684
|
+
fileCount
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
688
|
+
return backups;
|
|
689
|
+
}
|
|
690
|
+
async function getRestorePlan(archivePath) {
|
|
691
|
+
const metaBuf = await readFileFromArchive(archivePath, METADATA_FILENAME);
|
|
692
|
+
if (!metaBuf) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`No metadata found in archive: ${archivePath}
|
|
695
|
+
This may not be a valid syncpoint backup.`
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const metadata = parseMetadata(metaBuf);
|
|
699
|
+
const actions = [];
|
|
700
|
+
for (const file of metadata.files) {
|
|
701
|
+
const absPath = resolveTargetPath(file.path);
|
|
702
|
+
const exists = await fileExists(absPath);
|
|
703
|
+
if (!exists) {
|
|
704
|
+
actions.push({
|
|
705
|
+
path: file.path,
|
|
706
|
+
action: "create",
|
|
707
|
+
backupSize: file.size,
|
|
708
|
+
reason: "File does not exist on this machine"
|
|
709
|
+
});
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
const currentHash = await computeFileHash(absPath);
|
|
713
|
+
const currentStat = await stat2(absPath);
|
|
714
|
+
if (currentHash === file.hash) {
|
|
715
|
+
actions.push({
|
|
716
|
+
path: file.path,
|
|
717
|
+
action: "skip",
|
|
718
|
+
currentSize: currentStat.size,
|
|
719
|
+
backupSize: file.size,
|
|
720
|
+
reason: "File is identical (same hash)"
|
|
721
|
+
});
|
|
722
|
+
} else {
|
|
723
|
+
actions.push({
|
|
724
|
+
path: file.path,
|
|
725
|
+
action: "overwrite",
|
|
726
|
+
currentSize: currentStat.size,
|
|
727
|
+
backupSize: file.size,
|
|
728
|
+
reason: "File has been modified"
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return { metadata, actions };
|
|
733
|
+
}
|
|
734
|
+
async function createSafetyBackup(filePaths) {
|
|
735
|
+
const now = /* @__PURE__ */ new Date();
|
|
736
|
+
const filename = `_pre-restore_${formatDatetime(now)}.tar.gz`;
|
|
737
|
+
const backupDir = getSubDir(BACKUPS_DIR);
|
|
738
|
+
await ensureDir(backupDir);
|
|
739
|
+
const archivePath = join8(backupDir, filename);
|
|
740
|
+
const files = [];
|
|
741
|
+
for (const fp of filePaths) {
|
|
742
|
+
const absPath = resolveTargetPath(fp);
|
|
743
|
+
const exists = await fileExists(absPath);
|
|
744
|
+
if (!exists) continue;
|
|
745
|
+
const archiveName = fp.startsWith("~/") ? fp.slice(2) : fp;
|
|
746
|
+
files.push({ name: archiveName, sourcePath: absPath });
|
|
747
|
+
}
|
|
748
|
+
if (files.length === 0) {
|
|
749
|
+
logger.info("No existing files to safety-backup.");
|
|
750
|
+
return archivePath;
|
|
751
|
+
}
|
|
752
|
+
await createArchive(files, archivePath);
|
|
753
|
+
logger.info(`Safety backup created: ${archivePath}`);
|
|
754
|
+
return archivePath;
|
|
755
|
+
}
|
|
756
|
+
async function restoreBackup(archivePath, options = {}) {
|
|
757
|
+
const plan = await getRestorePlan(archivePath);
|
|
758
|
+
const restoredFiles = [];
|
|
759
|
+
const skippedFiles = [];
|
|
760
|
+
const overwritePaths = plan.actions.filter((a) => a.action === "overwrite").map((a) => a.path);
|
|
761
|
+
let safetyBackupPath;
|
|
762
|
+
if (overwritePaths.length > 0 && !options.dryRun) {
|
|
763
|
+
safetyBackupPath = await createSafetyBackup(overwritePaths);
|
|
764
|
+
}
|
|
765
|
+
if (options.dryRun) {
|
|
766
|
+
return {
|
|
767
|
+
restoredFiles: plan.actions.filter((a) => a.action !== "skip").map((a) => a.path),
|
|
768
|
+
skippedFiles: plan.actions.filter((a) => a.action === "skip").map((a) => a.path),
|
|
769
|
+
safetyBackupPath
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
const { mkdtemp: mkdtemp2, rm: rm2 } = await import("fs/promises");
|
|
773
|
+
const { tmpdir: tmpdir2 } = await import("os");
|
|
774
|
+
const tmpDir = await mkdtemp2(join8(tmpdir2(), "syncpoint-restore-"));
|
|
775
|
+
try {
|
|
776
|
+
await extractArchive(archivePath, tmpDir);
|
|
777
|
+
for (const action of plan.actions) {
|
|
778
|
+
if (action.action === "skip") {
|
|
779
|
+
skippedFiles.push(action.path);
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const archiveName = action.path.startsWith("~/") ? action.path.slice(2) : action.path;
|
|
783
|
+
const extractedPath = join8(tmpDir, archiveName);
|
|
784
|
+
const destPath = resolveTargetPath(action.path);
|
|
785
|
+
const extractedExists = await fileExists(extractedPath);
|
|
786
|
+
if (!extractedExists) {
|
|
787
|
+
logger.warn(`File not found in archive: ${archiveName}`);
|
|
788
|
+
skippedFiles.push(action.path);
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
await ensureDir(dirname2(destPath));
|
|
792
|
+
try {
|
|
793
|
+
const destStat = await lstat2(destPath);
|
|
794
|
+
if (destStat.isSymbolicLink()) {
|
|
795
|
+
logger.warn(`Skipping symlink target: ${action.path}`);
|
|
796
|
+
skippedFiles.push(action.path);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
} catch (err) {
|
|
800
|
+
if (err.code !== "ENOENT") throw err;
|
|
801
|
+
}
|
|
802
|
+
await copyFile(extractedPath, destPath);
|
|
803
|
+
restoredFiles.push(action.path);
|
|
804
|
+
}
|
|
805
|
+
} finally {
|
|
806
|
+
await rm2(tmpDir, { recursive: true, force: true });
|
|
807
|
+
}
|
|
808
|
+
return { restoredFiles, skippedFiles, safetyBackupPath };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/core/provision.ts
|
|
812
|
+
import { exec } from "child_process";
|
|
813
|
+
import { readFile as readFile4, readdir as readdir3 } from "fs/promises";
|
|
814
|
+
import { join as join9 } from "path";
|
|
815
|
+
import YAML2 from "yaml";
|
|
816
|
+
|
|
817
|
+
// src/schemas/template.schema.ts
|
|
818
|
+
var templateSchema = {
|
|
819
|
+
type: "object",
|
|
820
|
+
required: ["name", "steps"],
|
|
821
|
+
properties: {
|
|
822
|
+
name: { type: "string", minLength: 1 },
|
|
823
|
+
description: { type: "string" },
|
|
824
|
+
backup: { type: "string" },
|
|
825
|
+
sudo: { type: "boolean" },
|
|
826
|
+
steps: {
|
|
827
|
+
type: "array",
|
|
828
|
+
minItems: 1,
|
|
829
|
+
items: {
|
|
830
|
+
type: "object",
|
|
831
|
+
required: ["name", "command"],
|
|
832
|
+
properties: {
|
|
833
|
+
name: { type: "string", minLength: 1 },
|
|
834
|
+
description: { type: "string" },
|
|
835
|
+
command: { type: "string", minLength: 1 },
|
|
836
|
+
skip_if: { type: "string" },
|
|
837
|
+
continue_on_error: { type: "boolean" }
|
|
838
|
+
},
|
|
839
|
+
additionalProperties: false
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
additionalProperties: false
|
|
844
|
+
};
|
|
845
|
+
var validate3 = ajv.compile(templateSchema);
|
|
846
|
+
function validateTemplate(data) {
|
|
847
|
+
const valid = validate3(data);
|
|
848
|
+
if (valid) return { valid: true };
|
|
849
|
+
const errors = validate3.errors?.map(
|
|
850
|
+
(e) => `${e.instancePath || "/"} ${e.message ?? "unknown error"}`
|
|
851
|
+
);
|
|
852
|
+
return { valid: false, errors };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// src/core/provision.ts
|
|
856
|
+
var REMOTE_SCRIPT_PATTERNS = [
|
|
857
|
+
/curl\s.*\|\s*(ba)?sh/,
|
|
858
|
+
/wget\s.*\|\s*(ba)?sh/,
|
|
859
|
+
/curl\s.*\|\s*python/,
|
|
860
|
+
/wget\s.*\|\s*python/
|
|
861
|
+
];
|
|
862
|
+
function containsRemoteScriptPattern(command) {
|
|
863
|
+
return REMOTE_SCRIPT_PATTERNS.some((p) => p.test(command));
|
|
864
|
+
}
|
|
865
|
+
function sanitizeErrorOutput(output) {
|
|
866
|
+
return output.replace(/\/Users\/[^\s/]+/g, "/Users/***").replace(/\/home\/[^\s/]+/g, "/home/***").replace(/(password|token|key|secret)[=:]\s*\S+/gi, "$1=***").slice(0, 500);
|
|
867
|
+
}
|
|
868
|
+
async function loadTemplate(templatePath) {
|
|
869
|
+
const exists = await fileExists(templatePath);
|
|
870
|
+
if (!exists) {
|
|
871
|
+
throw new Error(`Template not found: ${templatePath}`);
|
|
872
|
+
}
|
|
873
|
+
const raw = await readFile4(templatePath, "utf-8");
|
|
874
|
+
const data = YAML2.parse(raw);
|
|
875
|
+
const result = validateTemplate(data);
|
|
876
|
+
if (!result.valid) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`Invalid template ${templatePath}:
|
|
879
|
+
${(result.errors ?? []).join("\n")}`
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
return data;
|
|
883
|
+
}
|
|
884
|
+
async function listTemplates() {
|
|
885
|
+
const templatesDir = getSubDir(TEMPLATES_DIR);
|
|
886
|
+
const exists = await fileExists(templatesDir);
|
|
887
|
+
if (!exists) return [];
|
|
888
|
+
const entries = await readdir3(templatesDir, { withFileTypes: true });
|
|
889
|
+
const templates = [];
|
|
890
|
+
for (const entry of entries) {
|
|
891
|
+
if (!entry.isFile() || !entry.name.endsWith(".yml") && !entry.name.endsWith(".yaml")) {
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
const fullPath = join9(templatesDir, entry.name);
|
|
895
|
+
try {
|
|
896
|
+
const config = await loadTemplate(fullPath);
|
|
897
|
+
templates.push({
|
|
898
|
+
name: entry.name.replace(/\.ya?ml$/, ""),
|
|
899
|
+
path: fullPath,
|
|
900
|
+
config
|
|
901
|
+
});
|
|
902
|
+
} catch {
|
|
903
|
+
logger.warn(`Skipping invalid template: ${entry.name}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return templates;
|
|
907
|
+
}
|
|
908
|
+
function execAsync(command) {
|
|
909
|
+
return new Promise((resolve2, reject) => {
|
|
910
|
+
exec(
|
|
911
|
+
command,
|
|
912
|
+
{ shell: "/bin/bash", timeout: 3e5 },
|
|
913
|
+
(error, stdout, stderr) => {
|
|
914
|
+
if (error) {
|
|
915
|
+
reject(
|
|
916
|
+
Object.assign(error, {
|
|
917
|
+
stdout: stdout?.toString() ?? "",
|
|
918
|
+
stderr: stderr?.toString() ?? ""
|
|
919
|
+
})
|
|
920
|
+
);
|
|
921
|
+
} else {
|
|
922
|
+
resolve2({
|
|
923
|
+
stdout: stdout?.toString() ?? "",
|
|
924
|
+
stderr: stderr?.toString() ?? ""
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
);
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
async function evaluateSkipIf(command, stepName) {
|
|
932
|
+
if (containsRemoteScriptPattern(command)) {
|
|
933
|
+
throw new Error(`Blocked dangerous remote script pattern in skip_if: ${stepName}`);
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
await execAsync(command);
|
|
937
|
+
return true;
|
|
938
|
+
} catch {
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async function executeStep(step) {
|
|
943
|
+
const startTime = Date.now();
|
|
944
|
+
if (containsRemoteScriptPattern(step.command)) {
|
|
945
|
+
throw new Error(`Blocked dangerous remote script pattern in command: ${step.name}`);
|
|
946
|
+
}
|
|
947
|
+
if (step.skip_if) {
|
|
948
|
+
const shouldSkip = await evaluateSkipIf(step.skip_if, step.name);
|
|
949
|
+
if (shouldSkip) {
|
|
950
|
+
return {
|
|
951
|
+
name: step.name,
|
|
952
|
+
status: "skipped",
|
|
953
|
+
duration: Date.now() - startTime
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const { stdout, stderr } = await execAsync(step.command);
|
|
959
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
960
|
+
return {
|
|
961
|
+
name: step.name,
|
|
962
|
+
status: "success",
|
|
963
|
+
duration: Date.now() - startTime,
|
|
964
|
+
output: output || void 0
|
|
965
|
+
};
|
|
966
|
+
} catch (err) {
|
|
967
|
+
const error = err;
|
|
968
|
+
const errorOutput = [error.stdout, error.stderr, error.message].filter(Boolean).join("\n").trim();
|
|
969
|
+
return {
|
|
970
|
+
name: step.name,
|
|
971
|
+
status: "failed",
|
|
972
|
+
duration: Date.now() - startTime,
|
|
973
|
+
error: sanitizeErrorOutput(errorOutput)
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
async function* runProvision(templatePath, options = {}) {
|
|
978
|
+
const template = await loadTemplate(templatePath);
|
|
979
|
+
for (const step of template.steps) {
|
|
980
|
+
if (options.dryRun) {
|
|
981
|
+
let status = "pending";
|
|
982
|
+
if (step.skip_if) {
|
|
983
|
+
const shouldSkip = await evaluateSkipIf(step.skip_if, step.name);
|
|
984
|
+
if (shouldSkip) status = "skipped";
|
|
985
|
+
}
|
|
986
|
+
yield {
|
|
987
|
+
name: step.name,
|
|
988
|
+
status
|
|
989
|
+
};
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
yield {
|
|
993
|
+
name: step.name,
|
|
994
|
+
status: "running"
|
|
995
|
+
};
|
|
996
|
+
const result = await executeStep(step);
|
|
997
|
+
yield result;
|
|
998
|
+
if (result.status === "failed" && !step.continue_on_error) {
|
|
999
|
+
logger.error(
|
|
1000
|
+
`Step "${step.name}" failed. Stopping provisioning.`
|
|
1001
|
+
);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
export {
|
|
1007
|
+
createBackup,
|
|
1008
|
+
getBackupList,
|
|
1009
|
+
getRestorePlan,
|
|
1010
|
+
initDefaultConfig,
|
|
1011
|
+
listTemplates,
|
|
1012
|
+
loadConfig,
|
|
1013
|
+
loadTemplate,
|
|
1014
|
+
restoreBackup,
|
|
1015
|
+
runProvision,
|
|
1016
|
+
saveConfig,
|
|
1017
|
+
scanTargets
|
|
1018
|
+
};
|