- Store up to 50 revisions per workflow in SQLite with normalized SHA dedup - Soft-delete workflows to trash (7-day retention) with restore and permanent purge - Assign UUID filenames for new and duplicated workflows; show name + file in UI - Warn on invalid YAML, unknown scripts, and plaintext secrets with save-anyway option - Add workflow backup zip (workflows + plugins) and merge/replace restore - Trash page, history panel on editor, and smoke test Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
135 lines
4.0 KiB
JavaScript
135 lines
4.0 KiB
JavaScript
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { randomUUID } from "node:crypto";
|
|
import { DATA_DIR, PLUGINS_DIR, WORKFLOWS_DIR } from "./paths.js";
|
|
import { getAppVersion } from "./app-version.js";
|
|
import * as fsStore from "./fs-store.js";
|
|
import { listInstalledPlugins } from "./plugin-store.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* @param {string} dir
|
|
* @param {string} zipPath
|
|
*/
|
|
async function zipDirectory(dir, zipPath) {
|
|
await execFileAsync("zip", ["-r", zipPath, "."], { cwd: dir });
|
|
}
|
|
|
|
/**
|
|
* @param {string} zipPath
|
|
* @param {string} destDir
|
|
*/
|
|
async function unzipArchive(zipPath, destDir) {
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
await execFileAsync("unzip", ["-o", zipPath, "-d", destDir]);
|
|
}
|
|
|
|
/**
|
|
* Copy directory recursively.
|
|
* @param {string} src
|
|
* @param {string} dest
|
|
*/
|
|
function copyDir(src, dest) {
|
|
if (!fs.existsSync(src)) return;
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
const from = path.join(src, entry.name);
|
|
const to = path.join(dest, entry.name);
|
|
if (entry.isDirectory()) copyDir(from, to);
|
|
else fs.copyFileSync(from, to);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a backup zip buffer (workflows + installed plugins + manifest).
|
|
*/
|
|
export async function createWorkflowBackupBuffer() {
|
|
const staging = path.join(DATA_DIR, `.backup-staging-${randomUUID()}`);
|
|
fs.mkdirSync(staging, { recursive: true });
|
|
const zipPath = path.join(DATA_DIR, `.backup-${randomUUID()}.zip`);
|
|
|
|
try {
|
|
const wfDest = path.join(staging, "workflows");
|
|
copyDir(WORKFLOWS_DIR, wfDest);
|
|
|
|
const pluginsDest = path.join(staging, "plugins");
|
|
copyDir(PLUGINS_DIR, pluginsDest);
|
|
|
|
const manifest = {
|
|
version: getAppVersion(),
|
|
created_at: new Date().toISOString(),
|
|
plugins: listInstalledPlugins().map((p) => p.id),
|
|
owners: fsStore.listOwners(),
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(staging, "manifest.json"),
|
|
JSON.stringify(manifest, null, 2),
|
|
"utf8",
|
|
);
|
|
|
|
await zipDirectory(staging, zipPath);
|
|
return fs.readFileSync(zipPath);
|
|
} finally {
|
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Buffer} zipBuffer
|
|
* @param {{ mode?: "merge" | "replace" }} [opts]
|
|
*/
|
|
export async function restoreWorkflowBackup(zipBuffer, opts = {}) {
|
|
const mode = opts.mode === "replace" ? "replace" : "merge";
|
|
const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "jflow-restore-"));
|
|
const zipPath = path.join(extractDir, "backup.zip");
|
|
fs.writeFileSync(zipPath, zipBuffer);
|
|
|
|
/** @type {string[]} */
|
|
const warnings = [];
|
|
|
|
try {
|
|
const contentDir = path.join(extractDir, "content");
|
|
await unzipArchive(zipPath, contentDir);
|
|
|
|
const manifestPath = path.join(contentDir, "manifest.json");
|
|
if (fs.existsSync(manifestPath)) {
|
|
try {
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
for (const pluginId of manifest.plugins ?? []) {
|
|
const dir = path.join(PLUGINS_DIR, pluginId);
|
|
if (!fs.existsSync(dir)) {
|
|
warnings.push(`Plugin "${pluginId}" from backup is not installed`);
|
|
}
|
|
}
|
|
} catch {
|
|
warnings.push("Could not read backup manifest.json");
|
|
}
|
|
}
|
|
|
|
const wfSrc = path.join(contentDir, "workflows");
|
|
if (fs.existsSync(wfSrc)) {
|
|
if (mode === "replace" && fs.existsSync(WORKFLOWS_DIR)) {
|
|
fs.rmSync(WORKFLOWS_DIR, { recursive: true, force: true });
|
|
}
|
|
copyDir(wfSrc, WORKFLOWS_DIR);
|
|
}
|
|
|
|
const pluginsSrc = path.join(contentDir, "plugins");
|
|
if (fs.existsSync(pluginsSrc)) {
|
|
if (mode === "replace" && fs.existsSync(PLUGINS_DIR)) {
|
|
fs.rmSync(PLUGINS_DIR, { recursive: true, force: true });
|
|
}
|
|
copyDir(pluginsSrc, PLUGINS_DIR);
|
|
}
|
|
|
|
return { ok: true, mode, warnings };
|
|
} finally {
|
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
}
|
|
}
|