n8n-decanter 0.3.2 → 0.5.0

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.
@@ -0,0 +1,124 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { kebabCase } from "./util.mjs";
4
+ /** Top-level dir (next to decanter.config.json) holding fetched data tables. */
5
+ export const DATA_TABLES_DIR = "data-tables";
6
+ /** Default rows fetched per page — the API caps a page at 250. */
7
+ export const DEFAULT_ROW_LIMIT = 100;
8
+ /**
9
+ * Folder slug for a fetched table: the kebab of its name with the id appended
10
+ * (data-table names aren't guaranteed unique, so the id disambiguates). The id
11
+ * is stripped of anything non-alphanumeric to stay filesystem-safe.
12
+ */
13
+ export function dataTableSlug(id, name) {
14
+ const safeId = String(id).replace(/[^A-Za-z0-9]+/g, "") || "id";
15
+ return `${kebabCase(name)}-${safeId}`;
16
+ }
17
+ /** Pick the tables named by `tableRefs` (id or exact name); empty = all. */
18
+ function selectTables(tables, tableRefs) {
19
+ if (tableRefs.length === 0)
20
+ return tables;
21
+ const picked = new Map();
22
+ for (const ref of tableRefs) {
23
+ const lc = ref.toLowerCase();
24
+ const hits = tables.filter((t) => String(t.id) === ref || t.name.toLowerCase() === lc);
25
+ if (hits.length === 0) {
26
+ const known = tables.map((t) => `"${t.name}" (${t.id})`).join(", ");
27
+ throw new Error(`no data table matches "${ref}"${known ? ` — tables: ${known}` : " — no data tables on the server"}`);
28
+ }
29
+ for (const t of hits)
30
+ picked.set(String(t.id), t);
31
+ }
32
+ return [...picked.values()];
33
+ }
34
+ /** Human summary fragment naming the applied server-side filter, or "". */
35
+ function filterSummary(opts) {
36
+ const parts = [];
37
+ if (opts.filter !== undefined)
38
+ parts.push(`filter ${opts.filter}`);
39
+ if (opts.search !== undefined)
40
+ parts.push(`search "${opts.search}"`);
41
+ if (opts.sortBy !== undefined)
42
+ parts.push(`sort ${opts.sortBy}`);
43
+ return parts.length > 0 ? ` [${parts.join(", ")}]` : "";
44
+ }
45
+ /**
46
+ * Fetch data-table schemas + (filtered) rows into a single top-level,
47
+ * self-ignored `data-tables/` dir — mirrors `executions` (read-only, temp,
48
+ * gitignored). Data tables are project-scoped (not owned by a workflow), so
49
+ * they land next to `decanter.config.json`, not under a workflow folder. Each
50
+ * table becomes `data-tables/<slug>/{meta,columns,rows}.json`; `meta.json`
51
+ * records the applied filter/search/sort/limit and the row count so a filtered
52
+ * `rows.json` is self-describing. The CLI never writes any data-table endpoint.
53
+ */
54
+ export async function fetchDataTables(api, configDir, opts, log) {
55
+ let tables;
56
+ try {
57
+ tables = await api.listDataTables();
58
+ }
59
+ catch (err) {
60
+ const msg = err.message;
61
+ if (/\b404\b/.test(msg)) {
62
+ throw new Error(`the n8n data-tables API is not available (404) — data tables need n8n ≥ 2.x; a full-access key still works if your instance predates them\n${msg}`);
63
+ }
64
+ throw err;
65
+ }
66
+ const selected = selectTables(tables, opts.tableRefs ?? []);
67
+ if (selected.length === 0) {
68
+ log.info("no data tables on the server");
69
+ return;
70
+ }
71
+ const outRoot = path.join(configDir, DATA_TABLES_DIR);
72
+ mkdirSync(outRoot, { recursive: true });
73
+ // Self-ignoring dir: data tables can hold PII — same reasoning as executions/.
74
+ writeFileSync(path.join(outRoot, ".gitignore"), "*\n");
75
+ const rel = (p) => path.relative(process.cwd(), p);
76
+ const filterDesc = filterSummary(opts);
77
+ for (const table of selected) {
78
+ const dir = path.join(outRoot, dataTableSlug(table.id, table.name));
79
+ mkdirSync(dir, { recursive: true });
80
+ const columns = await api.getDataTableColumns(String(table.id));
81
+ const rows = [];
82
+ let cursor;
83
+ do {
84
+ const page = await api.getDataTableRows(String(table.id), {
85
+ limit: opts.limit ?? DEFAULT_ROW_LIMIT,
86
+ cursor,
87
+ filter: opts.filter,
88
+ search: opts.search,
89
+ sortBy: opts.sortBy,
90
+ });
91
+ rows.push(...page.data);
92
+ cursor = opts.all ? (page.nextCursor ?? undefined) : undefined;
93
+ } while (cursor !== undefined);
94
+ const meta = {
95
+ id: table.id,
96
+ name: table.name,
97
+ projectId: table.projectId ?? null,
98
+ fetchedAt: new Date().toISOString(),
99
+ rowCount: rows.length,
100
+ // A filtered rows.json is a slice, never the whole table — record what
101
+ // produced it so it can't be mistaken for a full export.
102
+ filter: opts.filter ?? null,
103
+ search: opts.search ?? null,
104
+ sortBy: opts.sortBy ?? null,
105
+ limit: opts.limit ?? DEFAULT_ROW_LIMIT,
106
+ all: Boolean(opts.all),
107
+ };
108
+ writeFileSync(path.join(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
109
+ writeFileSync(path.join(dir, "columns.json"), JSON.stringify(columns, null, 2) + "\n");
110
+ writeFileSync(path.join(dir, "rows.json"), JSON.stringify(rows, null, 2) + "\n");
111
+ log.info(`wrote ${rel(dir)}/{meta,columns,rows}.json (${columns.length} column${columns.length === 1 ? "" : "s"}, ${rows.length} row${rows.length === 1 ? "" : "s"})`);
112
+ }
113
+ log.ok(`${selected.length} data table${selected.length === 1 ? "" : "s"}${filterDesc} -> ${rel(outRoot)} (gitignored — temp data, "data-tables clean" removes it)`);
114
+ }
115
+ /** Offline delete of the local `data-tables/` dir (mirrors cleanExecutions). */
116
+ export function cleanDataTables(configDir, log) {
117
+ const outRoot = path.join(configDir, DATA_TABLES_DIR);
118
+ if (!existsSync(outRoot)) {
119
+ log.info("no data-tables/ dir to clean");
120
+ return;
121
+ }
122
+ rmSync(outRoot, { recursive: true, force: true });
123
+ log.ok(`removed ${path.relative(process.cwd(), outRoot)}`);
124
+ }
@@ -0,0 +1,192 @@
1
+ // Plan 7 — the engine backend for `simulate`. Runs a transformed simulation
2
+ // workflow through a *real* n8n via route B (validated by the Plan 7 spike):
3
+ // `n8n import:workflow` + `n8n execute --id --rawOutput` in a throwaway
4
+ // container, no server and no credentials, output scraped from the result JSON.
5
+ //
6
+ // Backend: Docker (`n8nio/n8n:<version>`). The plan's eventual default is a
7
+ // dependency-free `npx n8n@<ver>` backend; that needs its own validation
8
+ // (heavy native install) and is a follow-up — the backend is pluggable, Docker
9
+ // is what's proven. `--network none` adds an enforced outbound cutoff on top of
10
+ // the structural guarantee (no I/O-capable node survives the transform).
11
+ import { execFile as execFileCb } from "node:child_process";
12
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { promisify } from "node:util";
16
+ const execFile = promisify(execFileCb);
17
+ /** n8n version the engine defaults to — the smoke-suite pin (Plan 7 version policy). */
18
+ /* Keep in sync with test/smoke-n8n.mts IMAGE and .github/workflows/ci.yml smoke floor. */
19
+ export const DEFAULT_N8N_VERSION = "2.30.7";
20
+ /** Fixed workflow id the sim copy is imported under (throwaway DB, one workflow). */
21
+ const SIM_WORKFLOW_ID = "decantersim0000";
22
+ const EXEC_MARKER = "---DECANTER-EXEC---";
23
+ const docker = (args, opts = {}) => execFile("docker", args, { encoding: "utf8", maxBuffer: 128 * 1024 * 1024, timeout: opts.timeoutMs ?? 120_000 });
24
+ /** True when a Docker daemon is reachable — lets `simulate`/`test:sim` skip cleanly. */
25
+ export async function dockerAvailable() {
26
+ try {
27
+ await docker(["version", "--format", "{{.Server.Version}}"], { timeoutMs: 10_000 });
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ /** Isolation env for the engine container (Plan 7 task 4): telemetry/notifications/
35
+ * template fetches off, Code-node module allowlists emptied. Credentials never
36
+ * enter — `docker run` passes only these `-e` values, so host `N8N_*` stays out. */
37
+ function isolationEnv() {
38
+ return [
39
+ "N8N_DIAGNOSTICS_ENABLED=false",
40
+ "N8N_VERSION_NOTIFICATIONS_ENABLED=false",
41
+ "N8N_TEMPLATES_ENABLED=false",
42
+ "N8N_PERSONALIZATION_ENABLED=false",
43
+ "EXTERNAL_FRONTEND_HOOKS_URLS=",
44
+ "NODE_FUNCTION_ALLOW_EXTERNAL=",
45
+ "N8N_USER_FOLDER=/tmp/n8n",
46
+ ];
47
+ }
48
+ /** Normalize an n8n `runData` object (from CLI JSON or the API) into a per-node item map. */
49
+ function runDataToMap(rd) {
50
+ const map = new Map();
51
+ for (const [node, runs] of Object.entries(rd)) {
52
+ const main = runs?.[0]?.data?.main;
53
+ map.set(node, Array.isArray(main) && Array.isArray(main[0]) ? main[0] : []);
54
+ }
55
+ return map;
56
+ }
57
+ /** Parse the `--rawOutput` result JSON that follows our marker, into a runData map. */
58
+ function parseRunData(stdout) {
59
+ const after = stdout.includes(EXEC_MARKER) ? stdout.slice(stdout.indexOf(EXEC_MARKER) + EXEC_MARKER.length) : stdout;
60
+ const start = after.indexOf("{");
61
+ const end = after.lastIndexOf("}");
62
+ if (start === -1 || end <= start) {
63
+ const tail = stdout.trim().split("\n").slice(-4).join(" | ").slice(0, 400);
64
+ return { runData: new Map(), ok: false, error: `engine produced no result JSON — ${tail}` };
65
+ }
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(after.slice(start, end + 1));
69
+ }
70
+ catch (err) {
71
+ return { runData: new Map(), ok: false, error: `unparsable engine result JSON (${err.message})` };
72
+ }
73
+ const error = parsed.data?.resultData?.error?.message;
74
+ return { runData: runDataToMap(parsed.data?.resultData?.runData ?? {}), ok: error === undefined, error };
75
+ }
76
+ /**
77
+ * Import the simulation workflow into a throwaway n8n and execute it, returning
78
+ * per-node output. Throws only on infrastructure failure (no Docker, image
79
+ * missing); a workflow that errors inside the engine comes back as `ok: false`
80
+ * with the message — the caller decides what a failed run means.
81
+ */
82
+ export async function runEngine(workflow, opts, log) {
83
+ const image = `n8nio/n8n:${opts.version}`;
84
+ const tmp = mkdtempSync(path.join(os.tmpdir(), "decanter-sim-"));
85
+ const simFile = path.join(tmp, "sim.json");
86
+ const container = `decanter-sim-${process.pid}-${Date.now()}`;
87
+ writeFileSync(simFile, JSON.stringify({ ...workflow, id: SIM_WORKFLOW_ID, active: false }));
88
+ const runArgs = [
89
+ "run", "--rm", "--name", container,
90
+ ...(opts.networkNone ? ["--network", "none"] : []),
91
+ ...isolationEnv().flatMap((e) => ["-e", e]),
92
+ "-v", `${simFile}:/tmp/sim.json:ro`,
93
+ "--entrypoint", "sh", image,
94
+ "-c", `mkdir -p /tmp/n8n && n8n import:workflow --input=/tmp/sim.json >/tmp/import.log 2>&1 && echo '${EXEC_MARKER}' && n8n execute --id=${SIM_WORKFLOW_ID} --rawOutput 2>/dev/null`,
95
+ ];
96
+ log.info(`engine: ${image}${opts.networkNone ? " (network: none)" : ""} — importing + executing simulation`);
97
+ try {
98
+ const { stdout, stderr } = await docker(runArgs, { timeoutMs: 180_000 });
99
+ return parseRunData(stdout + stderr);
100
+ }
101
+ catch (err) {
102
+ const e = err;
103
+ // A non-zero exit with a result JSON still means "ran, workflow errored".
104
+ if ((e.stdout ?? "").includes(EXEC_MARKER))
105
+ return parseRunData((e.stdout ?? "") + (e.stderr ?? ""));
106
+ throw new Error(`engine run failed (${image}): ${(e.stderr || e.message || "").split("\n").slice(0, 3).join(" ").slice(0, 300)}`);
107
+ }
108
+ finally {
109
+ rmSync(tmp, { recursive: true, force: true });
110
+ await docker(["rm", "-f", container], { timeoutMs: 15_000 }).catch(() => { });
111
+ }
112
+ }
113
+ /** Stable name of the kept-alive viewer container (one at a time; replaced per run). */
114
+ export const VIEWER_CONTAINER = "decanter-sim-viewer";
115
+ /** Fixed local owner for the throwaway viewer — printed so you can log in to browse. */
116
+ export const VIEWER_LOGIN = { email: "simulate@decanter.local", password: "Decanter-Sim-0000", firstName: "Decanter", lastName: "Simulate" };
117
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
118
+ /**
119
+ * Launch a *kept-alive* local n8n that has the simulation run saved and
120
+ * browsable: import + `n8n execute` (persists the run) + `n8n start` (serves
121
+ * it). Returns the execution's UI URL and a local login (n8n requires auth; we
122
+ * seed a fixed throwaway owner). The container is left running (bound to
123
+ * 127.0.0.1 only, throwaway data, no credentials) and replaced on the next
124
+ * viewer run. The diff comes from the separate headless run, so nothing is read
125
+ * back here. Incompatible with `--network none` (a published port needs a
126
+ * network). Throwaway DB ⇒ the run is always execution id 1.
127
+ */
128
+ export async function startViewer(workflow, opts, log) {
129
+ const image = `n8nio/n8n:${opts.version}`;
130
+ // A *stable* file the long-lived viewer bind-mounts: `docker run -d` is
131
+ // detached and the container reads it at startup, so it must outlive this call
132
+ // (unlike the headless run, which isn't detached). Reap the previous viewer
133
+ // before overwriting it, so nothing is holding the old mount.
134
+ const simFile = path.join(os.tmpdir(), "decanter-sim-viewer.json");
135
+ await docker(["rm", "-f", VIEWER_CONTAINER], { timeoutMs: 15_000 }).catch(() => { });
136
+ // `n8n execute` only persists the run (needed for it to be browsable) when the
137
+ // workflow opts in — force the save settings on the viewer copy.
138
+ const viewerWf = {
139
+ ...workflow, id: SIM_WORKFLOW_ID, active: false,
140
+ settings: { ...workflow.settings, saveDataSuccessExecution: "all", saveDataErrorExecution: "all", saveManualExecutions: true },
141
+ };
142
+ writeFileSync(simFile, JSON.stringify(viewerWf));
143
+ log.info(`engine: ${image} — starting a local viewer (kept alive so you can open the run)`);
144
+ // Minimal env: enough to save the run and serve it over http. The strict
145
+ // task-runner isolation (empty module allowlist, etc.) belongs to the headless
146
+ // dry run — the viewer's safety is structural (no credentials survive the
147
+ // transform), and it needs a network for the browser anyway.
148
+ await docker([
149
+ "run", "-d", "--name", VIEWER_CONTAINER, "-p", "127.0.0.1::5678",
150
+ "-e", "N8N_SECURE_COOKIE=false", "-e", "N8N_DIAGNOSTICS_ENABLED=false", "-e", "N8N_PERSONALIZATION_ENABLED=false",
151
+ "-e", "EXECUTIONS_DATA_SAVE_ON_SUCCESS=all", "-e", "EXECUTIONS_DATA_SAVE_ON_ERROR=all", "-e", "EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=true",
152
+ "-v", `${simFile}:/tmp/sim.json:ro`, "--entrypoint", "sh", image,
153
+ "-c", `n8n import:workflow --input=/tmp/sim.json && n8n execute --id=${SIM_WORKFLOW_ID}; n8n start`,
154
+ ], { timeoutMs: 60_000 });
155
+ const host = `http://${(await docker(["port", VIEWER_CONTAINER, "5678"])).stdout.trim().split("\n")[0]}`;
156
+ // wait for REST to serve real JSON (readiness, not just /healthz liveness)
157
+ let ready = false;
158
+ for (let i = 0; i < 90 && !ready; i++) {
159
+ ready = await fetch(`${host}/rest/settings`).then((r) => r.ok && (r.headers.get("content-type") ?? "").includes("json")).catch(() => false);
160
+ if (!ready)
161
+ await sleep(2000);
162
+ }
163
+ if (!ready)
164
+ throw new Error(`viewer n8n never became ready at ${host}`);
165
+ // Seed a fixed local owner so the browser lands on a login (not the setup
166
+ // wizard), and so we can read the saved run back to confirm it's browsable.
167
+ const authCookie = (r) => r.headers.getSetCookie().join("; ").match(/n8n-auth=[^;]+/)?.[0];
168
+ await fetch(`${host}/rest/owner/setup`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(VIEWER_LOGIN) }).catch(() => undefined);
169
+ let cookie;
170
+ for (let i = 0; i < 5 && !cookie; i++) {
171
+ const l = await fetch(`${host}/rest/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ emailOrLdapLoginId: VIEWER_LOGIN.email, password: VIEWER_LOGIN.password }) }).catch(() => undefined);
172
+ cookie = l && authCookie(l);
173
+ if (!cookie)
174
+ await sleep(1000);
175
+ }
176
+ // Poll until the saved run is queryable (it lands a beat after the server
177
+ // finishes initializing) and use its real id for the URL.
178
+ let execId = "1";
179
+ for (let i = 0; i < 20 && cookie; i++) {
180
+ const list = await fetch(`${host}/rest/executions`, { headers: { cookie } }).then((r) => r.json()).catch(() => undefined);
181
+ const first = (list?.data?.results ?? list?.data ?? [])[0];
182
+ if (first?.id) {
183
+ execId = String(first.id);
184
+ break;
185
+ }
186
+ await sleep(1000);
187
+ }
188
+ return {
189
+ url: `${host}/workflow/${SIM_WORKFLOW_ID}/executions/${execId}`,
190
+ login: { email: VIEWER_LOGIN.email, password: VIEWER_LOGIN.password },
191
+ };
192
+ }
@@ -1,7 +1,26 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { findWorkflowDir, listWorkflowDirs } from "./state.mjs";
4
4
  export const EXECUTIONS_DIR = "executions";
5
+ /**
6
+ * The newest captured execution id in a workflow folder's `executions/` dir, or
7
+ * null when none are captured. n8n execution ids are incrementing integers, so
8
+ * "newest" is the highest numeric filename; non-numeric files are ignored. Lets
9
+ * `simulate` (and the picker, which can't supply an id) default to the latest
10
+ * capture.
11
+ */
12
+ export function latestCaptureId(dir) {
13
+ const outDir = path.join(dir, EXECUTIONS_DIR);
14
+ if (!existsSync(outDir))
15
+ return null;
16
+ let best = null;
17
+ for (const entry of readdirSync(outDir)) {
18
+ const m = entry.match(/^(\d+)\.json$/);
19
+ if (m && (best === null || Number(m[1]) > best))
20
+ best = Number(m[1]);
21
+ }
22
+ return best === null ? null : String(best);
23
+ }
5
24
  /**
6
25
  * Warn when fetched executions ran a *published* version that differs from the
7
26
  * local draft (`workflow.json`'s `versionId`) — the captured data may not match
@@ -10,7 +29,7 @@ export const EXECUTIONS_DIR = "executions";
10
29
  * by version so a page of same-version executions warns once; silent when the
11
30
  * draft version or the execution version is unavailable (defensive).
12
31
  */
13
- function warnStaleFixtures(dir, executions, log) {
32
+ export function warnStaleFixtures(dir, executions, log) {
14
33
  const wfFile = path.join(dir, "workflow.json");
15
34
  if (!existsSync(wfFile))
16
35
  return;
package/dist/lib/init.mjs CHANGED
@@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
4
4
  import { parseEnvFile } from "./config.mjs";
5
5
  import { createPrompt } from "./prompt.mjs";
6
6
  import { style } from "./style.mjs";
7
+ import { classifyTemplateFile, MANIFEST_FILE, readManifest, writeManifest } from "./template.mjs";
8
+ import { sha256 } from "./util.mjs";
7
9
  /**
8
10
  * Nearest ancestor of `startDir` holding a package.json — the package root.
9
11
  * Works from the checkout (lib/ → repo root) *and* from the published build
@@ -52,30 +54,151 @@ export function printBanner(log) {
52
54
  console.log(style.red(row.slice(0, 6)) + style.bold(row.slice(6)));
53
55
  console.log(style.dim(`n8n workflows ⇄ agentic code · v${version}`));
54
56
  }
55
- function copyTemplate(srcDir, destDir, { force = false, protect = new Set() } = {}) {
56
- const overwritten = [];
57
- const walk = (src, dest, rel) => {
58
- mkdirSync(dest, { recursive: true });
57
+ /**
58
+ * Classify every template file against the target dir and the copy-time
59
+ * baseline manifest. Pure scan — no files are written.
60
+ */
61
+ function scanTemplate(srcDir, destDir, manifest, protect) {
62
+ const entries = [];
63
+ const walk = (src, rel) => {
59
64
  for (const entry of readdirSync(src, { withFileTypes: true })) {
60
65
  const srcPath = path.join(src, entry.name);
61
66
  if (entry.isDirectory()) {
62
- walk(srcPath, path.join(dest, entry.name), path.join(rel, entry.name));
67
+ walk(srcPath, path.join(rel, entry.name));
63
68
  continue;
64
69
  }
65
70
  const name = entry.name.endsWith(".example") && entry.name !== ".example"
66
71
  ? entry.name.slice(0, -".example".length)
67
72
  : entry.name;
68
- const destPath = path.join(dest, name);
73
+ const materializedRel = path.join(rel, name);
74
+ const destPath = path.join(destDir, materializedRel);
75
+ if (protect.has(destPath))
76
+ continue; // .env: written separately, never manifest-tracked
77
+ const templateHash = sha256(readFileSync(srcPath, "utf8"));
69
78
  const exists = existsSync(destPath);
70
- if (exists && (!force || protect.has(destPath)))
71
- continue;
72
- copyFileSync(srcPath, destPath);
73
- if (exists)
74
- overwritten.push(path.join(rel, name));
79
+ const targetHash = exists ? sha256(readFileSync(destPath, "utf8")) : undefined;
80
+ const manifestHash = manifest[materializedRel];
81
+ entries.push({
82
+ rel: materializedRel,
83
+ srcPath,
84
+ destPath,
85
+ templateHash,
86
+ targetHash,
87
+ outcome: classifyTemplateFile({ exists, targetHash, templateHash, manifestHash }),
88
+ });
75
89
  }
76
90
  };
77
- walk(srcDir, destDir, "");
78
- return overwritten;
91
+ walk(srcDir, "");
92
+ return entries;
93
+ }
94
+ function copyEntry(entry) {
95
+ mkdirSync(path.dirname(entry.destPath), { recursive: true });
96
+ copyFileSync(entry.srcPath, entry.destPath);
97
+ }
98
+ /**
99
+ * Modification-aware template refresh (dpkg conffile-style). First init copies
100
+ * everything and records a baseline manifest. Re-init copies files new to the
101
+ * template, offers to refresh files the user hasn't touched (pristine), and
102
+ * leaves locally-modified files alone while reporting the drift. `--force` is
103
+ * the escape hatch: it overwrites every template file regardless.
104
+ */
105
+ async function refreshTemplate(srcDir, destDir, { force, protect, version }, log) {
106
+ const manifest = readManifest(destDir);
107
+ const firstInit = !existsSync(path.join(destDir, MANIFEST_FILE));
108
+ const entries = scanTemplate(srcDir, destDir, manifest.files, protect);
109
+ const nextFiles = {};
110
+ if (force) {
111
+ let anyExisting = false;
112
+ for (const e of entries) {
113
+ const changed = e.targetHash !== undefined && e.targetHash !== e.templateHash;
114
+ if (e.targetHash !== undefined)
115
+ anyExisting = true;
116
+ copyEntry(e);
117
+ nextFiles[e.rel] = e.templateHash;
118
+ if (e.targetHash !== undefined)
119
+ log.warn(`--force: overwrote ${e.rel} with the template version${changed ? " (had local changes)" : ""}`);
120
+ }
121
+ writeManifest(destDir, { version, files: nextFiles });
122
+ log.info(anyExisting ? `reset template -> ${destDir}` : `copied template -> ${destDir}`);
123
+ return;
124
+ }
125
+ const added = [];
126
+ const pending = [];
127
+ const modified = [];
128
+ const conflicts = [];
129
+ let uptodate = 0;
130
+ for (const e of entries) {
131
+ switch (e.outcome) {
132
+ case "added":
133
+ copyEntry(e);
134
+ nextFiles[e.rel] = e.templateHash;
135
+ added.push(e.rel);
136
+ break;
137
+ case "converged":
138
+ nextFiles[e.rel] = e.templateHash; // adopt: on-disk copy now equals the template
139
+ break;
140
+ case "adopt":
141
+ nextFiles[e.rel] = e.targetHash; // legacy dir: trust the on-disk copy as the baseline
142
+ break;
143
+ case "update":
144
+ pending.push(e);
145
+ nextFiles[e.rel] = manifest.files[e.rel]; // provisional; set to templateHash if applied
146
+ break;
147
+ case "drift-modified":
148
+ modified.push(e.rel);
149
+ nextFiles[e.rel] = manifest.files[e.rel];
150
+ break;
151
+ case "drift-conflict":
152
+ conflicts.push(e.rel);
153
+ nextFiles[e.rel] = manifest.files[e.rel];
154
+ break;
155
+ case "uptodate":
156
+ uptodate++;
157
+ nextFiles[e.rel] = e.templateHash;
158
+ break;
159
+ }
160
+ }
161
+ // On first init everything is "added" — the single "copied template" line
162
+ // below says it; only call out files the template *gained* on a re-init.
163
+ if (!firstInit)
164
+ for (const rel of added)
165
+ log.info(`added ${rel} from the template`);
166
+ if (pending.length > 0) {
167
+ let apply = false;
168
+ if (process.stdin.isTTY) {
169
+ const rl = createPrompt();
170
+ try {
171
+ log.info(`${pending.length} template file(s) have newer versions and are unmodified locally:`);
172
+ for (const e of pending)
173
+ log.info(` ${e.rel}`);
174
+ apply = (await rl.question(`Update ${pending.length} pristine file(s) to the template version? [y/N] `)).trim().toLowerCase().startsWith("y");
175
+ }
176
+ finally {
177
+ rl.close();
178
+ }
179
+ }
180
+ else {
181
+ log.warn(`${pending.length} pristine template file(s) have updates available — re-run init interactively or with --force to apply: ${pending.map((e) => e.rel).join(", ")}`);
182
+ }
183
+ if (apply) {
184
+ for (const e of pending) {
185
+ copyEntry(e);
186
+ nextFiles[e.rel] = e.templateHash;
187
+ }
188
+ log.info(`updated ${pending.length} file(s) from the template`);
189
+ }
190
+ }
191
+ if (modified.length > 0)
192
+ log.warn(`left unchanged (modified locally): ${modified.join(", ")}`);
193
+ if (conflicts.length > 0)
194
+ log.warn(`left unchanged (changed in both the template and your copy — resolve manually or --force to reset): ${conflicts.join(", ")}`);
195
+ writeManifest(destDir, { version, files: nextFiles });
196
+ // "copied" only when the whole tree was genuinely fresh; a dir that pre-dates
197
+ // the manifest (files present, no baseline) adopts in place — report as such.
198
+ if (added.length === entries.length)
199
+ log.info(`copied template -> ${destDir}`);
200
+ else
201
+ log.info(`template up to date (${uptodate} unchanged${added.length ? `, ${added.length} added` : ""})`);
79
202
  }
80
203
  /** Interactive bootstrap: prompt for credentials, write .env, copy template/. */
81
204
  export async function init(targetDir, { force = false } = {}, log) {
@@ -107,16 +230,14 @@ export async function init(targetDir, { force = false } = {}, log) {
107
230
  writeFileSync(envFile, `N8N_HOST=${host}\nN8N_API_KEY=${apiKey}\n`);
108
231
  log.info(`wrote ${envFile}`);
109
232
  }
110
- // Copy the template completely (whatever it contains, recursively).
111
- // Default: never overwrite files that already exist in the target.
112
- // --force re-copies template files over existing ones (.env excepted —
113
- // it was just written with real credentials).
233
+ // Copy the template (whatever it contains, recursively), recording a
234
+ // per-file baseline in .decanter-template.json. Re-init is modification-aware:
235
+ // pristine files can be refreshed (after confirm), locally-edited files are
236
+ // left alone with drift reported, and `--force` overwrites everything. `.env`
237
+ // is protected (just written with real credentials) and never tracked.
114
238
  // Files named `X.example` are inert in this repo (so agent tooling ignores
115
239
  // them while working on the CLI itself) and materialize as `X` in the target.
116
- const overwritten = copyTemplate(TEMPLATE_DIR, dir, { force, protect: new Set([envFile]) });
117
- log.info(`copied template -> ${dir}`);
118
- for (const rel of overwritten)
119
- log.warn(`--force: overwrote ${rel} with the template version`);
240
+ await refreshTemplate(TEMPLATE_DIR, dir, { force, protect: new Set([envFile]), version: cliVersion() }, log);
120
241
  const configFile = path.join(dir, "decanter.config.json");
121
242
  if (!existsSync(configFile)) {
122
243
  writeFileSync(configFile, JSON.stringify({ root: "./workflows", workflows: [] }, null, 2) + "\n");
@@ -124,10 +245,10 @@ export async function init(targetDir, { force = false } = {}, log) {
124
245
  }
125
246
  const gitignoreFile = path.join(dir, ".gitignore");
126
247
  if (!existsSync(gitignoreFile)) {
127
- // executions/ holds fetched run data (may contain credentials/PII);
128
- // belt-and-braces with the self-ignoring executions/.gitignore the
129
- // `executions` verb writes into pre-existing sync dirs
130
- writeFileSync(gitignoreFile, "node_modules/\n.env\nworkflows/*/executions/\n");
248
+ // executions/ and data-tables/ hold fetched data (may contain
249
+ // credentials/PII); belt-and-braces with the self-ignoring .gitignore each
250
+ // fetch verb writes into pre-existing sync dirs
251
+ writeFileSync(gitignoreFile, "node_modules/\n.env\nworkflows/*/executions/\ndata-tables/\n");
131
252
  log.info("wrote .gitignore");
132
253
  }
133
254
  else if (!readFileSync(gitignoreFile, "utf8").split("\n").some((l) => l.trim() === ".env")) {
@@ -1,8 +1,11 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import path from "node:path";
2
3
  import { createPrompt } from "./prompt.mjs";
3
4
  import { pullWorkflow } from "./pull.mjs";
5
+ import { assertCompliant, buildNodeCode } from "./push.mjs";
4
6
  import { findWorkflowDir } from "./state.mjs";
5
- import { publicationState } from "./util.mjs";
7
+ import { isJsCodeNode, placeholderFile, publicationState, sanitizeForPut } from "./util.mjs";
8
+ import { validateWorkflowDir } from "./validate.mjs";
6
9
  /**
7
10
  * `publish` — take an unpublished draft live (n8n 2.x activate). A workflow
8
11
  * that is already published is a no-op-with-a-note, not an error: a push to a
@@ -39,13 +42,47 @@ export async function unpublishWorkflow(api, id, log) {
39
42
  * Plan 21's `duplicate`.
40
43
  */
41
44
  export async function createWorkflow(api, config, name, log) {
42
- const created = await api.createWorkflow(name);
45
+ const created = await api.createWorkflow({ name, nodes: [], connections: {}, settings: {} });
43
46
  const id = created.id;
44
47
  log.ok(`created "${name}" (${id}) on the server — unpublished draft`);
45
48
  const { dir } = await pullWorkflow(api, config.root, id, { commitOnPull: config.commitOnPull }, log);
46
49
  log.info(`edit code/, push, then "publish" to go live`);
47
50
  return { dir, id };
48
51
  }
52
+ /**
53
+ * `<ref> duplicate ["<new name>"]` — clone an already-pulled workflow into a
54
+ * brand-new remote one, then pull the copy. The body is assembled from the
55
+ * **local** folder exactly as `push` does (placeholders reconstituted from
56
+ * `code/`, `.ts` nodes compiled), so the clone carries the repo's current
57
+ * content — even edits not yet pushed to the source. The default name is
58
+ * `"<name> (copy)"` (matching the n8n UI). Born **unpublished**, like any
59
+ * create; the source folder and the source remote workflow are left untouched.
60
+ * Pull-first is preserved: the copy is born server-side and materialized via a
61
+ * fresh pull. Reuses `api.createWorkflow` (Plan 20) with a full body.
62
+ */
63
+ export async function duplicateWorkflow(api, config, id, newName, log) {
64
+ const dir = findWorkflowDir(config.root, id, log);
65
+ if (!dir)
66
+ throw new Error(`workflow ${id} not found under ${config.root} — pull it first`);
67
+ assertCompliant(validateWorkflowDir(dir), log, `"${path.basename(dir)}"`);
68
+ const wf = JSON.parse(readFileSync(path.join(dir, "workflow.json"), "utf8"));
69
+ const sourceName = wf.name;
70
+ for (const node of wf.nodes) {
71
+ if (!isJsCodeNode(node))
72
+ continue;
73
+ const file = placeholderFile(node);
74
+ if (file === null)
75
+ continue;
76
+ node.parameters.jsCode = (await buildNodeCode(dir, file, log)).jsCode;
77
+ }
78
+ const body = sanitizeForPut(wf);
79
+ body.name = newName?.trim() || `${sourceName} (copy)`;
80
+ const created = await api.createWorkflow(body);
81
+ log.ok(`duplicated "${sourceName}" -> "${body.name}" (${created.id}) on the server — unpublished draft`);
82
+ const { dir: newDir } = await pullWorkflow(api, config.root, created.id, { commitOnPull: config.commitOnPull }, log);
83
+ log.info(`edit code/, push, then "publish" to go live`);
84
+ return { dir: newDir, id: created.id };
85
+ }
49
86
  /**
50
87
  * `<ref> delete` — hard-delete a workflow from the server, deliberately.
51
88
  * Destructive and outward-facing, so consent is explicit: on a TTY a `y/N`