n8n-decanter 0.3.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +369 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/dist/lib/api.mjs +79 -0
  5. package/dist/lib/compile.mjs +220 -0
  6. package/dist/lib/config.mjs +61 -0
  7. package/dist/lib/diff.mjs +74 -0
  8. package/dist/lib/executions.mjs +76 -0
  9. package/dist/lib/git.mjs +44 -0
  10. package/dist/lib/init.mjs +152 -0
  11. package/dist/lib/picker.mjs +226 -0
  12. package/dist/lib/prompt.mjs +34 -0
  13. package/dist/lib/proxy.mjs +204 -0
  14. package/dist/lib/pull.mjs +148 -0
  15. package/dist/lib/push.mjs +136 -0
  16. package/dist/lib/rename.mjs +156 -0
  17. package/dist/lib/run.mjs +208 -0
  18. package/dist/lib/state.mjs +156 -0
  19. package/dist/lib/status.mjs +102 -0
  20. package/dist/lib/style.mjs +40 -0
  21. package/dist/lib/types.mjs +2 -0
  22. package/dist/lib/util.mjs +201 -0
  23. package/dist/lib/validate.mjs +225 -0
  24. package/dist/lib/watch.mjs +296 -0
  25. package/dist/n8n-decanter.mjs +482 -0
  26. package/dist/scripts/typecheck.mjs +90 -0
  27. package/package.json +51 -0
  28. package/template/.claude/hooks/verify.mjs.example +48 -0
  29. package/template/.claude/settings.local.json.example +35 -0
  30. package/template/.cursor/rules/n8n-decanter.mdc.example +21 -0
  31. package/template/.env.example +10 -0
  32. package/template/.mcp.json.example +16 -0
  33. package/template/.vscode/settings.json.example +6 -0
  34. package/template/AGENTS.md.example +302 -0
  35. package/template/CLAUDE.md.example +20 -0
  36. package/template/decanter-ts-plugin/index.js.example +58 -0
  37. package/template/decanter-ts-plugin/package.json.example +7 -0
  38. package/template/n8n-globals.d.ts.example +190 -0
  39. package/template/opencode.json.example +19 -0
  40. package/template/package.json.example +15 -0
  41. package/template/shared/.gitkeep +0 -0
  42. package/template/shared/example-helpers.ts +13 -0
  43. package/template/tsconfig.json.example +26 -0
  44. package/template/workflows/.gitkeep +0 -0
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import { N8nApi } from "./lib/api.mjs";
5
+ import { loadConfig } from "./lib/config.mjs";
6
+ import { cleanExecutions, fetchExecutionById, fetchExecutions } from "./lib/executions.mjs";
7
+ import { init, printBanner } from "./lib/init.mjs";
8
+ import { mergeRemote, runPicker } from "./lib/picker.mjs";
9
+ import { pullWorkflow } from "./lib/pull.mjs";
10
+ import { pushWorkflow } from "./lib/push.mjs";
11
+ import { renameNode, renameWorkflow } from "./lib/rename.mjs";
12
+ import { runNode } from "./lib/run.mjs";
13
+ import { findWorkflowDir, listWorkflowDirs, listWorkflowRefs, looksLikeWorkflowId, matchWorkflowRef } from "./lib/state.mjs";
14
+ import { statusWorkflow } from "./lib/status.mjs";
15
+ import { style, styleErr, transientLine } from "./lib/style.mjs";
16
+ import { runTypecheck, validateWorkflowDir } from "./lib/validate.mjs";
17
+ import { watchWorkflow } from "./lib/watch.mjs";
18
+ // Every real log line first erases a pending transient "pulling …" status line.
19
+ const transient = transientLine();
20
+ const log = {
21
+ info: (m) => {
22
+ transient.clear();
23
+ console.log(m);
24
+ },
25
+ ok: (m) => {
26
+ transient.clear();
27
+ console.log(`${style.green("✓")} ${m}`);
28
+ },
29
+ warn: (m) => {
30
+ transient.clear();
31
+ console.warn(styleErr.yellow(`! ${m}`));
32
+ },
33
+ error: (m) => {
34
+ transient.clear();
35
+ console.error(styleErr.red(`✗ ${m}`));
36
+ },
37
+ };
38
+ const usage = () => {
39
+ const b = style.bold;
40
+ const d = style.dim;
41
+ return `Usage:
42
+ ${b("n8n-decanter")} ${d("interactive picker (TTY, inited project):")}
43
+ ${d("choose a workflow, then verbs — stays in")}
44
+ ${d("the workflow's menu until Esc")}
45
+ ${b("n8n-decanter init")} [dir] [--force] ${d("interactive setup: .env, starter files, config")}
46
+ ${d("(--force re-copies template files over existing ones)")}
47
+ ${b("n8n-decanter")} [ref...] ${b("pull")} ${d("pull workflows (default: all in decanter.config.json)")}
48
+ ${b("n8n-decanter")} [ref...] ${b("push")} [--force] [--no-typecheck]
49
+ ${b("n8n-decanter")} [ref...] ${b("status")} [--diff] ${d("drift report (--diff shows line diffs);")}
50
+ ${d("exits 1 on conflict/remote drift")}
51
+ ${b("n8n-decanter")} [ref...] ${b("check")} [--no-typecheck] ${d("offline layout-compliance check")}
52
+ ${b("n8n-decanter")} <ref> ${b("rename")} "<old node>" "<new node>" ${d("rename a node everywhere (offline)")}
53
+ ${b("n8n-decanter")} <ref> ${b("rename")} --workflow "<new name>" ${d("rename the workflow itself")}
54
+ ${b("n8n-decanter")} [ref] ${b("watch")} [--force] ${d("watch code/ + workflow.json, push on save")}
55
+ ${d("(starts with a safety commit + pull; structural")}
56
+ ${d("conflicts prompt; browser live-reload optional)")}
57
+ ${b("n8n-decanter")} [ref...] ${b("executions")} [--status=success|error|waiting] [--limit=N]
58
+ ${d("fetch recent execution data (real run JSON) into")}
59
+ ${d("workflows/<Name>/executions/ — gitignored temp files;")}
60
+ ${d("a numeric argument fetches that one execution by id")}
61
+ ${b("n8n-decanter")} [ref...] ${b("executions clean")} ${d("delete fetched execution data (offline)")}
62
+ ${b("n8n-decanter list")} [--remote] ${d("pulled workflows: name, id, folder")}
63
+ ${d("(--remote adds workflows not pulled yet)")}
64
+ ${b("n8n-decanter completion")} zsh|bash ${d("print a shell completion script for your rc file")}
65
+ ${b("n8n-decanter")} <node-file> ${b("run")} [fixture.json] [--allow-env] ${d("run a node locally (offline;")}
66
+ ${d("$env is empty unless the fixture sets env or --allow-env inherits process.env)")}
67
+ ${b("n8n-decanter uuid")} [count] ${d("print lowercase v4 UUID(s) for new node ids")}
68
+
69
+ A workflow <ref> is its id, its workflow/folder name, or a unique name prefix
70
+ (case-insensitive; ambiguity is an error, never a prompt). A workflow named
71
+ like a verb must be addressed by id. The verb may sit anywhere among the
72
+ arguments: "n8n-decanter push wf123" is the same as "n8n-decanter wf123 push",
73
+ and flags may appear in any position too.
74
+
75
+ Config: decanter.config.json (searched upward from cwd), credentials from .env
76
+ next to it or the environment (N8N_HOST, N8N_API_KEY).`;
77
+ };
78
+ const VERBS = new Set(["init", "pull", "push", "status", "check", "rename", "watch", "run", "uuid", "list", "executions", "completion", "__complete", "help"]);
79
+ /** Verbs whose workflow arguments go through name resolution. */
80
+ const REF_VERBS = new Set(["pull", "push", "status", "check", "watch"]);
81
+ // Both scripts delegate to the hidden `__complete` verb at completion time,
82
+ // so candidates stay current without regenerating the script.
83
+ const COMPLETION_SCRIPTS = {
84
+ zsh: [
85
+ "# n8n-decanter zsh completion — append to ~/.zshrc (after compinit):",
86
+ '# eval "$(n8n-decanter completion zsh)"',
87
+ "_n8n_decanter() {",
88
+ " local -a words",
89
+ ' words=(${(f)"$(n8n-decanter __complete 2>/dev/null)"})',
90
+ ' compadd -- "${words[@]}"',
91
+ "}",
92
+ "compdef _n8n_decanter n8n-decanter",
93
+ "",
94
+ ].join("\n"),
95
+ bash: [
96
+ "# n8n-decanter bash completion — append to ~/.bashrc:",
97
+ '# eval "$(n8n-decanter completion bash)"',
98
+ "_n8n_decanter() {",
99
+ ' local cur="${COMP_WORDS[COMP_CWORD]}"',
100
+ " local IFS=$'\\n'",
101
+ ' COMPREPLY=($(compgen -W "$(n8n-decanter __complete 2>/dev/null)" -- "$cur"))',
102
+ "}",
103
+ "complete -F _n8n_decanter n8n-decanter",
104
+ "",
105
+ ].join("\n"),
106
+ };
107
+ async function main() {
108
+ // --status/--limit take a value (--limit=5 or --limit 5); they're peeled
109
+ // off first so the boolean-flag and positional logic below stays untouched.
110
+ const valueFlags = new Map();
111
+ const args = [];
112
+ {
113
+ const raw = process.argv.slice(2);
114
+ for (let i = 0; i < raw.length; i++) {
115
+ const m = raw[i].match(/^--(status|limit)(?:=(.*))?$/);
116
+ if (!m) {
117
+ args.push(raw[i]);
118
+ continue;
119
+ }
120
+ const value = m[2] ?? raw[++i];
121
+ if (value === undefined || value === "")
122
+ throw new Error(`--${m[1]} needs a value (e.g. --${m[1]}=${m[1] === "limit" ? "5" : "success"})`);
123
+ valueFlags.set(m[1], value);
124
+ }
125
+ }
126
+ const force = args.includes("--force");
127
+ const noTypecheck = args.includes("--no-typecheck");
128
+ const allowEnv = args.includes("--allow-env");
129
+ const workflowFlag = args.includes("--workflow");
130
+ const remoteFlag = args.includes("--remote");
131
+ const diffFlag = args.includes("--diff");
132
+ const positional = args.filter((a) => !a.startsWith("--"));
133
+ // id-first support: the first token matching a known verb is the command,
134
+ // wherever it sits — `push wf123` and `wf123 push` are equivalent.
135
+ const verbIndex = positional.findIndex((a) => VERBS.has(a));
136
+ const command = verbIndex === -1 ? positional[0] : positional[verbIndex];
137
+ const rest = positional.filter((_, i) => i !== verbIndex);
138
+ // Bare invocation on a TTY in an inited project → interactive picker
139
+ // (Plan 19). Piped runs and config-less directories fall through to
140
+ // usage() unchanged — scripts and LLM harnesses never see the picker.
141
+ if (command === undefined && args.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
142
+ let pickerConfig;
143
+ try {
144
+ pickerConfig = loadConfig(process.cwd(), { requireCredentials: false });
145
+ }
146
+ catch {
147
+ // no decanter.config.json in reach — bare invocation stays usage()
148
+ }
149
+ if (pickerConfig !== undefined) {
150
+ await pickerLoop(pickerConfig);
151
+ return;
152
+ }
153
+ }
154
+ if (!command || command === "help" || args[0] === "--help") {
155
+ console.log(usage());
156
+ return;
157
+ }
158
+ if (command === "init") {
159
+ // must run before loadConfig: a fresh directory has no config/.env yet
160
+ if (rest.length > 1)
161
+ throw new Error("init takes at most one directory argument");
162
+ await init(rest[0], { force }, log);
163
+ return;
164
+ }
165
+ // Offline, config-free verbs — no decanter.config.json or credentials needed.
166
+ if (command === "uuid") {
167
+ const count = rest[0] ? Number(rest[0]) : 1;
168
+ if (!Number.isInteger(count) || count < 1)
169
+ throw new Error("uuid count must be a positive integer");
170
+ for (let i = 0; i < count; i++)
171
+ console.log(randomUUID());
172
+ return;
173
+ }
174
+ if (command === "run") {
175
+ if (rest.length < 1)
176
+ throw new Error("run needs a node file argument: n8n-decanter run <node-file> [fixture.json]");
177
+ await runNode(rest[0], rest[1], log, { allowEnv });
178
+ return;
179
+ }
180
+ if (command === "completion") {
181
+ const script = rest[0] !== undefined ? COMPLETION_SCRIPTS[rest[0]] : undefined;
182
+ if (script === undefined)
183
+ throw new Error("completion needs a shell: n8n-decanter completion zsh|bash");
184
+ process.stdout.write(script);
185
+ return;
186
+ }
187
+ if (command === "__complete") {
188
+ // hidden helper backing the completion scripts: verbs, flags, and local
189
+ // workflow names/ids — offline, credentials-free, silent without a config
190
+ const words = [...VERBS].filter((v) => v !== "__complete" && v !== "help");
191
+ words.push("--force", "--no-typecheck", "--workflow", "--remote", "--diff", "--status=", "--limit=", "--allow-env", "--help");
192
+ try {
193
+ const config = loadConfig(process.cwd(), { requireCredentials: false });
194
+ for (const ref of listWorkflowRefs(config.root))
195
+ words.push(...ref.names, ref.id);
196
+ }
197
+ catch {
198
+ // no decanter.config.json in reach — verbs and flags still complete
199
+ }
200
+ console.log([...new Set(words)].join("\n"));
201
+ return;
202
+ }
203
+ await dispatch(command, rest, { force, noTypecheck, workflowFlag, remoteFlag, diffFlag, valueFlags });
204
+ }
205
+ /** Flag defaults for picker-launched verbs (no CLI flags in play). */
206
+ const PICKER_FLAGS = { force: false, noTypecheck: false, workflowFlag: false, remoteFlag: false, diffFlag: false, valueFlags: new Map() };
207
+ /**
208
+ * Interactive session (Plan 19 + loop follow-up): banner, then pick → run →
209
+ * back in the same workflow's verb menu until Esc (workflow list, then quit)
210
+ * or Ctrl-C. The remote list is fetched once and cached across iterations; a
211
+ * verb error is logged and returns to the menu instead of ending the session.
212
+ * The process exit code reflects the last verb run.
213
+ */
214
+ async function pickerLoop(config) {
215
+ printBanner(log);
216
+ let remoteCache;
217
+ let remoteNotice;
218
+ let remotePending = config.host !== "" && config.apiKey !== ""
219
+ ? new N8nApi(config).listWorkflows().then((ws) => ws.map((w) => ({ id: w.id, name: w.name })))
220
+ : undefined;
221
+ remotePending?.then((ws) => {
222
+ remoteCache = ws;
223
+ remotePending = undefined;
224
+ }).catch((err) => {
225
+ remoteNotice = `remote list unavailable (${err.message.split("\n")[0]})`;
226
+ remotePending = undefined;
227
+ });
228
+ let resume;
229
+ for (;;) {
230
+ // re-listed each round: a pull just added a folder (or renamed one)
231
+ const local = listWorkflowRefs(config.root, log).map((r) => ({ id: r.id, name: r.name, pulled: true }));
232
+ const entries = remoteCache !== undefined ? mergeRemote(local, remoteCache) : local;
233
+ const picked = await runPicker(entries, remotePending, { resume, notice: remoteNotice });
234
+ if (picked === "quit")
235
+ return;
236
+ if (picked === "interrupted") {
237
+ process.exitCode = 130;
238
+ return;
239
+ }
240
+ log.info(style.dim(`❯ ${picked.verb} ${picked.name}`));
241
+ process.exitCode = 0;
242
+ try {
243
+ await dispatch(picked.verb, [picked.id], PICKER_FLAGS);
244
+ }
245
+ catch (err) {
246
+ process.exitCode = 1;
247
+ log.error(err.message);
248
+ }
249
+ console.log("");
250
+ resume = { id: picked.id, verb: picked.verb };
251
+ }
252
+ }
253
+ /** Config-needing verbs: load config, resolve refs, run the verb switch. */
254
+ async function dispatch(command, rest, flags) {
255
+ const { force, noTypecheck, workflowFlag, remoteFlag, diffFlag, valueFlags } = flags;
256
+ const offline = command === "check" || command === "rename" || (command === "list" && !remoteFlag)
257
+ || (command === "executions" && rest.includes("clean"));
258
+ const config = loadConfig(process.cwd(), { requireCredentials: !offline });
259
+ const api = new N8nApi(config);
260
+ /**
261
+ * Workflow-name arguments: resolve a ref locally (id → name → unique
262
+ * prefix); `pull` falls back to the remote workflow list for not-yet-pulled
263
+ * names. An id-shaped ref that matches nothing passes through unchanged —
264
+ * it may exist only remotely (pull/status by fresh id must keep working).
265
+ */
266
+ const resolveRef = async (ref) => {
267
+ const local = matchWorkflowRef(listWorkflowRefs(config.root, log), ref);
268
+ if (local)
269
+ return local.id;
270
+ if (command === "pull") {
271
+ try {
272
+ const remote = await api.listWorkflows();
273
+ const hit = matchWorkflowRef(remote.map((w) => ({ id: w.id, names: [w.name] })), ref);
274
+ if (hit)
275
+ return hit.id;
276
+ }
277
+ catch (err) {
278
+ log.warn(`could not list remote workflows to resolve "${ref}" (${err.message.split("\n")[0]})`);
279
+ }
280
+ }
281
+ if (looksLikeWorkflowId(ref))
282
+ return ref;
283
+ const known = listWorkflowRefs(config.root).map((r) => `"${r.name}"`);
284
+ throw new Error(`no workflow matches "${ref}"${known.length > 0 ? ` — pulled workflows: ${known.join(", ")}` : " — nothing pulled yet"}`);
285
+ };
286
+ let refs = rest;
287
+ if (REF_VERBS.has(command)) {
288
+ refs = [];
289
+ for (const r of rest)
290
+ refs.push(await resolveRef(r));
291
+ }
292
+ else if (command === "rename" && rest.length > 0) {
293
+ refs = [await resolveRef(rest[0]), ...rest.slice(1)];
294
+ }
295
+ const ids = refs.length > 0 ? refs : config.workflows;
296
+ switch (command) {
297
+ case "pull":
298
+ case "push":
299
+ case "status": {
300
+ if (ids.length === 0) {
301
+ throw new Error('no workflow ids: pass them as arguments or list them in decanter.config.json "workflows"');
302
+ }
303
+ if (command === "push" && !noTypecheck)
304
+ await runTypecheck(config.configDir, log);
305
+ let failed = false;
306
+ let drifted = false;
307
+ const total = ids.length;
308
+ for (const [i, id] of ids.entries()) {
309
+ // progress: [2/5] prefix in both modes (dim on a TTY), transient
310
+ // "pulling …" only on a TTY, (0.4s) duration on pull/push result lines
311
+ const prefix = total > 1 ? `[${i + 1}/${total}] ` : "";
312
+ const plog = prefix === "" ? log : {
313
+ info: (m) => log.info(style.dim(prefix) + m),
314
+ ok: (m) => log.ok(style.dim(prefix) + m),
315
+ warn: (m) => log.warn(styleErr.dim(prefix) + m),
316
+ error: (m) => log.error(styleErr.dim(prefix) + m),
317
+ };
318
+ const started = performance.now();
319
+ const dur = () => " " + style.dim(`(${((performance.now() - started) / 1000).toFixed(1)}s)`);
320
+ try {
321
+ if (command === "pull") {
322
+ transient.show(`${prefix}pulling ${id}…`);
323
+ const { name, dir } = await pullWorkflow(api, config.root, id, { commitOnPull: config.commitOnPull }, plog);
324
+ plog.ok(`pulled "${name}" -> ${dir}${dur()}`);
325
+ }
326
+ else if (command === "push") {
327
+ transient.show(`${prefix}pushing ${id}…`);
328
+ await pushWorkflow(api, config.root, id, { force, commitOnPush: config.commitOnPush }, { ...plog, ok: (m) => plog.ok(m + dur()) });
329
+ }
330
+ else {
331
+ const { remoteDrift } = await statusWorkflow(api, config.root, id, plog, { diff: diffFlag });
332
+ drifted ||= remoteDrift;
333
+ }
334
+ }
335
+ catch (err) {
336
+ failed = true;
337
+ plog.error(`${id}: ${err.message}`);
338
+ }
339
+ finally {
340
+ transient.clear();
341
+ }
342
+ }
343
+ // status: conflict/remote drift exits 1 so scripts and CI can gate on it
344
+ if (failed || drifted)
345
+ process.exitCode = 1;
346
+ break;
347
+ }
348
+ case "list": {
349
+ const pulled = listWorkflowRefs(config.root, log);
350
+ for (const r of pulled) {
351
+ log.info(`${style.bold(r.name)} ${style.dim(r.id)} ${style.dim(path.relative(process.cwd(), r.dir) || ".")}`);
352
+ }
353
+ if (remoteFlag) {
354
+ const known = new Set(pulled.map((r) => r.id));
355
+ for (const wf of await api.listWorkflows()) {
356
+ if (known.has(wf.id))
357
+ continue;
358
+ log.info(`${style.bold(wf.name)} ${style.dim(wf.id)} ${style.dim("(not pulled)")}`);
359
+ }
360
+ }
361
+ else if (pulled.length === 0) {
362
+ log.info(`no pulled workflows under ${config.root} — try: n8n-decanter list --remote`);
363
+ }
364
+ break;
365
+ }
366
+ case "check": {
367
+ const dirs = refs.length > 0
368
+ ? refs.map((id) => {
369
+ const dir = findWorkflowDir(config.root, id, log);
370
+ if (!dir)
371
+ throw new Error(`workflow ${id} not found under ${config.root} — pull it first`);
372
+ return dir;
373
+ })
374
+ : listWorkflowDirs(config.root);
375
+ if (dirs.length === 0)
376
+ log.info(`nothing to check — no pulled workflows under ${config.root}`);
377
+ let errorCount = 0;
378
+ for (const dir of dirs) {
379
+ const name = path.basename(dir);
380
+ const { errors, warnings } = validateWorkflowDir(dir);
381
+ for (const w of warnings)
382
+ log.warn(`${name}: ${w}`);
383
+ for (const e of errors)
384
+ log.error(`${name}: ${e}`);
385
+ if (errors.length === 0)
386
+ log.ok(`${name}: OK`);
387
+ errorCount += errors.length;
388
+ }
389
+ if (!noTypecheck) {
390
+ try {
391
+ // explicit ids scope the typecheck output too; bare `check` stays project-wide
392
+ await runTypecheck(config.configDir, log, refs.length > 0 ? dirs : undefined);
393
+ }
394
+ catch (err) {
395
+ log.error(err.message);
396
+ errorCount++;
397
+ }
398
+ }
399
+ if (errorCount > 0)
400
+ process.exitCode = 1;
401
+ break;
402
+ }
403
+ case "executions": {
404
+ // grammar: "clean" may sit anywhere (like the verb itself); a purely
405
+ // numeric argument is an execution id (n8n execution ids are integers,
406
+ // workflow ids are 16-char alphanumeric tokens) — everything else is a
407
+ // workflow ref. A workflow literally named "clean" or like a number
408
+ // must be addressed by id, same rule as verb-named workflows.
409
+ const params = rest.filter((a) => a !== "clean");
410
+ const wfIds = [];
411
+ for (const r of params.filter((a) => !/^\d+$/.test(a)))
412
+ wfIds.push(await resolveRef(r));
413
+ if (rest.includes("clean")) {
414
+ cleanExecutions(config.root, wfIds, log);
415
+ break;
416
+ }
417
+ const limitRaw = valueFlags.get("limit");
418
+ const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
419
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 250)) {
420
+ throw new Error("--limit must be an integer between 1 and 250 (the executions API page cap)");
421
+ }
422
+ const status = valueFlags.get("status");
423
+ const execIds = params.filter((a) => /^\d+$/.test(a));
424
+ if (execIds.length === 0 && wfIds.length === 0) {
425
+ if (config.workflows.length === 0) {
426
+ throw new Error('no workflow ids: pass them as arguments or list them in decanter.config.json "workflows"');
427
+ }
428
+ wfIds.push(...config.workflows);
429
+ }
430
+ let failed = false;
431
+ const attempt = async (label, fn) => {
432
+ try {
433
+ await fn();
434
+ }
435
+ catch (err) {
436
+ failed = true;
437
+ log.error(`${label}: ${err.message}`);
438
+ }
439
+ };
440
+ for (const e of execIds)
441
+ await attempt(`execution ${e}`, () => fetchExecutionById(api, config.root, e, log));
442
+ for (const id of wfIds)
443
+ await attempt(id, () => fetchExecutions(api, config.root, id, { status, limit }, log));
444
+ if (failed)
445
+ process.exitCode = 1;
446
+ break;
447
+ }
448
+ case "rename": {
449
+ const [id, ...names] = refs;
450
+ if (!id)
451
+ throw new Error('rename needs a workflow id: n8n-decanter rename <id> "<old node>" "<new node>" (or --workflow "<new name>")');
452
+ if (workflowFlag) {
453
+ if (names.length !== 1)
454
+ throw new Error('rename --workflow needs exactly one name: n8n-decanter rename <id> --workflow "<new name>"');
455
+ renameWorkflow(config.root, id, names[0], log);
456
+ }
457
+ else {
458
+ if (names.length !== 2)
459
+ throw new Error('rename needs the old and new node name: n8n-decanter rename <id> "<old node>" "<new node>"');
460
+ renameNode(config.root, id, names[0], names[1], log);
461
+ }
462
+ break;
463
+ }
464
+ case "watch": {
465
+ if (ids.length !== 1)
466
+ throw new Error("watch needs exactly one workflow id (pass it, or list a single workflow in decanter.config.json)");
467
+ // the returned handle exists for tests; the CLI watches until Ctrl-C
468
+ await watchWorkflow(api, config, ids[0], { force }, log);
469
+ await new Promise(() => { });
470
+ break;
471
+ }
472
+ default:
473
+ console.log(usage());
474
+ throw new Error(`unknown command: ${command}`);
475
+ }
476
+ }
477
+ main().catch((err) => {
478
+ // DEBUG=1 surfaces the stack — the one-line default hides exactly the
479
+ // context needed when an unexpected TypeError escapes
480
+ log.error(process.env.DEBUG ? err.stack ?? String(err) : err.message);
481
+ process.exitCode = 1;
482
+ });
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // Typecheck wrapper around tsc: n8n Code node source is a *function body*
3
+ // (top-level `return` / `await`), which plain `tsc` rejects in .ts files
4
+ // (TS1108). This script wraps node files in `async function () { ... }`
5
+ // in memory only — files on disk stay verbatim — and maps diagnostic line
6
+ // numbers back. Node files are recognized by a .decanter.json sibling, or —
7
+ // code/ layout — one in the parent of their code/ dir.
8
+ import path from "node:path";
9
+ import ts from "typescript";
10
+ import { scanNodeImports } from "../lib/compile.mjs";
11
+ import { nodeFileContextDir } from "../lib/state.mjs";
12
+ const PREFIX = "async function __n8nNode() {\n";
13
+ const SUFFIX = "\n}\nvoid __n8nNode;\n";
14
+ // Optional dir arguments scope the *output*: the whole project is still
15
+ // compiled (cross-file types need the full graph), but only diagnostics whose
16
+ // file lives under one of the given dirs are reported and counted. Global
17
+ // (file-less) diagnostics are always reported — a broken tsconfig must not
18
+ // pass as green just because a scope was given.
19
+ const scopeDirs = process.argv.slice(2).map((d) => path.resolve(d));
20
+ function inScope(fileName) {
21
+ if (scopeDirs.length === 0)
22
+ return true;
23
+ const file = path.resolve(fileName);
24
+ return scopeDirs.some((dir) => file === dir || file.startsWith(dir + path.sep));
25
+ }
26
+ const configPath = ts.findConfigFile(process.cwd(), ts.sys.fileExists, "tsconfig.json");
27
+ if (!configPath) {
28
+ console.error("tsconfig.json not found");
29
+ process.exit(2);
30
+ }
31
+ const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
32
+ ...ts.sys,
33
+ onUnRecoverableConfigFileDiagnostic: (d) => {
34
+ console.error(ts.flattenDiagnosticMessageText(d.messageText, "\n"));
35
+ process.exit(2);
36
+ },
37
+ });
38
+ if (!parsed)
39
+ process.exit(2);
40
+ function isNodeFile(fileName) {
41
+ if (fileName.endsWith(".d.ts") || fileName.endsWith(".remote.js"))
42
+ return false;
43
+ if (!/\.(ts|js)$/.test(fileName))
44
+ return false;
45
+ // .decanter.json sibling, or — kebab-case layout — in the parent of code/
46
+ return nodeFileContextDir(fileName) !== null;
47
+ }
48
+ // Wrapped node files map to their import-block line count: imports must stay
49
+ // at module scope (plans/14 bundling), so the wrapper is inserted *after*
50
+ // them — lines up to importLines are unshifted, later lines shift by one.
51
+ const wrapped = new Map();
52
+ const host = ts.createCompilerHost(parsed.options);
53
+ const originalReadFile = host.readFile.bind(host);
54
+ host.readFile = (fileName) => {
55
+ const text = originalReadFile(fileName);
56
+ if (text === undefined || !isNodeFile(fileName))
57
+ return text;
58
+ const { importBlock, body, importLines } = scanNodeImports(text);
59
+ wrapped.set(path.resolve(fileName), importLines);
60
+ return importBlock + PREFIX + body + SUFFIX;
61
+ };
62
+ const program = ts.createProgram(parsed.fileNames, parsed.options, host);
63
+ let problems = 0;
64
+ for (const d of ts.getPreEmitDiagnostics(program)) {
65
+ const message = ts.flattenDiagnosticMessageText(d.messageText, "\n");
66
+ if (!d.file) {
67
+ console.error(`error TS${d.code}: ${message}`);
68
+ problems++;
69
+ continue;
70
+ }
71
+ if (!inScope(d.file.fileName))
72
+ continue;
73
+ const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
74
+ const importLines = wrapped.get(path.resolve(d.file.fileName));
75
+ let displayLine = line + 1;
76
+ if (importLines !== undefined && displayLine > importLines)
77
+ displayLine -= 1;
78
+ if (importLines !== undefined && displayLine < 1)
79
+ continue; // diagnostic on the injected wrapper itself
80
+ const rel = path.relative(process.cwd(), d.file.fileName);
81
+ const category = ts.DiagnosticCategory[d.category].toLowerCase();
82
+ console.error(`${rel}(${displayLine},${character + 1}): ${category} TS${d.code}: ${message}`);
83
+ if (d.category === ts.DiagnosticCategory.Error)
84
+ problems++;
85
+ }
86
+ if (problems > 0) {
87
+ console.error(`\n${problems} error(s)`);
88
+ process.exit(1);
89
+ }
90
+ console.log("typecheck OK");
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "n8n-decanter",
3
+ "version": "0.3.0",
4
+ "description": "Work on n8n like a codebase — built for AI coding agents",
5
+ "keywords": [
6
+ "n8n",
7
+ "workflow",
8
+ "git",
9
+ "sync",
10
+ "cli",
11
+ "code-node",
12
+ "typescript",
13
+ "agentic"
14
+ ],
15
+ "homepage": "https://github.com/buttjer/n8n-decanter#readme",
16
+ "bugs": "https://github.com/buttjer/n8n-decanter/issues",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/buttjer/n8n-decanter.git"
20
+ },
21
+ "license": "MIT",
22
+ "author": "Malte Buttjer (https://www.buttjer.net)",
23
+ "type": "module",
24
+ "bin": {
25
+ "n8n-decanter": "./dist/n8n-decanter.mjs"
26
+ },
27
+ "files": [
28
+ "dist/",
29
+ "template/",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.build.json",
34
+ "prepack": "npm run build",
35
+ "typecheck": "tsc -p tsconfig.cli.json && node scripts/typecheck.mts",
36
+ "test": "node --test 'test/unit/*.test.mts' && node test/e2e.mts && node test/proxy.mts",
37
+ "test:smoke": "node test/smoke-n8n.mts"
38
+ },
39
+ "engines": {
40
+ "node": ">=22.18"
41
+ },
42
+ "dependencies": {
43
+ "esbuild": "^0.28.1",
44
+ "luxon": "^3.7.2"
45
+ },
46
+ "devDependencies": {
47
+ "@types/luxon": "^3.7.2",
48
+ "@types/node": "^26.1.1",
49
+ "typescript": "^5.9.3"
50
+ }
51
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse verify hook. After an Edit/Write to a Code-node source file, run
3
+ // the offline `n8n-decanter check` (layout compliance + typecheck) and, on
4
+ // failure, feed the output back to the agent (exit 2 = blocking). No-op when the
5
+ // edit isn't a node file, or when the CLI isn't on PATH yet (fresh scaffold).
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import path from "node:path";
9
+
10
+ let payload = {};
11
+ try {
12
+ payload = JSON.parse(readFileSync(0, "utf8") || "{}");
13
+ } catch {
14
+ process.exit(0);
15
+ }
16
+
17
+ const file = payload.tool_input?.file_path;
18
+ if (!file) process.exit(0);
19
+
20
+ // A node source file is a .ts/.js (not a .remote.js artifact) that lives in a
21
+ // workflow folder's code/ subdir — the .decanter.json sits in the parent
22
+ // (legacy layouts had it as a direct sibling).
23
+ const dir = path.dirname(file);
24
+ const stateDir = existsSync(path.join(dir, ".decanter.json"))
25
+ ? dir
26
+ : path.basename(dir) === "code" && existsSync(path.join(path.dirname(dir), ".decanter.json"))
27
+ ? path.dirname(dir)
28
+ : null;
29
+ const isNodeFile = /\.(ts|js)$/.test(file) && !file.endsWith(".remote.js") && stateDir !== null;
30
+ if (!isNodeFile) process.exit(0);
31
+
32
+ // Scope the check to the edited workflow. `check` matches on the workflow id
33
+ // stored in .decanter.json (NOT the folder name), so read the id from there;
34
+ // if the state file is unreadable, fall back to a project-wide check.
35
+ let workflowId;
36
+ try {
37
+ workflowId = JSON.parse(readFileSync(path.join(stateDir, ".decanter.json"), "utf8")).workflowId;
38
+ } catch {}
39
+ const args = typeof workflowId === "string" && workflowId ? ["check", workflowId] : ["check"];
40
+ const res = spawnSync("n8n-decanter", args, { encoding: "utf8" });
41
+ if (res.error?.code === "ENOENT") process.exit(0); // CLI not installed yet — stay quiet
42
+
43
+ if (typeof res.status === "number" && res.status !== 0) {
44
+ const out = ((res.stdout ?? "") + (res.stderr ?? "")).trim();
45
+ console.error(`n8n-decanter check failed after editing ${path.basename(file)}:\n${out}`);
46
+ process.exit(2);
47
+ }
48
+ process.exit(0);