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.
- package/CHANGELOG.md +369 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/dist/lib/api.mjs +79 -0
- package/dist/lib/compile.mjs +220 -0
- package/dist/lib/config.mjs +61 -0
- package/dist/lib/diff.mjs +74 -0
- package/dist/lib/executions.mjs +76 -0
- package/dist/lib/git.mjs +44 -0
- package/dist/lib/init.mjs +152 -0
- package/dist/lib/picker.mjs +226 -0
- package/dist/lib/prompt.mjs +34 -0
- package/dist/lib/proxy.mjs +204 -0
- package/dist/lib/pull.mjs +148 -0
- package/dist/lib/push.mjs +136 -0
- package/dist/lib/rename.mjs +156 -0
- package/dist/lib/run.mjs +208 -0
- package/dist/lib/state.mjs +156 -0
- package/dist/lib/status.mjs +102 -0
- package/dist/lib/style.mjs +40 -0
- package/dist/lib/types.mjs +2 -0
- package/dist/lib/util.mjs +201 -0
- package/dist/lib/validate.mjs +225 -0
- package/dist/lib/watch.mjs +296 -0
- package/dist/n8n-decanter.mjs +482 -0
- package/dist/scripts/typecheck.mjs +90 -0
- package/package.json +51 -0
- package/template/.claude/hooks/verify.mjs.example +48 -0
- package/template/.claude/settings.local.json.example +35 -0
- package/template/.cursor/rules/n8n-decanter.mdc.example +21 -0
- package/template/.env.example +10 -0
- package/template/.mcp.json.example +16 -0
- package/template/.vscode/settings.json.example +6 -0
- package/template/AGENTS.md.example +302 -0
- package/template/CLAUDE.md.example +20 -0
- package/template/decanter-ts-plugin/index.js.example +58 -0
- package/template/decanter-ts-plugin/package.json.example +7 -0
- package/template/n8n-globals.d.ts.example +190 -0
- package/template/opencode.json.example +19 -0
- package/template/package.json.example +15 -0
- package/template/shared/.gitkeep +0 -0
- package/template/shared/example-helpers.ts +13 -0
- package/template/tsconfig.json.example +26 -0
- package/template/workflows/.gitkeep +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# n8n-decanter
|
|
2
|
+
|
|
3
|
+
[](https://github.com/buttjer/n8n-decanter/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/n8n-decanter)
|
|
5
|
+
[](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
|
+

|
|
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.*
|
package/dist/lib/api.mjs
ADDED
|
@@ -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
|
+
}
|