n8n-decanter 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +369 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/dist/lib/api.mjs +79 -0
  5. package/dist/lib/compile.mjs +220 -0
  6. package/dist/lib/config.mjs +61 -0
  7. package/dist/lib/diff.mjs +74 -0
  8. package/dist/lib/executions.mjs +76 -0
  9. package/dist/lib/git.mjs +44 -0
  10. package/dist/lib/init.mjs +152 -0
  11. package/dist/lib/picker.mjs +226 -0
  12. package/dist/lib/prompt.mjs +34 -0
  13. package/dist/lib/proxy.mjs +204 -0
  14. package/dist/lib/pull.mjs +148 -0
  15. package/dist/lib/push.mjs +136 -0
  16. package/dist/lib/rename.mjs +156 -0
  17. package/dist/lib/run.mjs +208 -0
  18. package/dist/lib/state.mjs +156 -0
  19. package/dist/lib/status.mjs +102 -0
  20. package/dist/lib/style.mjs +40 -0
  21. package/dist/lib/types.mjs +2 -0
  22. package/dist/lib/util.mjs +201 -0
  23. package/dist/lib/validate.mjs +225 -0
  24. package/dist/lib/watch.mjs +296 -0
  25. package/dist/n8n-decanter.mjs +482 -0
  26. package/dist/scripts/typecheck.mjs +90 -0
  27. package/package.json +51 -0
  28. package/template/.claude/hooks/verify.mjs.example +48 -0
  29. package/template/.claude/settings.local.json.example +35 -0
  30. package/template/.cursor/rules/n8n-decanter.mdc.example +21 -0
  31. package/template/.env.example +10 -0
  32. package/template/.mcp.json.example +16 -0
  33. package/template/.vscode/settings.json.example +6 -0
  34. package/template/AGENTS.md.example +302 -0
  35. package/template/CLAUDE.md.example +20 -0
  36. package/template/decanter-ts-plugin/index.js.example +58 -0
  37. package/template/decanter-ts-plugin/package.json.example +7 -0
  38. package/template/n8n-globals.d.ts.example +190 -0
  39. package/template/opencode.json.example +19 -0
  40. package/template/package.json.example +15 -0
  41. package/template/shared/.gitkeep +0 -0
  42. package/template/shared/example-helpers.ts +13 -0
  43. package/template/tsconfig.json.example +26 -0
  44. package/template/workflows/.gitkeep +0 -0
@@ -0,0 +1,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,7 @@
1
+ {
2
+ "name": "decanter-ts-plugin",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "description": "tsserver plugin: suppress false TS1108/TS1375/TS1378 on n8n Code node files",
6
+ "main": "index.js"
7
+ }
@@ -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
+ };