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.
- package/CHANGELOG.md +194 -0
- package/README.md +99 -48
- package/dist/lib/add.mjs +91 -0
- package/dist/lib/api.mjs +51 -7
- package/dist/lib/config.mjs +2 -0
- package/dist/lib/datatables.mjs +124 -0
- package/dist/lib/engine.mjs +192 -0
- package/dist/lib/executions.mjs +21 -2
- package/dist/lib/init.mjs +146 -25
- package/dist/lib/lifecycle.mjs +39 -2
- package/dist/lib/picker.mjs +34 -10
- package/dist/lib/pull.mjs +23 -22
- package/dist/lib/push.mjs +1 -1
- package/dist/lib/rename.mjs +11 -2
- package/dist/lib/simulate.mjs +374 -0
- package/dist/lib/state.mjs +10 -6
- package/dist/lib/template.mjs +45 -0
- package/dist/lib/util.mjs +4 -0
- package/dist/n8n-decanter.mjs +273 -86
- package/package.json +4 -2
- package/template/.claude/settings.local.json.example +16 -3
- package/template/.env.example +2 -0
- package/template/AGENTS.md.example +111 -40
- package/template/CLAUDE.md.example +1 -1
- package/template/n8n-globals.d.ts.example +6 -1
|
@@ -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
|
+
}
|
package/dist/lib/executions.mjs
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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,
|
|
78
|
-
return
|
|
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
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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
|
-
|
|
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/
|
|
128
|
-
// belt-and-braces with the self-ignoring
|
|
129
|
-
//
|
|
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")) {
|
package/dist/lib/lifecycle.mjs
CHANGED
|
@@ -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`
|