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
package/README.md ADDED
@@ -0,0 +1,318 @@
1
+ # n8n-decanter
2
+
3
+ [![CI](https://github.com/buttjer/n8n-decanter/actions/workflows/ci.yml/badge.svg)](https://github.com/buttjer/n8n-decanter/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/n8n-decanter)](https://www.npmjs.com/package/n8n-decanter)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ **Work on n8n like a codebase — built for AI coding agents.**
8
+
9
+ n8n-decanter syncs your n8n instance into a git-friendly, folder-per-workflow
10
+ layout: every Code node's source becomes its own `.js` or `.ts` file,
11
+ editable in your IDE or by your agent, and pushed back through the n8n API.
12
+
13
+ - **Real version control** — meaningful diffs, PRs, blame; every push and
14
+ pull is auto-committed.
15
+ - **TypeScript or typed JS** — write nodes in TS (compiled on push); n8n
16
+ globals (`$input`, `$('…')`, …) are typed in both.
17
+ - **Agent-native** — `init` scaffolds Claude Code / Cursor / Codex configs
18
+ and verification hooks; offline `check` and `run` give agents a
19
+ credential-free feedback loop.
20
+ - **Guardrails** — a compliance guard and typecheck gate block broken
21
+ pushes; a drift guard keeps you from clobbering remote edits.
22
+ - **Real execution data on tap** — `executions` fetches recent run JSON into
23
+ a gitignored temp dir, so agents see actual payload shapes (and build
24
+ accurate `run` fixtures) instead of guessing; `executions clean` removes
25
+ it when done.
26
+ - **Live editing** — `watch` pushes on save and auto-reloads the n8n editor
27
+ tab via a local proxy.
28
+ - **Shared code and small libraries** — `.ts` nodes import helpers/types
29
+ from `shared/` and opted-in npm packages; push bundles them into
30
+ self-contained nodes that run anywhere, n8n Cloud included.
31
+ - **Built for n8n 2.x** — the draft/publish model is a first-class citizen
32
+ (push tells you whether code went live or stayed a draft), and every
33
+ behavior that touches the API is verified against a real n8n 2.x instance
34
+ by an automated integration suite.
35
+
36
+ ![VS Code Screenshot](./docs/screenshot.webp)
37
+
38
+ ## Setup
39
+
40
+ Requires Node >= 22.18 — the CLI is TypeScript (`.mts`), executed natively
41
+ via Node's type stripping; there is no build step. **On older Node the CLI
42
+ fails at startup with a confusing `SyntaxError`** rather than a clean version
43
+ message: npm's `engines` field only *warns* at install time (unless you set
44
+ `engine-strict`). If you see a syntax error pointing into a `.mts` file,
45
+ check `node --version` first.
46
+
47
+ ```sh
48
+ npm install -g n8n-decanter
49
+ n8n-decanter init [dir] # prompts for host + API key, writes .env,
50
+ # copies template/, scaffolds config + .gitignore
51
+ ```
52
+
53
+ From a git checkout instead: `npm link` (run `npm run build` once first — the
54
+ installed bin is the compiled `dist/`), or invoke `node n8n-decanter.mts …`
55
+ directly, no build needed.
56
+
57
+ `init` copies everything in [template/](template/); files named `X.example`
58
+ land as `X` (the suffix keeps agent configs inert in this repo, live in the
59
+ target). It never overwrites existing files (safe to re-run) unless you pass
60
+ `--force`, which re-copies template files over existing ones (`.env` is never
61
+ touched by it). When `.env` already holds both values, init skips the
62
+ prompts and reuses them — edit or delete `.env` to change credentials. It
63
+ also does a best-effort credential check. Alternatively set up manually: `cp .env.example .env` and
64
+ fill it in. Then add workflow ids to `decanter.config.json`:
65
+
66
+ ```json
67
+ { "root": "./workflows", "workflows": ["0cXNQKKzmO0pXiCq"] }
68
+ ```
69
+
70
+ **Prefer a scoped API key.** In n8n (Settings → n8n API → Create) create a key
71
+ limited to the permissions the CLI actually uses rather than a full-access one,
72
+ so a leaked `.env` has a small blast radius:
73
+
74
+ - `workflow:read`, `workflow:list`, `workflow:update` — `pull` / `status` / `push`
75
+ - `execution:read`, `execution:list` — the `executions` verb
76
+
77
+ A full-access key also works if your n8n version predates scoped keys.
78
+
79
+ After every successful push **and pull**, the workflow's folder is
80
+ git-committed automatically (scoped to that folder; outside a git repo it
81
+ just warns). Set `"commitOnPush": false` / `"commitOnPull": false` to turn
82
+ that off.
83
+
84
+ `init` also scaffolds the TypeScript tooling a sync dir needs to type-check and
85
+ run nodes locally — `package.json` (with a `typecheck` script + the `typescript`
86
+ devDep), `tsconfig.json`, and `n8n-globals.d.ts` — plus a Claude Code
87
+ PostToolUse hook that runs `check` after node edits. Verification routes through
88
+ the CLI, so `n8n-decanter` must be on the sync dir's PATH: install it globally
89
+ (`npm i -g n8n-decanter`), add it to the sync dir's `devDependencies`, or
90
+ `npm link` a git checkout (build it first — Node won't type-strip `.mts`
91
+ under `node_modules`). The verbs `check`, `run`, and `uuid` are fully
92
+ offline (no credentials, no network).
93
+
94
+ ## Commands
95
+
96
+ ```sh
97
+ n8n-decanter # interactive picker (terminal, inited
98
+ # project): filter workflows by typing —
99
+ # pulled green, unpulled remote yellow —
100
+ # then run verbs (unpulled: Enter pulls);
101
+ # stays in the workflow's menu between
102
+ # verbs, Esc backs out, Esc Esc quits
103
+ n8n-decanter init [dir] # interactive bootstrap (see Setup)
104
+ n8n-decanter [ref...] pull # remote -> workflows/<Name>/
105
+ n8n-decanter [ref...] push [--force] [--no-typecheck]
106
+ n8n-decanter [ref...] status [--diff] # drift report (--diff: line diffs);
107
+ # exits 1 on conflict/remote drift
108
+ n8n-decanter [ref...] check # offline layout-compliance + typecheck
109
+ n8n-decanter <ref> rename "<old node>" "<new node>" # rename a node everywhere
110
+ n8n-decanter <ref> rename --workflow "<new name>" # rename the workflow
111
+ n8n-decanter [ref] watch # push a workflow's nodes on save
112
+ # (+ browser live-reload, opt-in)
113
+ n8n-decanter [ref...] executions [--status=success|error|waiting] [--limit=N]
114
+ # fetch recent execution data (run JSON)
115
+ # into workflows/<Name>/executions/
116
+ # (gitignored temp files; a numeric arg
117
+ # fetches that one execution by id)
118
+ n8n-decanter [ref...] executions clean # delete fetched execution data (offline)
119
+ n8n-decanter list [--remote] # pulled workflows: name, id, folder
120
+ # (--remote adds unpulled ones)
121
+ n8n-decanter completion zsh|bash # print a shell completion script
122
+ n8n-decanter <node-file> run [fixture.json] [--allow-env] # run a node offline, print items
123
+ n8n-decanter uuid [count] # lowercase v4 UUID(s) for new node ids
124
+ ```
125
+
126
+ A workflow `<ref>` is its **id, its workflow/folder name, or a unique name
127
+ prefix** — `n8n-decanter "Order Sync" push` and `n8n-decanter order push` both
128
+ work. Matching is case-insensitive and never prompts: an ambiguous or unknown
129
+ name errors with the candidate list. `pull` resolves not-yet-pulled names
130
+ against the server's workflow list; a workflow literally named like a verb
131
+ must be addressed by id. Without refs, all workflows from the config are
132
+ processed. The verb may sit anywhere among the arguments (`push wf123` ==
133
+ `wf123 push`) — the first token matching a known verb is the command; flags
134
+ may appear in any position too.
135
+
136
+ Output is styled (color, `✓`/`!`/`✗` glyphs, progress) **only when writing to
137
+ a terminal** and respects `NO_COLOR`/`FORCE_COLOR`; piped or redirected output
138
+ is plain line-oriented text, safe for scripts and LLM harnesses. The
139
+ interactive picker follows the same rule: it exists only on a terminal —
140
+ a piped bare `n8n-decanter` prints plain usage text. For shell tab
141
+ completion of verbs, flags, and workflow names, append
142
+ `eval "$(n8n-decanter completion zsh)"` (or `bash`) to your shell rc.
143
+
144
+ `status` exits 1 when a pull is needed or a push would clobber remote work
145
+ (CONFLICT, remote-only changes, not pulled yet); local-only pending edits
146
+ exit 0 — scripts and CI can gate on it like on `check`. `status --diff`
147
+ shows the actual line diff under each drifted node, so you see what a push
148
+ would overwrite before running it. API requests time out after 30 s (set
149
+ `"requestTimeoutMs"` in `decanter.config.json` for slow instances), and
150
+ `DEBUG=1` prints full stack traces on errors. `run` fakes the full Code-node
151
+ context including `$getWorkflowStaticData`, seeded from `workflow.json` and
152
+ overridable per fixture. Like n8n's own scoped `$env`, `run`'s `$env` is
153
+ **empty by default** — set it explicitly with the fixture's `"env"` field, or
154
+ pass `--allow-env` to inherit the CLI process's environment (which may include
155
+ `N8N_API_KEY` and other secrets), so a node that prints `$env` never leaks the
156
+ host environment by accident.
157
+
158
+ `executions` is read-only against the API and writes each execution as
159
+ `workflows/<Name>/executions/<execId>.json` — real run data (the items every
160
+ node produced), meant as temporary reference for building `run` fixtures.
161
+ The dir is written self-gitignored (run data can contain credentials/PII and
162
+ must never land in git); executions reflect the *published* workflow version
163
+ (n8n 2.x), so treat them as convenience data, not ground truth, and remove
164
+ them with `executions clean` when done.
165
+
166
+ <!--
167
+ "How it compares" is temporarily hidden pending the n8n-as-code maintainer's
168
+ sign-off on the comparison scorings (asked 2026-07-20). Uncomment to restore.
169
+
170
+ ## How it compares
171
+
172
+ n8n-decanter is **Code-node-first**: it optimizes the loop of writing, typing,
173
+ testing, and shipping the JavaScript/TypeScript *inside* your workflows. Here's
174
+ how that focus stacks up against the native editor and against
175
+ [n8n-as-code](https://github.com/EtienneLescot/n8n-as-code) — a broader,
176
+ whole-workflow authoring toolkit.
177
+
178
+ | Capability | Native n8n (browser) | n8n-as-code | n8n-decanter |
179
+ |---|---|---|---|
180
+ | **TypeScript for Code nodes** | ❌ JavaScript or Python only | ❌ TS is at workflow level, not node logic | ✅ Code nodes as `.ts`, compiled on push, typed n8n globals |
181
+ | **Shared code in Code nodes** | ❌ self-host `NODE_FUNCTION_ALLOW_*` only; no libraries | ❌ not part of its model | ✅ `shared/*.ts` + npm bundled into self-contained nodes (Cloud-safe) |
182
+ | **Code as individual files** | ❌ no source files (JSON blob) | 🟡 one `.workflow.ts` per workflow | ✅ folder per workflow; each Code node its own `.js`/`.ts` |
183
+ | **Versioning** | 🟡 in-app history (DB snapshots, tiered retention); Git source control is Enterprise-only | ✅ GitOps sync of workflow source | ✅ real git — diffs, PRs, blame; auto-commit each push/pull |
184
+ | **Live editing** | ✅ the canvas (baseline) | 🟡 explicit pull/push, no auto-watch | ✅ `watch`: push on save + auto-reload the editor tab |
185
+ | **Offline testing on historic executions** | 🟡 re-run past executions / pin data, but online in-editor | 🟡 inspect executions against a live env | ✅ fetch real run JSON → offline `run` fixtures, no creds/network |
186
+ | **Agent-native tooling** | 🟡 n8n's own canvas AI, not your agent on the codebase | ✅ Agent Workbench, skills, MCP, Claude/editor plugins | ✅ scaffolds Claude Code / Cursor / Codex configs + MCP; offline `check`/`run` loop; guardrails |
187
+ | **Model ownership** | ❌ locked to n8n's own hosted AI; can't use your Claude subscription | 🟡 beta Claude Code plugin uses your subscription; flagship Workbench needs an Anthropic key for Claude | ✅ never calls an LLM itself — your agent/subscription does 100%, no key or model config ever |
188
+ | **Agentic workflow creation** | 🟡 AI Workflow Builder (natural language), but Cloud / plan-gated — credits, self-host needs setup | ✅ 537 node schemas + 7,700+ templates + skills | 🟡 today via scaffolded n8n-mcp; first-party repo-authored creation planned (`add` + `push --create`) |
189
+ | **Whole-workflow TypeScript authoring** | ❌ | ✅ `.workflow.ts` decorator classes (structure + links) | ❌ keeps `workflow.json`; extracts Code-node source only |
190
+ | **Multi-environment promotion** | 🟡 Enterprise source control / environments | ✅ `promote` remaps creds + refs Dev→Prod | 🟡 separate sync dir per instance, but no `promote` (IDs/creds/refs not remapped) |
191
+
192
+ Legend: ✅ first-class · 🟡 partial or indirect · ❌ not supported.
193
+
194
+ **Bottom line:** reach for n8n-decanter when your workflows live or die by their
195
+ Code nodes — TypeScript, shared libraries, offline tests, and real git diffs.
196
+ n8n-as-code shines for whole-workflow authoring/generation and multi-environment
197
+ ops; the native editor stays the live visual canvas everything syncs back to. And
198
+ decanter makes no LLM calls of its own — you drive it with the coding agent you
199
+ already run, so Claude Code on a Claude subscription needs no extra API tokens.
200
+ -->
201
+
202
+ ## Caveats
203
+
204
+ - **Pushing to a *published* workflow republishes it immediately.** n8n 2.x
205
+ splits each workflow into a draft and a published version — in the editor,
206
+ *Save* updates the draft and *Publish* makes it live. The public API has no
207
+ such split: `PUT /workflows/:id` hardcodes `publishIfActive: true`, so a push
208
+ to an already-published workflow **goes live at once — there is no draft-only
209
+ update through the API** (pushing to an *unpublished* workflow only updates
210
+ its draft). This is an n8n API limitation, not decanter's; decanter surfaces
211
+ the outcome — push prints `published: code is live now` vs `unpublished:
212
+ draft only`, and `status`/`watch` show the state. For a staged rollout,
213
+ unpublish → push → publish (triggers are down in between), or push to a
214
+ staging-copy workflow.
215
+ - **n8n's optimistic locking isn't reachable through the API.** That same `PUT`
216
+ also forces `forceSave: true` and exposes no version checksum, so the server
217
+ won't reject a stale write. Decanter's **drift guard** is the only thing
218
+ protecting remote edits from being clobbered — so always `pull` (or check
219
+ `status`) before pushing. `--force` deliberately bypasses only this guard,
220
+ never the compliance guard.
221
+
222
+ ## How node files work
223
+
224
+ Node sources live in a `code/` subdir inside the workflow folder, named in
225
+ kebab-case after their node (`Parse Order` → `code/parse-order.js`).
226
+ Layouts from older versions (files at the folder root) migrate automatically
227
+ on the next `pull`.
228
+
229
+ - `code/<node>.js` — lossless: pulled/pushed byte-identical. Type-checked via
230
+ JSDoc + `checkJs`. **No `import`s** — yes, `import` is standard JavaScript,
231
+ but a `.js` node is pushed verbatim into n8n, where Code nodes cannot load
232
+ modules; `check` rejects it. Imports are a `.ts`-only feature (bundled on
233
+ push, next bullets).
234
+ - `code/<node>.ts` — one-way: local file is the source of truth. `push`
235
+ compiles it (esbuild, comments stripped) and appends a
236
+ `// @ts-n8n sha256:...` marker line; `pull` never touches the `.ts`.
237
+ To convert a node, replace `code/<node>.js` with `code/<node>.ts` and change
238
+ its `//@file:` placeholder in `workflow.json` to the `.ts` name.
239
+ - `shared/*.ts` — helpers and types **imported by `.ts` nodes** (values and
240
+ types); push bundles the imports into the compiled node, so the pushed
241
+ code is self-contained and runs anywhere — n8n Cloud included, no
242
+ `NODE_FUNCTION_ALLOW_*` setup. Small npm packages bundle the same way:
243
+ install them in the sync dir and list them under `"bundleDependencies"`
244
+ in `decanter.config.json` (pure JS only; builtins and unlisted packages
245
+ are compile errors, imports must sit at the top of the file). `.js` nodes
246
+ stay import-free — that tier is byte-lossless by contract.
247
+ - `code/<node>.remote.js` — written by `pull` when the remote code changed in
248
+ ways it can't merge (UI edit of a TS-managed node, conflict, missing local
249
+ `.ts`). Port the changes manually, then push; the file is removed on the
250
+ next in-sync pull.
251
+ - `.decanter.json` — per-folder state (node-id → file map, sync hashes).
252
+ Commit it; don't edit it.
253
+
254
+ Push refuses to overwrite remote changes made since the last sync
255
+ (`pull first`, or `--force`). Pulling records the remote state as the new
256
+ sync base — after a warned pull, push *will* overwrite the surfaced remote
257
+ edits, with `.remote.js` + git as the safety net.
258
+
259
+ Push also runs a **compliance guard** first (standalone: `check`, which needs
260
+ no credentials): inline code without a `//@file:` placeholder, placeholders
261
+ pointing at missing/`.remote.js`/non-`.js`/`.ts` files or at files outside
262
+ `code/`, an `@ts-n8n` marker inside a `.js` file, dangling connection
263
+ sources/targets, duplicate node names or ids, orphan `.js`/`.ts` files
264
+ nothing references, and dangling literal `$('…')` references (in node source
265
+ and in expression parameters) all abort the push — `--force` does not bypass
266
+ these, only the drift guard. Unresolved `.remote.js` leftovers warn without
267
+ blocking. The typecheck runs as a blocking push gate too (`--no-typecheck` to
268
+ skip; auto-skipped when no `tsconfig.json` is found).
269
+
270
+ **Renaming a node** by hand means touching four places at once (name,
271
+ connections, `$('…')` references, filename) — use
272
+ `rename <id> "<old>" "<new>"` instead: it rewrites all of them atomically,
273
+ refuses colliding names, and re-validates the folder afterwards. It works
274
+ offline; `push` propagates the rename to n8n.
275
+
276
+ ## Browser live-reload (watch)
277
+
278
+ Tired of ⌘R'ing the n8n editor after every push? Add `"browserReload":
279
+ "proxy"` to `decanter.config.json` and `watch` boots a transparent dev proxy
280
+ on `127.0.0.1:5679` (override with `"proxyPort"`):
281
+
282
+ ```json
283
+ { "root": "./workflows", "workflows": ["…"], "browserReload": "proxy" }
284
+ ```
285
+
286
+ Open the n8n editor through the **proxy URL** (`http://localhost:5679`) instead
287
+ of the real port. The proxy pipes everything to your n8n host untouched — login,
288
+ assets, and n8n's native `/rest/push` WebSocket — and injects a tiny reload
289
+ client into the editor page. Every successful `watch` push then refreshes the
290
+ tab for you, **unless the editor has unsaved changes** (it declines the reload
291
+ and logs a console warning so nothing in-browser is clobbered). If the port is
292
+ taken, `watch` warns and keeps syncing without live reload.
293
+
294
+ Built on native Node (no extra deps). It's designed for a **local http** n8n
295
+ (`http://localhost:5678`); pointing it at an https/remote host is best-effort —
296
+ Secure cookies don't survive the plain-http hop, so auth may not carry through.
297
+
298
+ ## Type checking
299
+
300
+ n8n Code node source is a function body (top-level `return`/`await`), which
301
+ plain `tsc` rejects in `.ts` files (TS1108). `npm run typecheck` therefore
302
+ runs [scripts/typecheck.mts](scripts/typecheck.mts), which wraps node files in
303
+ an `async function` in memory (a `.decanter.json` next to the file — or in the
304
+ parent of its `code/` dir — marks it as a node file) and maps diagnostics back
305
+ to real line numbers. The IDE's own tsserver doesn't apply the wrapper, so on
306
+ its own it would flag top-level `return`/`await` (TS1108/TS1375/TS1378) —
307
+ which is why `init` also scaffolds `decanter-ts-plugin/`, a language-service
308
+ plugin that suppresses exactly those codes on node files while every other
309
+ diagnostic stays live. It loads after `npm install` in the sync dir, via the
310
+ workspace TypeScript (VS Code prompts *Use Workspace Version* once; JetBrains
311
+ IDEs use the project TypeScript by default).
312
+
313
+ The CLI's own `.mts` sources are checked separately by `tsc -p
314
+ tsconfig.cli.json` (strict; the first half of `npm run typecheck`). That
315
+ config is not the root `tsconfig.json`, which belongs to the workflow node
316
+ files above.
317
+
318
+ *Not affiliated with or endorsed by n8n GmbH.*
@@ -0,0 +1,79 @@
1
+ export class N8nApi {
2
+ #host;
3
+ #apiKey;
4
+ #timeoutMs;
5
+ constructor({ host, apiKey, requestTimeoutMs = 30_000 }) {
6
+ this.#host = host;
7
+ this.#apiKey = apiKey;
8
+ this.#timeoutMs = requestTimeoutMs;
9
+ }
10
+ async getWorkflow(id) {
11
+ return this.#request("GET", `/api/v1/workflows/${encodeURIComponent(id)}`);
12
+ }
13
+ /**
14
+ * All workflows, cursor-paginated. Name matching stays client-side — the
15
+ * server-side `name` filter is exact-match only, which defeats prefix
16
+ * resolution.
17
+ */
18
+ async listWorkflows() {
19
+ const all = [];
20
+ let cursor;
21
+ do {
22
+ const query = new URLSearchParams({ limit: "100", ...(cursor !== undefined && { cursor }) });
23
+ const page = (await this.#request("GET", `/api/v1/workflows?${query}`));
24
+ all.push(...page.data);
25
+ cursor = page.nextCursor ?? undefined;
26
+ } while (cursor !== undefined);
27
+ return all;
28
+ }
29
+ /**
30
+ * Recent executions with full run data, newest first. Read-only by design —
31
+ * the executions API is never written through. `limit` caps the single page
32
+ * (the API allows up to 250); no pagination on purpose, "recent" is the use
33
+ * case.
34
+ */
35
+ async listExecutions({ workflowId, status, limit = 5 }) {
36
+ const query = new URLSearchParams({
37
+ includeData: "true",
38
+ limit: String(limit),
39
+ ...(workflowId !== undefined && { workflowId }),
40
+ ...(status !== undefined && { status }),
41
+ });
42
+ const page = (await this.#request("GET", `/api/v1/executions?${query}`));
43
+ return page.data;
44
+ }
45
+ async getExecution(id) {
46
+ return this.#request("GET", `/api/v1/executions/${encodeURIComponent(id)}?includeData=true`);
47
+ }
48
+ async updateWorkflow(id, body) {
49
+ return this.#request("PUT", `/api/v1/workflows/${encodeURIComponent(id)}`, body);
50
+ }
51
+ async #request(method, pathname, body) {
52
+ try {
53
+ // The signal also covers body consumption, so a stalled response
54
+ // stream can't hang the CLI either.
55
+ const res = await fetch(this.#host + pathname, {
56
+ method,
57
+ headers: {
58
+ "X-N8N-API-KEY": this.#apiKey,
59
+ accept: "application/json",
60
+ ...(body !== undefined && { "content-type": "application/json" }),
61
+ },
62
+ body: body !== undefined ? JSON.stringify(body) : undefined,
63
+ signal: AbortSignal.timeout(this.#timeoutMs),
64
+ });
65
+ const text = await res.text();
66
+ if (!res.ok) {
67
+ throw new Error(`${method} ${pathname} failed: ${res.status} ${res.statusText}\n${text.slice(0, 2000)}`);
68
+ }
69
+ return text ? JSON.parse(text) : undefined;
70
+ }
71
+ catch (err) {
72
+ const name = err.name;
73
+ if (name === "TimeoutError" || name === "AbortError") {
74
+ throw new Error(`${method} ${pathname} timed out after ${this.#timeoutMs / 1000}s — n8n did not respond (raise "requestTimeoutMs" in decanter.config.json for a slow instance)`);
75
+ }
76
+ throw err;
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,220 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { builtinModules } from "node:module";
3
+ import path from "node:path";
4
+ import { build, transform } from "esbuild";
5
+ // Bundling (plans/14): a Code-node body may import from shared/ (and from
6
+ // bundleDependencies-allowlisted npm packages); the imports are inlined into
7
+ // the pushed artifact, which stays a legal *function body*. Mechanism —
8
+ // esbuild rejects any top-level `import` next to a top-level `return`, so:
9
+ // hoist the import block, wrap the body in an async arrow, bundle as an
10
+ // iife, then re-enter with a top-level `return` footer.
11
+ const GLOBAL_NAME = "__n8n_node";
12
+ const SIZE_WARN_BYTES = 100_000;
13
+ const BUILTINS = new Set(builtinModules);
14
+ /**
15
+ * Split a node file into its leading top-level import block and the body.
16
+ * Dependency-free and line-precise: the imports-at-top rule means only the
17
+ * file head needs scanning — anything after the first non-import statement
18
+ * is body (a stray later import fails at bundle time with esbuild's own
19
+ * error). Dynamic `import(…)` and `import.meta` are not declarations and
20
+ * terminate the scan.
21
+ */
22
+ export function scanNodeImports(source) {
23
+ const len = source.length;
24
+ let pos = 0;
25
+ let end = 0;
26
+ const specifiers = [];
27
+ scan: for (;;) {
28
+ while (pos < len) {
29
+ const ch = source[pos];
30
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r")
31
+ pos++;
32
+ else if (ch === "/" && source[pos + 1] === "/") {
33
+ const nl = source.indexOf("\n", pos);
34
+ pos = nl === -1 ? len : nl + 1;
35
+ }
36
+ else if (ch === "/" && source[pos + 1] === "*") {
37
+ const close = source.indexOf("*/", pos + 2);
38
+ pos = close === -1 ? len : close + 2;
39
+ }
40
+ else
41
+ break;
42
+ }
43
+ if (!source.startsWith("import", pos))
44
+ break;
45
+ const boundary = source[pos + 6];
46
+ if (boundary !== undefined && /[A-Za-z0-9_$]/.test(boundary))
47
+ break; // an identifier like `importantThing`
48
+ let cursor = pos + 6;
49
+ while (cursor < len && /\s/.test(source[cursor]))
50
+ cursor++;
51
+ if (source[cursor] === "(" || source[cursor] === ".")
52
+ break; // dynamic import / import.meta
53
+ // the specifier is the first string literal in an import declaration
54
+ let quote = -1;
55
+ for (let i = cursor; i < len; i++) {
56
+ if (source[i] === '"' || source[i] === "'") {
57
+ quote = i;
58
+ break;
59
+ }
60
+ if (source[i] === ";")
61
+ break scan; // malformed — let the compiler complain
62
+ }
63
+ if (quote === -1)
64
+ break;
65
+ const closing = source.indexOf(source[quote], quote + 1);
66
+ if (closing === -1)
67
+ break;
68
+ specifiers.push(source.slice(quote + 1, closing));
69
+ let stmtEnd = closing + 1;
70
+ while (stmtEnd < len && (source[stmtEnd] === " " || source[stmtEnd] === "\t"))
71
+ stmtEnd++;
72
+ if (source[stmtEnd] === ";")
73
+ stmtEnd++;
74
+ pos = stmtEnd;
75
+ end = stmtEnd;
76
+ }
77
+ const importBlock = source.slice(0, end);
78
+ return {
79
+ importBlock,
80
+ specifiers,
81
+ body: source.slice(end),
82
+ importLines: importBlock.length === 0 ? 0 : importBlock.split("\n").length - (importBlock.endsWith("\n") ? 1 : 0),
83
+ };
84
+ }
85
+ /**
86
+ * Compile-time context: nearest decanter.config.json upward from `fromDir`.
87
+ * Reads only `bundleDependencies` — no credentials or env involved, so
88
+ * config-free verbs (`run` on a bare file) stay config-free.
89
+ */
90
+ export function findBundleContext(fromDir) {
91
+ let dir = path.resolve(fromDir);
92
+ for (;;) {
93
+ const file = path.join(dir, "decanter.config.json");
94
+ if (existsSync(file)) {
95
+ let deps;
96
+ try {
97
+ deps = JSON.parse(readFileSync(file, "utf8")).bundleDependencies;
98
+ }
99
+ catch (err) {
100
+ throw new Error(`${file}: invalid JSON (${err.message})`);
101
+ }
102
+ return {
103
+ syncRoot: dir,
104
+ bundleDependencies: Array.isArray(deps) ? deps.filter((d) => typeof d === "string") : [],
105
+ };
106
+ }
107
+ const parent = path.dirname(dir);
108
+ if (parent === dir)
109
+ return { syncRoot: null, bundleDependencies: [] };
110
+ dir = parent;
111
+ }
112
+ }
113
+ /** Package name of a bare specifier (`@scope/pkg/sub` → `@scope/pkg`). */
114
+ function packageName(spec) {
115
+ return spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
116
+ }
117
+ /**
118
+ * Offline import rules for a node file (plans/14): relative imports stay
119
+ * inside the sync dir, bare specifiers need a `bundleDependencies` opt-in,
120
+ * builtins can never be bundled. Shared by the compliance guard and the
121
+ * compiler, so `check` and `push` disagree on nothing.
122
+ */
123
+ export function checkNodeImports(file, specifiers, ctx) {
124
+ const problems = [];
125
+ for (const spec of specifiers) {
126
+ if (spec.startsWith("node:") || BUILTINS.has(packageName(spec))) {
127
+ problems.push(`imports the Node builtin "${spec}" — builtins cannot be bundled into a Code node (whether n8n allows them at runtime is the instance's NODE_FUNCTION_ALLOW_BUILTIN policy); inline the logic instead`);
128
+ }
129
+ else if (spec.startsWith("./") || spec.startsWith("../")) {
130
+ if (ctx.syncRoot !== null) {
131
+ const resolved = path.resolve(path.dirname(file), spec);
132
+ if (resolved !== ctx.syncRoot && !resolved.startsWith(ctx.syncRoot + path.sep)) {
133
+ problems.push(`imports "${spec}", which resolves outside the sync dir (${ctx.syncRoot}) — shared code must live inside it`);
134
+ }
135
+ }
136
+ }
137
+ else if (path.isAbsolute(spec)) {
138
+ problems.push(`imports the absolute path "${spec}" — use a relative import inside the sync dir`);
139
+ }
140
+ else if (!ctx.bundleDependencies.includes(packageName(spec))) {
141
+ problems.push(`imports the npm package "${packageName(spec)}" without opting it in — add it to "bundleDependencies" in decanter.config.json to bundle it into the pushed node`);
142
+ }
143
+ }
144
+ return problems;
145
+ }
146
+ /**
147
+ * One-way compile of a .ts node file to the JS that runs inside n8n.
148
+ * Without imports this is a plain esbuild transform — byte-identical to the
149
+ * pre-bundling compiler, so existing nodes never change shape or hash. With
150
+ * imports, the file is bundled self-contained (see the header comment); the
151
+ * output is still a function body ending in a top-level `return`.
152
+ */
153
+ export async function compileTs(file, log) {
154
+ const source = readFileSync(file, "utf8");
155
+ const { importBlock, specifiers, body } = scanNodeImports(source);
156
+ if (specifiers.length === 0) {
157
+ const result = await transform(source, {
158
+ loader: "ts",
159
+ format: "cjs",
160
+ target: "node18",
161
+ sourcefile: file,
162
+ });
163
+ return result.code.endsWith("\n") ? result.code : result.code + "\n";
164
+ }
165
+ const ctx = findBundleContext(path.dirname(file));
166
+ const problems = checkNodeImports(file, specifiers, ctx);
167
+ if (problems.length > 0) {
168
+ throw new Error(`${file}:\n${problems.map((p) => ` ${p}`).join("\n")}`);
169
+ }
170
+ const workingDir = ctx.syncRoot ?? path.dirname(file);
171
+ // The entry must contain NO `export` syntax: n8n's task-runner sandbox
172
+ // neuters getter property descriptors (Object.defineProperty with `get`
173
+ // reads back undefined), and esbuild lowers module exports to exactly such
174
+ // getters. A plain assignment onto a free identifier sidesteps the entire
175
+ // export machinery — esbuild inlines ESM imports directly into the iife
176
+ // scope, getter-free.
177
+ const entry = importBlock +
178
+ (importBlock.endsWith("\n") ? "" : "\n") +
179
+ `${GLOBAL_NAME}.default = async () => {\n` +
180
+ body +
181
+ "\n};\n";
182
+ let bundled;
183
+ try {
184
+ const result = await build({
185
+ stdin: {
186
+ contents: entry,
187
+ loader: "ts",
188
+ resolveDir: path.dirname(file),
189
+ sourcefile: path.basename(file),
190
+ },
191
+ bundle: true,
192
+ format: "iife",
193
+ platform: "node",
194
+ target: "node18",
195
+ write: false,
196
+ // sync-root-relative module comments -> machine-independent hashes
197
+ absWorkingDir: workingDir,
198
+ logLevel: "silent",
199
+ });
200
+ bundled = result.outputFiles[0].text;
201
+ }
202
+ catch (err) {
203
+ const e = err;
204
+ const messages = e.errors?.map((m) => (m.location?.file ? `${m.location.file}:${m.location.line}: ${m.text}` : m.text)) ?? [err.message];
205
+ throw new Error(`${file}: bundling failed\n${messages.map((m) => ` ${m}`).join("\n")}`);
206
+ }
207
+ // Same sandbox constraint, second front: esbuild's CJS-interop helper
208
+ // (__copyProps, used by __toESM for npm packages) copies properties as
209
+ // getters. Rewrite it to eager data assignment — snapshot-at-require is
210
+ // normal CommonJS behavior, and our bundles have no live-binding needs.
211
+ bundled = bundled.replace(/__defProp\(to, key, \{ get: \(\) => from\[key\], enumerable: [^}]+\}\);/, "to[key] = from[key];");
212
+ if (/\b__export\(/.test(bundled)) {
213
+ log?.warn(`${file}: the bundle contains lazily-wrapped modules (import cycle or top-level await in shared code?) — these rely on getter exports, which n8n's Code-node sandbox does not support; restructure the shared imports`);
214
+ }
215
+ const code = `var ${GLOBAL_NAME} = {};\n${bundled}return ${GLOBAL_NAME}.default();\n`;
216
+ if (code.length > SIZE_WARN_BYTES && log) {
217
+ log.warn(`${file}: compiled node is ${Math.round(code.length / 1024)} KB after bundling — large nodes bloat the workflow JSON; consider trimming imports`);
218
+ }
219
+ return code;
220
+ }