feat(workflows): add revision history, trash, UUID naming, and backup

- 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>
This commit is contained in:
Cursor Agent
2026-08-20 02:14:50 +00:00
co-authored by nsrb
parent 611511ae60
commit 7d51ff433f
20 changed files with 1943 additions and 206 deletions
+134
View File
@@ -0,0 +1,134 @@
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 });
}
}