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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Edit(workflows/**)",
|
|
5
|
+
"Edit(shared/**)",
|
|
6
|
+
"Bash(n8n-decanter check)",
|
|
7
|
+
"Bash(n8n-decanter check:*)",
|
|
8
|
+
"Bash(n8n-decanter run:*)",
|
|
9
|
+
"Bash(n8n-decanter uuid:*)",
|
|
10
|
+
"Bash(n8n-decanter status:*)",
|
|
11
|
+
"Bash(npm run typecheck)",
|
|
12
|
+
"Bash(npm run check)"
|
|
13
|
+
],
|
|
14
|
+
"deny": [
|
|
15
|
+
"Edit(**/.decanter.json)",
|
|
16
|
+
"Edit(**/*.remote.js)",
|
|
17
|
+
"Read(.env)",
|
|
18
|
+
"Edit(.env)",
|
|
19
|
+
"Bash(n8n-decanter push --force)",
|
|
20
|
+
"Bash(n8n-decanter push --force:*)",
|
|
21
|
+
"Bash(node n8n-decanter.mts push --force)",
|
|
22
|
+
"Bash(node n8n-decanter.mts push --force:*)"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"hooks": {
|
|
26
|
+
"PostToolUse": [
|
|
27
|
+
{
|
|
28
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
29
|
+
"hooks": [
|
|
30
|
+
{ "type": "command", "command": "node .claude/hooks/verify.mjs" }
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: n8n-decanter repo — how to treat decanted workflow files
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
This repository is managed by **n8n-decanter**. The authoritative contract is
|
|
7
|
+
`AGENTS.md` at the repo root — read it in full before editing workflow files.
|
|
8
|
+
|
|
9
|
+
Hard rules (violating any of these corrupts sync state):
|
|
10
|
+
|
|
11
|
+
- `jsCode` in any `workflow.json` is only a `//@file:code/<name>` placeholder. Never
|
|
12
|
+
inline code there — edit the referenced source file in the folder's `code/` subdir.
|
|
13
|
+
- Never edit `.decanter.json` (machine state) or any `*.remote.js` (incoming-change
|
|
14
|
+
artifact — port its change into the matching `.ts`, then delete it).
|
|
15
|
+
- Never write a `// @ts-n8n sha256:` line — the tool appends it on push.
|
|
16
|
+
- Code nodes run as a function body with a top-level `return`, no `import`/`require`.
|
|
17
|
+
- Run `npm run typecheck` after editing node source. `push` is a deploy — only when asked,
|
|
18
|
+
and never `--force` without an explicit instruction.
|
|
19
|
+
|
|
20
|
+
Cursor applies this as guidance only; the n8n-decanter CLI enforces the same invariants
|
|
21
|
+
at `push` regardless of which tool made the edit.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Copy to .env and fill in. .env is gitignored.
|
|
2
|
+
N8N_HOST=https://your-n8n.example.com
|
|
3
|
+
|
|
4
|
+
# Prefer a SCOPED API key (n8n → Settings → n8n API → Create) limited to the
|
|
5
|
+
# permissions this tool actually uses, not a full-access key — smaller blast
|
|
6
|
+
# radius if .env leaks:
|
|
7
|
+
# workflow:read workflow:list workflow:update (pull / status / push)
|
|
8
|
+
# execution:read execution:list (executions)
|
|
9
|
+
# A full-access key also works.
|
|
10
|
+
N8N_API_KEY=n8n_api_...
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mcpServers": {
|
|
3
|
+
"n8n-mcp": {
|
|
4
|
+
"command": "sh",
|
|
5
|
+
"args": [
|
|
6
|
+
"-c",
|
|
7
|
+
"set -a; [ -f ./.env ] && . ./.env; set +a; if [ -n \"$N8N_HOST\" ]; then N8N_API_URL=\"$N8N_HOST\"; export N8N_API_URL; fi; exec npx n8n-mcp"
|
|
8
|
+
],
|
|
9
|
+
"env": {
|
|
10
|
+
"MCP_MODE": "stdio",
|
|
11
|
+
"LOG_LEVEL": "error",
|
|
12
|
+
"DISABLE_CONSOLE_OUTPUT": "true"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Use the workspace TypeScript (devDependency) so tsserver can load
|
|
3
|
+
// decanter-ts-plugin from node_modules — VS Code asks once ("Use Workspace
|
|
4
|
+
// Version"). The bundled tsserver cannot see workspace plugins.
|
|
5
|
+
"typescript.tsdk": "node_modules/typescript/lib"
|
|
6
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# n8n-decanter — guide for coding agents
|
|
2
|
+
|
|
3
|
+
This is the **source of truth** for working in this repository, and it is written to
|
|
4
|
+
be tool-agnostic. Codex and opencode read this file natively; Claude Code reads it
|
|
5
|
+
through a one-line `@AGENTS.md` import in `CLAUDE.md`; other agents can be pointed at
|
|
6
|
+
it (see `SCAFFOLD.md`). Keep project-specific rules here so every agent stays in sync.
|
|
7
|
+
|
|
8
|
+
This repo holds n8n workflows *decanted* into git-friendly form: one folder per
|
|
9
|
+
workflow containing `workflow.json` plus a `code/` subdir with one source file
|
|
10
|
+
per Code node. The
|
|
11
|
+
`n8n-decanter` CLI moves content between here and the live n8n instance
|
|
12
|
+
(`pull` / `push`). This document is the contract for creating and editing these files.
|
|
13
|
+
|
|
14
|
+
## How this differs from standard n8n editing — read this first
|
|
15
|
+
|
|
16
|
+
Your general n8n knowledge (docs, n8n MCP tools, training data) is **valid** for:
|
|
17
|
+
node types and parameters, expression syntax, connection semantics, the Code-node
|
|
18
|
+
runtime. It is **wrong** about how code is stored here. The deviations:
|
|
19
|
+
|
|
20
|
+
1. **`jsCode` in `workflow.json` never contains code.** Its entire value is a
|
|
21
|
+
placeholder: `//@file:code/<filename>`
|
|
22
|
+
(e.g. `"jsCode": "//@file:code/amazon-feed.ts"`).
|
|
23
|
+
Never inline JavaScript into `workflow.json`; edit the referenced file.
|
|
24
|
+
2. **Code node sources live in the folder's `code/` subdir**, named after the
|
|
25
|
+
node in kebab-case (`Amazon Feed` → `code/amazon-feed.ts`). `.js` files are
|
|
26
|
+
pushed verbatim (what you write is exactly what runs in n8n). `.ts` files
|
|
27
|
+
are compiled on push; the local `.ts` is the only source of truth — n8n
|
|
28
|
+
holds compiled output.
|
|
29
|
+
3. **Never write a `// @ts-n8n sha256:…` line.** That marker is appended by the
|
|
30
|
+
tool to *compiled* output on push. It must not appear in any repo file.
|
|
31
|
+
4. **`.decanter.json` is machine state** (node-id → filename map, drift-detection
|
|
32
|
+
hashes). Never edit it by hand; never "fix" a hash.
|
|
33
|
+
5. **`code/<node>.remote.js` is an incoming-change artifact** written by `pull` when
|
|
34
|
+
someone edited a TS-managed node in the n8n UI. Handle it like an incoming
|
|
35
|
+
diff: port the change into the `.ts`, delete the `.remote.js`, then the user
|
|
36
|
+
pushes. Never edit `.remote.js` itself; never push while one exists.
|
|
37
|
+
6. **Renames have no UI magic here.** The n8n editor auto-updates references on
|
|
38
|
+
rename; raw JSON editing does not. See the rename checklist below.
|
|
39
|
+
7. **New workflows are born in n8n, not here.** The tool has no create. To add a
|
|
40
|
+
workflow: create it (even empty) in the n8n UI, add its id to
|
|
41
|
+
`decanter.config.json`, then the user runs `pull`.
|
|
42
|
+
|
|
43
|
+
> **Enforcement.** Rules 1, 3, 4, 5 are *hard invariants* — violating them corrupts
|
|
44
|
+
> sync state. They are enforced by the CLI at `push` time regardless of who made the
|
|
45
|
+
> edit (any agent, or a human). On Claude Code and opencode they are additionally
|
|
46
|
+
> guarded before the write happens (see `.claude/settings.json` and `opencode.json`).
|
|
47
|
+
> If a write is blocked with a `decanter-guard` reason, the edit broke a hard rule —
|
|
48
|
+
> fix the edit, don't work around the guard.
|
|
49
|
+
|
|
50
|
+
## File ownership
|
|
51
|
+
|
|
52
|
+
| File | Written by | May the agent edit it? |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `workflow.json` | pull + you | Yes — structure, parameters, connections. Placeholders stay placeholders. |
|
|
55
|
+
| `code/<node>.js` | pull + you | Yes — this is live node code, verbatim. |
|
|
56
|
+
| `code/<node>.ts` | you only | Yes — pull never touches it. |
|
|
57
|
+
| `code/<node>.remote.js` | pull only | No — read, port into the `.ts`, delete. |
|
|
58
|
+
| `.decanter.json` | tool only | No. |
|
|
59
|
+
| `decanter.config.json` | you | Yes — add/remove workflow ids. |
|
|
60
|
+
| `n8n-globals.d.ts` | you | Yes — extend when node code uses a global it doesn't cover yet. |
|
|
61
|
+
|
|
62
|
+
## Editing `workflow.json`
|
|
63
|
+
|
|
64
|
+
Pretty-printed, 2-space indent, keep the existing key order (clean diffs are the
|
|
65
|
+
point of this repo).
|
|
66
|
+
|
|
67
|
+
A node object:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"id": "a1b2c3d4-…", // UUID v4 — `n8n-decanter uuid` to generate; NEVER change an existing id
|
|
72
|
+
"name": "Amazon Feed", // unique per workflow — connections + $('…') reference it
|
|
73
|
+
"type": "n8n-nodes-base.code",
|
|
74
|
+
"typeVersion": 2,
|
|
75
|
+
"position": [880, 300],
|
|
76
|
+
"parameters": {
|
|
77
|
+
"mode": "runOnceForAllItems", // or "runOnceForEachItem"
|
|
78
|
+
"jsCode": "//@file:code/amazon-feed.ts"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Connections are keyed by **node name**, not id:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
"connections": {
|
|
87
|
+
"Fetch Products": { "main": [[{ "node": "Amazon Feed", "type": "main", "index": 0 }]] }
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Rules:
|
|
92
|
+
|
|
93
|
+
- **Expressions** in parameters keep their `=` prefix: `"={{ $json.sku }}"`.
|
|
94
|
+
A value without `=` is a literal string.
|
|
95
|
+
- **Credentials** are referenced as `{ "id": "…", "name": "…" }` and must already
|
|
96
|
+
exist in n8n — never invent credential ids.
|
|
97
|
+
- **Not synced / don't add:** `active`, `tags`, `pinData`, `shared`, timestamps.
|
|
98
|
+
The push endpoint only accepts `name`, `nodes`, `connections`, `settings`
|
|
99
|
+
(whitelisted keys), `staticData`. Activation is managed in the n8n UI.
|
|
100
|
+
- **Position:** keep a sensible grid; new nodes roughly +200 x from their input.
|
|
101
|
+
|
|
102
|
+
### Renaming a node — checklist (all four, atomically)
|
|
103
|
+
|
|
104
|
+
1. `name` in the node object.
|
|
105
|
+
2. Every reference in `connections` (keys *and* target `"node"` values).
|
|
106
|
+
3. Every `$('Old Name')` in **all** code files of the workflow.
|
|
107
|
+
4. The source file name and its `//@file:` placeholder (keep the kebab-case
|
|
108
|
+
name-file convention, file stays in `code/`). The tool re-maps by node id
|
|
109
|
+
on the next pull, but the repo must be self-consistent immediately.
|
|
110
|
+
|
|
111
|
+
Renaming a **workflow**: change `name` in `workflow.json`; folder rename happens
|
|
112
|
+
on the next pull (or do it yourself, folder names are cosmetic — the id inside
|
|
113
|
+
`workflow.json` is authoritative).
|
|
114
|
+
|
|
115
|
+
### Adding a Code node
|
|
116
|
+
|
|
117
|
+
1. Add the node object — generate its `id` with `n8n-decanter uuid` (a lowercase
|
|
118
|
+
v4 UUID), placeholder `jsCode`, position, connections.
|
|
119
|
+
2. Create the source file it points at in `code/` (`.js` or `.ts`, see below).
|
|
120
|
+
3. Verify: `n8n-decanter run <file> [fixture.json]` to execute it, then
|
|
121
|
+
`n8n-decanter check` (typecheck + layout).
|
|
122
|
+
|
|
123
|
+
## Writing Code node code
|
|
124
|
+
|
|
125
|
+
The runtime contract (identical for `.js` and `.ts` — types are erased):
|
|
126
|
+
|
|
127
|
+
- Code runs as a function body, **top-level `return` required**.
|
|
128
|
+
- `runOnceForAllItems`: return an array of items: `[{ json: {…} }, …]`.
|
|
129
|
+
Always wrap in `{ json: … }` explicitly.
|
|
130
|
+
- `runOnceForEachItem`: return a single `{ json: {…} }`.
|
|
131
|
+
- Editors squiggle that top-level `return`/`await` (TS1108/TS1375/TS1378) —
|
|
132
|
+
a **false positive** from the editor's own tsserver; ignore it, and never
|
|
133
|
+
"fix" it by wrapping the file in a function. `n8n-decanter check` wraps
|
|
134
|
+
node bodies in memory before typechecking and is authoritative.
|
|
135
|
+
The bundled `decanter-ts-plugin/` suppresses exactly these three codes on
|
|
136
|
+
node files (all other diagnostics stay live) once tsserver runs the
|
|
137
|
+
workspace TypeScript: `npm install`, then in VS Code accept
|
|
138
|
+
*Use Workspace Version* (offered via `.vscode/settings.json`'s
|
|
139
|
+
`typescript.tsdk`; JetBrains uses the project TypeScript by default).
|
|
140
|
+
- Available globals (extend `n8n-globals.d.ts` when you use one it lacks):
|
|
141
|
+
`$input.all()/.first()/.last()/.item` · `$('Node Name').all()/.first()/.item/.itemMatching(i)` ·
|
|
142
|
+
`$json`, `$binary`, `$itemIndex` (each-item mode) ·
|
|
143
|
+
`DateTime`, `Duration`, `Interval` (Luxon), `$now`, `$today` ·
|
|
144
|
+
`$jmespath()` · `$workflow`, `$execution`, `$prevNode`, `$runIndex` ·
|
|
145
|
+
`$getWorkflowStaticData('global' | 'node')`.
|
|
146
|
+
- **Imports**: `.js` nodes — none at all (they run verbatim; assume the
|
|
147
|
+
instance allows no external or built-in modules). `.ts` nodes — imports
|
|
148
|
+
from inside this repo (`shared/`, relative paths) and from npm packages
|
|
149
|
+
listed in `decanter.config.json`'s `"bundleDependencies"` are **bundled
|
|
150
|
+
into the compiled node on push** (self-contained output, works on any
|
|
151
|
+
instance incl. n8n Cloud). Rules: imports at the **top of the file** only;
|
|
152
|
+
relative paths must stay inside this repo; unlisted npm packages and Node
|
|
153
|
+
builtins (`node:*`, `fs`, `crypto`, …) are compile errors. Never
|
|
154
|
+
`require()`.
|
|
155
|
+
- `console.log` is available for debugging (visible in the execution view).
|
|
156
|
+
- `$('Node Name')` takes the node's **name** — see the rename checklist.
|
|
157
|
+
|
|
158
|
+
### `.js` nodes (default — lossless)
|
|
159
|
+
|
|
160
|
+
What you write is byte-for-byte what runs and what round-trips back on pull.
|
|
161
|
+
Type-safety via JSDoc:
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
// @ts-check
|
|
165
|
+
|
|
166
|
+
/** @typedef {{ sku: string, qty: number }} StockLine */
|
|
167
|
+
|
|
168
|
+
/** @type {StockLine[]} */
|
|
169
|
+
const lines = [];
|
|
170
|
+
for (const item of $input.all()) {
|
|
171
|
+
// …
|
|
172
|
+
}
|
|
173
|
+
return lines.map((l) => ({ json: l }));
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
- First line is `// @ts-check`.
|
|
177
|
+
- Prefer `@typedef` for shapes, inline casts `/** @type {Foo} */ (x)` where
|
|
178
|
+
narrowing is needed. `@template`, `@satisfies` are available.
|
|
179
|
+
- Comments are fine and encouraged — they survive into n8n and document the
|
|
180
|
+
node in place.
|
|
181
|
+
|
|
182
|
+
### `.ts` nodes (one-way)
|
|
183
|
+
|
|
184
|
+
Choose `.ts` when the type surface is heavy (large interfaces, generics,
|
|
185
|
+
discriminated unions). Costs, so you can weigh them:
|
|
186
|
+
|
|
187
|
+
- esbuild compiles on push: **comments are stripped** and **line numbers shift**
|
|
188
|
+
— n8n error line numbers won't match the source, and the node code in the
|
|
189
|
+
n8n UI is undocumented output. Documentation belongs in the `.ts`.
|
|
190
|
+
- UI edits can't be merged back automatically (they arrive as `.remote.js`).
|
|
191
|
+
|
|
192
|
+
Rules: full TS is fine (interfaces, type aliases, enums, generics); still
|
|
193
|
+
ends in a top-level `return`. Never commit compiled output; never add the
|
|
194
|
+
marker line.
|
|
195
|
+
|
|
196
|
+
**Shared code (`shared/`)**: put helpers and types used by several nodes in
|
|
197
|
+
`shared/*.ts` and import them from `.ts` nodes — types *and* functions:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { total, type OrderLine } from "../../shared/money";
|
|
201
|
+
|
|
202
|
+
const lines: OrderLine[] = $input.all().map((i) => i.json as OrderLine);
|
|
203
|
+
return [{ json: { total: total(lines) } }];
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Push inlines the imported code into the compiled node (each importing node
|
|
207
|
+
carries its own copy — keep helpers small). Editing a shared file makes every
|
|
208
|
+
importing node show up as "push pending" in `status`; push to propagate.
|
|
209
|
+
Small npm libraries work the same way once installed here (`npm i zod`) and
|
|
210
|
+
opted in via `"bundleDependencies": ["zod"]` in `decanter.config.json` —
|
|
211
|
+
pure-JS packages only (no native addons, no builtins). `.js` nodes cannot
|
|
212
|
+
import; give them JSDoc `@typedef`s or convert to `.ts`.
|
|
213
|
+
|
|
214
|
+
### Switching a node `.js` → `.ts` (or back)
|
|
215
|
+
|
|
216
|
+
1. Rename the file, convert JSDoc ↔ annotations.
|
|
217
|
+
2. Update the `//@file:` placeholder in `workflow.json`.
|
|
218
|
+
3. `n8n-decanter check`. The tool picks up the new extension on next push;
|
|
219
|
+
direction of sync changes accordingly (`.ts` = one-way).
|
|
220
|
+
|
|
221
|
+
## Verification
|
|
222
|
+
|
|
223
|
+
Two offline tools, always available (no credentials, no network, no live n8n):
|
|
224
|
+
|
|
225
|
+
- **`n8n-decanter check`** — runs the layout-compliance guard *and* the
|
|
226
|
+
typecheck (the same wrapper that maps top-level-`return` node bodies back to
|
|
227
|
+
real line numbers). Run it after editing any code file or `n8n-globals.d.ts`;
|
|
228
|
+
treat a failure as a blocker. `npm run typecheck` is an alias for it.
|
|
229
|
+
- **`n8n-decanter run <file> [fixture.json]`** — actually executes a node's body
|
|
230
|
+
against a faked n8n context (`$input`, `$json`, `$('Node')`, `DateTime`, …) and
|
|
231
|
+
prints the items it returns. Prefer this over hand-rolling a throwaway test
|
|
232
|
+
script.
|
|
233
|
+
- The optional fixture JSON supplies the context; every field is optional:
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"input": [{ "json": { "sku": "A1" } }],
|
|
238
|
+
"nodes": { "Fetch Products": [{ "json": { "id": 1 } }] },
|
|
239
|
+
"env": { "REGION": "eu" },
|
|
240
|
+
"staticData": { "global": { "cursor": 42 } }
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`input` feeds `$input`/`$json`; `nodes` backs `$('Node Name')`; without a
|
|
245
|
+
fixture the input defaults to a single empty item. `env` backs `$env`,
|
|
246
|
+
which is **empty by default** (like n8n's scoped `$env`) — set it here, or
|
|
247
|
+
pass `--allow-env` to inherit the process environment (may include
|
|
248
|
+
`N8N_API_KEY`; never `run --allow-env` an untrusted node).
|
|
249
|
+
`$getWorkflowStaticData` is seeded from `workflow.json`'s `staticData`
|
|
250
|
+
(`global` and this node's `node:` slice); a fixture `staticData` replaces
|
|
251
|
+
the matching slice (`"node"` = the node being run). Mutations are visible
|
|
252
|
+
during the run but never persisted — `run` is offline.
|
|
253
|
+
- Run mode (`runOnceForAllItems` / `runOnceForEachItem`) is read from the node
|
|
254
|
+
in `workflow.json`, so each-item nodes are looped once per input item.
|
|
255
|
+
- JSON validity of `workflow.json` after every structural edit.
|
|
256
|
+
|
|
257
|
+
## Real execution data (`executions/` — temporary, gitignored)
|
|
258
|
+
|
|
259
|
+
When you need to see what data actually flows through a workflow — to write an
|
|
260
|
+
accurate `run` fixture, or to debug a node against real payloads — fetch recent
|
|
261
|
+
executions from the instance instead of guessing shapes:
|
|
262
|
+
|
|
263
|
+
```sh
|
|
264
|
+
n8n-decanter "<workflow>" executions # 5 most recent, full run data
|
|
265
|
+
n8n-decanter "<workflow>" executions --status=error --limit=3
|
|
266
|
+
n8n-decanter executions 4711 # one execution by its id
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Each execution lands as `workflows/<Name>/executions/<execId>.json`. The items
|
|
270
|
+
a node produced are under
|
|
271
|
+
`data.resultData.runData["<Node Name>"][0].data.main[0][]` — each entry's
|
|
272
|
+
`.json` is one item, exactly what `$('<Node Name>')` (and the downstream node's
|
|
273
|
+
`$input`) sees at runtime. Copy real item shapes from here into `run` fixtures.
|
|
274
|
+
|
|
275
|
+
Rules:
|
|
276
|
+
|
|
277
|
+
- **Temporary working data, never committed.** The tool writes the dir
|
|
278
|
+
self-ignoring (`executions/.gitignore` containing `*`) because run data can
|
|
279
|
+
hold credentials and PII — never weaken that, and never copy secrets from it
|
|
280
|
+
into code or fixtures.
|
|
281
|
+
- **Clean up when the task that needed it is done:**
|
|
282
|
+
`n8n-decanter executions clean` (offline; removes every `executions/` dir,
|
|
283
|
+
or pass a workflow ref to scope it).
|
|
284
|
+
- **Convenience, not ground truth.** Executions run the *published* workflow
|
|
285
|
+
version (n8n 2.x), which may be older than the local draft, and payloads
|
|
286
|
+
change over time — re-fetch rather than trusting stale files.
|
|
287
|
+
|
|
288
|
+
## Commands — who runs what
|
|
289
|
+
|
|
290
|
+
- **Offline, safe — run freely:** `n8n-decanter check` (or `npm run typecheck`),
|
|
291
|
+
`n8n-decanter run <file> [fixture]`, `n8n-decanter uuid [n]`,
|
|
292
|
+
`n8n-decanter executions clean`. These never touch the network.
|
|
293
|
+
- **Reads remote, no writes:** `n8n-decanter status` (`--diff` shows the line
|
|
294
|
+
diffs; exits 1 on conflict/remote drift) and `n8n-decanter <ref> executions`
|
|
295
|
+
(fetches execution data, read-only) — safe to run, but they do contact the
|
|
296
|
+
instance.
|
|
297
|
+
- **Live n8n instance — only when the user explicitly asks:** `n8n-decanter
|
|
298
|
+
pull` / `push` / `watch` read/write the real instance and `push` deploys.
|
|
299
|
+
Otherwise finish edits, verify with `check` + `run`, and report that the
|
|
300
|
+
change is ready to push.
|
|
301
|
+
- Never use `--force` on push without the user's explicit instruction — it
|
|
302
|
+
overrides the drift guard that protects edits made in the n8n UI.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
@AGENTS.md
|
|
2
|
+
|
|
3
|
+
## Claude Code specifics
|
|
4
|
+
|
|
5
|
+
The contract above (from `AGENTS.md`) is authoritative. A few Claude-only notes:
|
|
6
|
+
|
|
7
|
+
- **File-level invariants are enforced by `.claude/settings.local.json`.** Its
|
|
8
|
+
permission deny-list blocks edits to `.decanter.json` and `*.remote.js`, reads/edits
|
|
9
|
+
of `.env`, and `push --force`. If an `Edit`/`Write` is blocked, it broke a hard rule —
|
|
10
|
+
fix the edit, don't route around the guard. The content-level invariants (jsCode
|
|
11
|
+
placeholder integrity, the `@ts-n8n` marker) are the CLI's wall at `push`/`check`.
|
|
12
|
+
- **After editing a node source (`.ts`/`.js`), a PostToolUse hook runs
|
|
13
|
+
`n8n-decanter check`** (layout compliance + typecheck) via `.claude/hooks/verify.mjs`
|
|
14
|
+
and feeds any failure back to you. Treat a failure as a blocker to finishing the task.
|
|
15
|
+
The hook is a quiet no-op until the CLI is on your PATH (install it globally or
|
|
16
|
+
`npm link` it) — until then run `n8n-decanter check` / `n8n-decanter run` yourself.
|
|
17
|
+
- **Renames must be atomic** (all four steps in the AGENTS.md rename checklist land in
|
|
18
|
+
one change). Use plan mode for multi-node renames so nothing is left half-updated.
|
|
19
|
+
- **`push --force` is blocked from the agent by design.** It overrides the drift guard;
|
|
20
|
+
if it is genuinely needed, ask the user to run it themselves in the terminal.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// decanter-ts-plugin — TypeScript language-service plugin that hides the
|
|
2
|
+
// editor-only false positives on n8n Code node files. Node source is a
|
|
3
|
+
// *function body* (top-level `return` / `await` is legal), but the editor's
|
|
4
|
+
// tsserver reads the raw file and reports TS1108 / TS1375 / TS1378.
|
|
5
|
+
// `n8n-decanter check` wraps node bodies in memory and stays authoritative;
|
|
6
|
+
// this plugin only filters those three codes in the editor, for node files
|
|
7
|
+
// only — every other diagnostic and every other file passes through.
|
|
8
|
+
//
|
|
9
|
+
// CommonJS on purpose: tsserver loads plugins via require(). Loaded through
|
|
10
|
+
// the tsconfig "plugins" entry; resolves from node_modules via the
|
|
11
|
+
// `file:./decanter-ts-plugin` devDependency (VS Code needs the workspace
|
|
12
|
+
// TypeScript version for that — see AGENTS.md).
|
|
13
|
+
const FILTERED = new Set([
|
|
14
|
+
1108, // A 'return' statement can only be used within a function body.
|
|
15
|
+
1375, // 'await' expressions are only allowed at the top level of a module …
|
|
16
|
+
1378, // Top-level 'await' expressions require module es2022+ …
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function init({ typescript: ts }) {
|
|
20
|
+
function create(info) {
|
|
21
|
+
const ls = info.languageService;
|
|
22
|
+
const fileExists = (p) =>
|
|
23
|
+
info.languageServiceHost.fileExists
|
|
24
|
+
? info.languageServiceHost.fileExists(p)
|
|
25
|
+
: ts.sys.fileExists(p);
|
|
26
|
+
|
|
27
|
+
// Mirrors isNodeFile in n8n-decanter's scripts/typecheck.mts — keep the
|
|
28
|
+
// two in sync: .ts/.js, not *.d.ts / *.remote.js, with a .decanter.json
|
|
29
|
+
// sibling directly or (code/ layout) in the parent of the code/ dir.
|
|
30
|
+
function isNodeFile(fileName) {
|
|
31
|
+
const file = fileName.replace(/\\/g, "/");
|
|
32
|
+
if (file.endsWith(".d.ts") || file.endsWith(".remote.js")) return false;
|
|
33
|
+
if (!/\.(ts|js)$/.test(file)) return false;
|
|
34
|
+
const cut = file.lastIndexOf("/");
|
|
35
|
+
if (cut < 0) return false;
|
|
36
|
+
const dir = file.slice(0, cut);
|
|
37
|
+
if (fileExists(dir + "/.decanter.json")) return true;
|
|
38
|
+
return dir.endsWith("/code") && fileExists(dir.slice(0, -"/code".length) + "/.decanter.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const proxy = Object.create(null);
|
|
42
|
+
for (const k of Object.keys(ls)) {
|
|
43
|
+
proxy[k] = (...args) => ls[k].apply(ls, args);
|
|
44
|
+
}
|
|
45
|
+
const filtered = (fileName, diags) =>
|
|
46
|
+
isNodeFile(fileName) ? diags.filter((d) => !FILTERED.has(d.code)) : diags;
|
|
47
|
+
// Both channels: grammar errors like 1108 are raised in the checker
|
|
48
|
+
// (semantic) on current TS, but cover syntactic too in case a TS version
|
|
49
|
+
// moves them.
|
|
50
|
+
proxy.getSemanticDiagnostics = (fileName) => filtered(fileName, ls.getSemanticDiagnostics(fileName));
|
|
51
|
+
proxy.getSyntacticDiagnostics = (fileName) => filtered(fileName, ls.getSyntacticDiagnostics(fileName));
|
|
52
|
+
return proxy;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { create };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = init;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Ambient globals available inside n8n Code nodes ("Run Once for All Items" /
|
|
2
|
+
// "Run Once for Each Item"). Pragmatic subset — loose where n8n is dynamic.
|
|
3
|
+
|
|
4
|
+
interface N8nBinaryData {
|
|
5
|
+
data: string;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
fileName?: string;
|
|
8
|
+
fileExtension?: string;
|
|
9
|
+
fileSize?: string;
|
|
10
|
+
directory?: string;
|
|
11
|
+
id?: string;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface N8nItem {
|
|
16
|
+
json: Record<string, any>;
|
|
17
|
+
binary?: Record<string, N8nBinaryData>;
|
|
18
|
+
pairedItem?: number | { item: number; input?: number };
|
|
19
|
+
error?: Error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface N8nInput {
|
|
23
|
+
all(branchIndex?: number, runIndex?: number): N8nItem[];
|
|
24
|
+
first(branchIndex?: number, runIndex?: number): N8nItem;
|
|
25
|
+
last(branchIndex?: number, runIndex?: number): N8nItem;
|
|
26
|
+
/** Only in "Run Once for Each Item" mode. */
|
|
27
|
+
item: N8nItem;
|
|
28
|
+
params?: Record<string, any>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface N8nNodeRef {
|
|
32
|
+
all(branchIndex?: number, runIndex?: number): N8nItem[];
|
|
33
|
+
first(branchIndex?: number, runIndex?: number): N8nItem;
|
|
34
|
+
last(branchIndex?: number, runIndex?: number): N8nItem;
|
|
35
|
+
item: N8nItem | undefined;
|
|
36
|
+
itemMatching(itemIndex: number): N8nItem;
|
|
37
|
+
params: Record<string, any>;
|
|
38
|
+
context: Record<string, any>;
|
|
39
|
+
isExecuted: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Access output of an earlier node: `$("Node Name").all()` */
|
|
43
|
+
declare function $(nodeName: string): N8nNodeRef;
|
|
44
|
+
|
|
45
|
+
declare const $input: N8nInput;
|
|
46
|
+
/** Shorthand for the current item's json ("Run Once for Each Item" mode). */
|
|
47
|
+
declare const $json: Record<string, any>;
|
|
48
|
+
declare const $binary: Record<string, N8nBinaryData>;
|
|
49
|
+
declare const $env: Record<string, string | undefined>;
|
|
50
|
+
|
|
51
|
+
declare const $execution: {
|
|
52
|
+
id: string;
|
|
53
|
+
mode: "test" | "production";
|
|
54
|
+
resumeUrl?: string;
|
|
55
|
+
resumeFormUrl?: string;
|
|
56
|
+
customData?: {
|
|
57
|
+
set(key: string, value: string): void;
|
|
58
|
+
get(key: string): string | undefined;
|
|
59
|
+
setAll(values: Record<string, string>): void;
|
|
60
|
+
getAll(): Record<string, string>;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
declare const $workflow: { id: string; name: string; active: boolean };
|
|
65
|
+
declare const $prevNode: { name: string; outputIndex: number; runIndex: number };
|
|
66
|
+
declare const $runIndex: number;
|
|
67
|
+
declare const $itemIndex: number;
|
|
68
|
+
declare const $nodeVersion: number;
|
|
69
|
+
declare const $nodeId: string;
|
|
70
|
+
declare const $webhookId: string | undefined;
|
|
71
|
+
|
|
72
|
+
/** Per-workflow / per-node persisted store. */
|
|
73
|
+
declare function $getWorkflowStaticData(type: "global" | "node"): Record<string, any>;
|
|
74
|
+
|
|
75
|
+
declare function $jmespath(data: unknown, expression: string): any;
|
|
76
|
+
declare function $evaluateExpression(expression: string, itemIndex?: number): any;
|
|
77
|
+
declare function $if<T, F>(condition: boolean, valueIfTrue: T, valueIfFalse: F): T | F;
|
|
78
|
+
declare function $min(...numbers: number[]): number;
|
|
79
|
+
declare function $max(...numbers: number[]): number;
|
|
80
|
+
|
|
81
|
+
/** Legacy helpers */
|
|
82
|
+
declare function $items(nodeName?: string, outputIndex?: number, runIndex?: number): N8nItem[];
|
|
83
|
+
declare const $node: Record<string, N8nNodeRef>;
|
|
84
|
+
|
|
85
|
+
/** Luxon DateTime (subset). */
|
|
86
|
+
declare class DateTime {
|
|
87
|
+
static now(): DateTime;
|
|
88
|
+
static fromISO(text: string, opts?: object): DateTime;
|
|
89
|
+
static fromMillis(ms: number, opts?: object): DateTime;
|
|
90
|
+
static fromSeconds(seconds: number, opts?: object): DateTime;
|
|
91
|
+
static fromJSDate(date: Date, opts?: object): DateTime;
|
|
92
|
+
static fromFormat(text: string, format: string, opts?: object): DateTime;
|
|
93
|
+
year: number;
|
|
94
|
+
month: number;
|
|
95
|
+
day: number;
|
|
96
|
+
hour: number;
|
|
97
|
+
minute: number;
|
|
98
|
+
second: number;
|
|
99
|
+
millisecond: number;
|
|
100
|
+
weekday: number;
|
|
101
|
+
zoneName: string;
|
|
102
|
+
isValid: boolean;
|
|
103
|
+
plus(duration: object | number): DateTime;
|
|
104
|
+
minus(duration: object | number): DateTime;
|
|
105
|
+
startOf(unit: string): DateTime;
|
|
106
|
+
endOf(unit: string): DateTime;
|
|
107
|
+
set(values: object): DateTime;
|
|
108
|
+
setZone(zone: string, opts?: object): DateTime;
|
|
109
|
+
setLocale(locale: string): DateTime;
|
|
110
|
+
diff(other: DateTime, unit?: string | string[], opts?: object): any;
|
|
111
|
+
diffNow(unit?: string | string[], opts?: object): any;
|
|
112
|
+
hasSame(other: DateTime, unit: string): boolean;
|
|
113
|
+
toISO(opts?: object): string | null;
|
|
114
|
+
toISODate(): string | null;
|
|
115
|
+
toISOTime(opts?: object): string | null;
|
|
116
|
+
toFormat(format: string, opts?: object): string;
|
|
117
|
+
toLocaleString(opts?: object): string;
|
|
118
|
+
toMillis(): number;
|
|
119
|
+
toSeconds(): number;
|
|
120
|
+
toJSDate(): Date;
|
|
121
|
+
toUTC(offset?: number, opts?: object): DateTime;
|
|
122
|
+
valueOf(): number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Luxon Duration (subset). */
|
|
126
|
+
declare class Duration {
|
|
127
|
+
static fromObject(values: object, opts?: object): Duration;
|
|
128
|
+
static fromMillis(ms: number, opts?: object): Duration;
|
|
129
|
+
static fromISO(text: string, opts?: object): Duration;
|
|
130
|
+
years: number;
|
|
131
|
+
quarters: number;
|
|
132
|
+
months: number;
|
|
133
|
+
weeks: number;
|
|
134
|
+
days: number;
|
|
135
|
+
hours: number;
|
|
136
|
+
minutes: number;
|
|
137
|
+
seconds: number;
|
|
138
|
+
milliseconds: number;
|
|
139
|
+
isValid: boolean;
|
|
140
|
+
as(unit: string): number;
|
|
141
|
+
get(unit: string): number;
|
|
142
|
+
plus(duration: Duration | object | number): Duration;
|
|
143
|
+
minus(duration: Duration | object | number): Duration;
|
|
144
|
+
negate(): Duration;
|
|
145
|
+
normalize(): Duration;
|
|
146
|
+
shiftTo(...units: string[]): Duration;
|
|
147
|
+
set(values: object): Duration;
|
|
148
|
+
toObject(): Record<string, number>;
|
|
149
|
+
toISO(opts?: object): string | null;
|
|
150
|
+
toFormat(format: string, opts?: object): string;
|
|
151
|
+
toHuman(opts?: object): string;
|
|
152
|
+
toMillis(): number;
|
|
153
|
+
valueOf(): number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Luxon Interval (subset). */
|
|
157
|
+
declare class Interval {
|
|
158
|
+
static fromDateTimes(start: DateTime | Date, end: DateTime | Date): Interval;
|
|
159
|
+
static after(start: DateTime | Date, duration: Duration | object | number): Interval;
|
|
160
|
+
static before(end: DateTime | Date, duration: Duration | object | number): Interval;
|
|
161
|
+
static fromISO(text: string, opts?: object): Interval;
|
|
162
|
+
start: DateTime | null;
|
|
163
|
+
end: DateTime | null;
|
|
164
|
+
isValid: boolean;
|
|
165
|
+
length(unit?: string): number;
|
|
166
|
+
count(unit?: string): number;
|
|
167
|
+
contains(dateTime: DateTime): boolean;
|
|
168
|
+
overlaps(other: Interval): boolean;
|
|
169
|
+
engulfs(other: Interval): boolean;
|
|
170
|
+
isBefore(dateTime: DateTime): boolean;
|
|
171
|
+
isAfter(dateTime: DateTime): boolean;
|
|
172
|
+
set(values: object): Interval;
|
|
173
|
+
splitAt(...dateTimes: DateTime[]): Interval[];
|
|
174
|
+
splitBy(duration: Duration | object | number): Interval[];
|
|
175
|
+
toDuration(unit?: string | string[], opts?: object): Duration;
|
|
176
|
+
toISO(opts?: object): string;
|
|
177
|
+
toFormat(format: string, opts?: object): string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
declare const $now: DateTime;
|
|
181
|
+
declare const $today: DateTime;
|
|
182
|
+
|
|
183
|
+
/** Debug logging — surfaces in the n8n execution view. */
|
|
184
|
+
declare const console: {
|
|
185
|
+
log(...args: any[]): void;
|
|
186
|
+
error(...args: any[]): void;
|
|
187
|
+
warn(...args: any[]): void;
|
|
188
|
+
info(...args: any[]): void;
|
|
189
|
+
debug(...args: any[]): void;
|
|
190
|
+
};
|