n8n-decanter 0.8.0 → 0.10.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 CHANGED
@@ -7,6 +7,270 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-08-07
11
+
12
+ ### Added
13
+
14
+ - **`scenario create <workflow> "<slug>" --extend`** — top an **existing**
15
+ scenario up with the pinnable nodes it is missing, keeping every value already
16
+ authored. Previously `scenario create` refused an existing file outright, so a
17
+ scenario `test` rejected could only be fixed by hand-editing raw JSON for nodes
18
+ the tool had never named. Also covers the ordinary case of a workflow that
19
+ gained a node after its scenario was written.
20
+
21
+ - **`scenario create --scaffold` now works with no instance.** The fill entries
22
+ were always built from your local `workflow.json`; the instance only supplied
23
+ the per-node output **JSON Schemas**, which annotate the fill rather than
24
+ enable it. With no `N8N_HOST` configured it now says the annotations are
25
+ missing and scaffolds anyway — each node lands as provenance `authored`
26
+ instead of `scaffolded`, so the difference stays visible in the file. This is
27
+ what makes `preflight --offline --simulate` reachable on a plane: of the four
28
+ pin sources, only *fetching a fresh capture* actually needs n8n. The messages
29
+ that route you to a pin source now lead with the offline-viable ones, and
30
+ `docs/cli/preflight.md` states which of the four need the instance.
31
+
32
+ ### Changed
33
+
34
+ - **Breaking:** **a slug-less `scenario create --scaffold` now writes
35
+ `scenarios/scaffold.json`, not `scenarios/scenario.json`.** The old default collided with the `scenario`
36
+ verb, and the flag parser refuses to read a verb name as a flag value — so
37
+ `preflight --simulate --scenario scenario` failed with `--scenario needs a
38
+ value`, leaving the default file referenceable only as `--scenario=scenario`.
39
+ If you have a script that names the old default file, point it at the new one
40
+ (an explicit `<slug>` argument was, and stays, unaffected).
41
+
42
+ - **`scenario check` now reports the `test` gate too, not just the
43
+ `preflight --simulate` one.** The two demand different node sets on purpose —
44
+ `--simulate` asks only for nodes the capture **reached**, `test` asks for
45
+ **every** enabled non-pure node because it runs on the live instance with real
46
+ credentials. Reporting only the looser one meant a scenario could be green and
47
+ still be refused by `test`. `check` now says which gate you have passed, and
48
+ names `--extend` as the way to close the difference. The docs that asserted the
49
+ two rules were the same are corrected.
50
+
51
+ - **`scenario create --execution` pins unreached nodes to an empty run instead of
52
+ asking you to invent output for them.** A pinnable node the capture never
53
+ reached is written as `[{"data":{"main":[[]]}}]` — "this branch isn't
54
+ exercised" — and listed under `_decanterScenario.notExercised`. That makes a
55
+ capture-seeded scenario usable with `test` straight away, keeps the node pinned
56
+ to zero items (so it can never touch the real world), and leaves the claim
57
+ visible for review: if a branch *should* have run, give it real data.
58
+
59
+ - **The cold-start errors now name the non-interactive `init`, and `--mcp-token`
60
+ is an accepted alias for `--token`.** A fresh clone has no `.env` (it is
61
+ gitignored), so `N8N_HOST must be set …` is the first thing you — or a coding
62
+ agent — read. It said what was wrong and pointed only at a path that needs
63
+ someone at a prompt; a blind session diagnosed the problem in one command and
64
+ then had to hand the job back to a human. Both messages now spell out
65
+ `n8n-decanter init . --host <host-url> --token <mcp-token>`, `init --host` on
66
+ its own names `--token` when it warns about missing credentials, and the token
67
+ flag accepts either spelling.
68
+
69
+ ### Fixed
70
+
71
+ - **The "install typescript" advice now pins `@^5`.** A bare
72
+ `npm i -D typescript` installs **7.x**, whose compiler is the native rewrite
73
+ and no longer exposes the programmatic API decanter's node-file typecheck
74
+ drives — so following the old advice replaced a *skipped* check with a broken
75
+ one. The skip message and `preflight`'s unlock now both say
76
+ `npm i -D typescript@^5` and name the reason. `init`'s scaffold already pinned
77
+ `^5`; this only ever bit projects `init` deliberately left alone.
78
+
79
+ - **A 403 from the public API now names the scope you are missing.** n8n answers
80
+ a valid-but-under-scoped `N8N_API_KEY` with a bare 403 and says nothing about
81
+ which of eight scopes is absent. Every REST surface — executions, data tables,
82
+ backup — now gets a per-endpoint hint, including the trap that catches people
83
+ out: `dataTable:read` does **not** cover `/columns` or `/rows`, which need
84
+ `dataTableColumn:read` and `dataTableRow:read`. The data-table hints also say
85
+ outright that decanter only ever **reads** them, so no write scope is needed.
86
+
87
+ - **`scenario create` prints the file's size, and warns before it lands in git.**
88
+ A capture-seeded scenario is a verbatim copy of every item of every node; one
89
+ from a busy production run can be tens of megabytes. Nothing measured it, and
90
+ `scenarios/` is tracked — so the folder-wide auto-commit on the next `pull` or
91
+ `push` swept it into history unasked. The success line now carries the size,
92
+ and anything above 1 MB warns explicitly that it is about to be committed.
93
+
94
+ - **Three scenario messages pointed somewhere the thing you needed was not.**
95
+ A pre-rename scenario (`_decanterMock`) was told to look in
96
+ `_decanterScenario.fill` — a key not in the file; the message now names the key
97
+ it actually found. A replay gap derives its node list from the **workflow
98
+ graph**, so those nodes are by definition *not* in `fill`, yet it said "see the
99
+ `_decanterScenario` block"; it now says they are **not** listed, and points at
100
+ `--extend`. And a node deliberately written as `"Node": []` was reported as
101
+ unfilled — it is now told apart from a node with no entry at all, with the
102
+ spelling for "emits nothing".
103
+
104
+ - **The agent guard's 401 no longer reads as "this project was never set up".**
105
+ It led with *"run `n8n-decanter init`"*, and a blind field-test round watched an
106
+ agent conclude from it that there was no `.env` and no token at all — then send
107
+ its user through a pointless `init`. The `.env` existed; the token had simply
108
+ been rotated. The guard now leads with the cause (*"n8n rejected decanter's
109
+ existing MCP credentials … they are configured but no longer valid"*), matching
110
+ what the CLI already said, and offers `init` only as the OAuth alternative. It
111
+ also maps **403** now, pointing at n8n → Settings → MCP.
112
+
113
+ - **A missing `typescript` is reported as a skipped check, not a failed one.**
114
+ `preflight`'s node-file typecheck needs `typescript` in your project. A
115
+ globally installed decanter ships none (it is a devDependency), and `init`
116
+ leaves an existing `package.json` alone — so scaffolding into a project you
117
+ already had produced a module-resolution stack trace surfacing as a *typecheck
118
+ failure*, which reads like a type error in your own code. It is now an honest
119
+ skip, named in the coverage block with the one-command fix
120
+ (`npm i -D typescript`).
121
+
122
+ - **The scaffolded `AGENTS.md` tells agents that `.env` is unreadable by policy
123
+ and that this is not evidence it is missing** — the reasoning trap behind the
124
+ 401 finding above.
125
+
126
+ - **`node run` no longer answers a branch index with the wrong branch's data.**
127
+ `$('Node').all(1)`, `$items('Node', 1)` and `$input.all(1)` ask for a node's
128
+ *second* output — an `IF`'s false branch, a `Switch`'s other case. A fixture
129
+ pins **one** items array per node, so there is no honest answer, but the
130
+ argument was accepted and **ignored**: you got output 0's items and the node
131
+ looked like it worked, graded against a shape it will never see live. Worse
132
+ than empty data, because nothing fails. These calls now refuse with a message
133
+ naming the call and the two ways forward (pin that branch as its own fixture
134
+ node, or run it for real with `test`) — the same signpost pattern `$vars` and
135
+ `$secrets` already use. `n8n-globals.d.ts` declared the parameter and
136
+ `docs/cli/node-run.md` listed the calls as fully covered, so both surfaces had
137
+ promised something the emulation never did.
138
+
139
+ - **`pull` no longer destroys uncommitted local edits.** It committed the folder
140
+ *after* overwriting it, so an uncommitted `.js` edit was gone and had never
141
+ entered git — while the warning printed on that exact path told you to
142
+ "recover via git". Pull now takes a **snapshot commit before it writes
143
+ anything** (`watch` and the live mirror already did). If the snapshot cannot be
144
+ made — no git repo, `commitOnPull: false`, a git error — the pull still runs,
145
+ but the warning says the overwrite is **not** recoverable instead of promising
146
+ a safety net that isn't there.
147
+
148
+ - **`pull`'s clobber warning now fires for a node it has never synced.** It was
149
+ gated on the node already having a sync baseline, which is backwards on the
150
+ read side: no baseline means the node isn't in `.decanter.json` yet, so the
151
+ local file is precisely the one with no protection. The loss path this opened
152
+ matches the scaffolded agent workflow exactly — an agent adds a Code node over
153
+ the guard (the guard blocks `jsCode`, so the remote body is empty), writes the
154
+ source into `code/<node>.js`, and a background mirror pull lands before the
155
+ first push: fresh file replaced by the empty remote body, silently.
156
+
157
+ - **The live mirror stops refreshing when its safety commit fails.** It awaited
158
+ the commit and discarded the result, but that call returns a failure (it never
159
+ throws) for any git error — unset identity, a mid-merge tree, `index.lock`, a
160
+ rejecting hook. The documented "a dirty tree is safety-committed before the
161
+ pull" rail therefore degraded silently into an unrecoverable overwrite. It now
162
+ skips the refresh and says why, matching `watch`.
163
+
164
+ - **Scenario gaps are now judged per branch, not per node.** A branching node
165
+ (an `IF`, a `Switch`) emits on **one** output per run, but `preflight
166
+ --simulate` and `scenario create --execution` read its *first* output for every
167
+ outgoing edge. Two consequences, both wrong: a node on the branch the capture
168
+ **never took** was reported as a gap you had to write pin data for, and a
169
+ genuine gap's `inputSample` was filled from the **other** branch's items — so
170
+ you coded against the wrong upstream shape. Untaken branches are exempt and
171
+ neutralized, as documented.
172
+
173
+ - **An n8n whose MCP access is switched off now says so.** Turning MCP off is a
174
+ one-click state in n8n (Settings → MCP) and it makes the server answer a valid
175
+ token with `403 MCP access is disabled` — it never 404s. decanter recognised
176
+ 401 and 404 but not 403, so every command failed with a bare
177
+ `403 Forbidden` and no next step. It now surfaces n8n's own reason plus the
178
+ fix. The 404 message was reworded to match what 404 actually means (no MCP
179
+ server at that address — wrong `N8N_HOST`, or an n8n too old to have one).
180
+
181
+ Note the ordering trap, now documented in the troubleshooting FAQ: a missing
182
+ or stale token still returns 401 while MCP is off, so the 401 can hide the 403.
183
+
184
+ ## [0.9.0] - 2026-08-04
185
+
186
+ ### Changed
187
+
188
+ - **The agent guard now refuses a `publish_workflow` that would take a broken
189
+ draft live.** `publish` already checked, but the raw MCP tool went straight
190
+ through the guard — so an agent could go live around the verb and ship exactly
191
+ the breakage the check exists to catch. Both transports (`mcp connect` and
192
+ `mcp serve`) run the same check on the same shared code.
193
+
194
+ **Fail-closed:** if the check itself cannot run — n8n unreachable — the publish
195
+ is refused too, and the message says the *check* failed rather than claiming
196
+ the workflow is broken. A read that fails almost certainly means the publish
197
+ would have failed anyway, and "couldn't verify, so we shipped it" is not a gate.
198
+
199
+ - **Dangling-reference checks now cover all four forms n8n rewrites on a rename**
200
+ — `` $('X') `` (as before) plus `$node["X"]`, `$node.X` and `$items('X')`.
201
+ Previously only the first was detected, so a rename could strand a `$node[…]`
202
+ call site that nothing reported: `preflight`, `push`, `test` and `publish` all
203
+ passed it, and it failed at run time instead. The rule is n8n's own — its
204
+ rewriter handles exactly these four — so if n8n treats it as a reference, the
205
+ guard now does too.
206
+
207
+ **This can surface errors in workflows that passed before.** A `$node["Old"]`
208
+ reference to a node that no longer exists is a hard compliance error, which
209
+ `--force` does not bypass. The message quotes the reference **as written**, so
210
+ it is clear which form triggered it. Computed references (`$(someVar)`, a
211
+ template literal with `${…}`) are still left alone — a regex cannot resolve
212
+ them, and n8n has the same limit.
213
+
214
+ - **Breaking: `n8n-decanter test <workflow>` no longer executes.** It used to
215
+ fall back to the newest capture under `executions/` and run the workflow for
216
+ real on your instance — a directory that is gitignored, so the same commit
217
+ behaved differently for different people, and a bare verb had real side
218
+ effects. Bare `test` is now a **static tier**: it reads the instance's draft,
219
+ reports dangling `$('…')` references, and runs nothing. Pass
220
+ `--execution <id>` or `--scenario <slug>` for the pinned run, which is
221
+ otherwise unchanged. There is no deprecation shim — a `test` that still
222
+ executed *sometimes* would keep exactly the ambiguity this removes.
223
+
224
+ The pinned run now also does the static check first, so a draft already known
225
+ to be broken is never fired at the instance.
226
+ - **`publish` refuses a draft carrying a dangling `$('…')` reference.**
227
+ Previously nothing checked: the compliance guard runs on `push`, `preflight`
228
+ and `backup` — not `publish` — so a task that only renamed nodes never hit a
229
+ gate and the break went live. The check reads **the draft on the instance**
230
+ (the read `publish` already makes), not your local folder: `workflow.json` is
231
+ a snapshot, so grading it would pass a broken workflow on a stale mirror and
232
+ block a legitimate publish from a fresh clone.
233
+
234
+ ### Added
235
+
236
+ - **`init` now scaffolds a hook that catches stranded `$('…')` references right
237
+ after a rename.** n8n's `renameNode` MCP op rewrites the node name and the
238
+ connections only, so the references it leaves behind used to surface at the
239
+ next `push` — arbitrarily far from the rename that caused them. On Claude Code
240
+ a PostToolUse hook on `update_workflow` now reports them immediately, split
241
+ into the two halves and in the order they must be repaired: other nodes'
242
+ expression parameters in n8n first, then the code files here, then `push`.
243
+
244
+ It scans for the old name instead of running `preflight`, deliberately: the
245
+ hook fires before the background snapshot refresh, and until that lands the
246
+ snapshot still carries the old name, so every reference still resolves and
247
+ `preflight` would report clean. Silent when nothing references the renamed
248
+ node. The checklist in the scaffolded `AGENTS.md` remains the contract for
249
+ every agent — the hook is a reminder, not a replacement.
250
+
251
+ ### Fixed
252
+
253
+ - **Corrected the rename guidance: n8n's `renameNode` MCP op does NOT rewrite
254
+ `$('…')` references.** The scaffolded agent guide (and the 0.6.0 notes below)
255
+ claimed n8n rewrites connections *and* `$('…')` references server-side on a
256
+ rename. Verified against real n8n 2.30.7 and 2.33.3: the MCP op rewrites the
257
+ node name and the **connections only**, then reports success with
258
+ `validationWarnings: []` — every `$('Old Name')` ref is left dangling, both in
259
+ Code-node source and in other nodes' expression parameters. The n8n *editor*
260
+ does rewrite them, but in the browser before it saves, so "server-side" was
261
+ wrong for that path too. No amount of pulling repairs this; `pull` faithfully
262
+ mirrors what n8n stored.
263
+
264
+ `init`'s `AGENTS.md`/`CLAUDE.md` now describe the real contract, including the
265
+ repair order that matters: **fix other nodes' expression parameters over MCP
266
+ first, then local code, then `push`.** The other order loses the code fix,
267
+ because a forwarded MCP write schedules a background snapshot refresh whose
268
+ pull overwrites unpushed `.js` edits.
269
+ - **A dangling-reference error now says which half it is and where to fix it.**
270
+ The two compliance errors were near-identical and neither mentioned a rename,
271
+ which led to `workflow.json` being hand-edited — turning the check green while
272
+ n8n stayed broken.
273
+
10
274
  ## [0.8.0] - 2026-07-27
11
275
 
12
276
  ### Added
package/README.md CHANGED
@@ -30,46 +30,30 @@ clean git diffs.
30
30
 
31
31
  ![Terminal demo — the interactive picker: filter workflows, choose a verb, sync](./docs/terminal-demo.gif)
32
32
 
33
- - **Real version control** — meaningful diffs, PRs, blame; every push and
34
- pull is auto-committed.
33
+ - **Real version control** — meaningful diffs, PRs and blame per Code node;
34
+ every push and pull is auto-committed.
35
35
  - **TypeScript or typed JS** — write nodes in TS (compiled on push) or plain
36
36
  JS; n8n globals (`$input`, `$('…')`, …) are typed in both.
37
+ - **Shared code and small libraries** — `.ts` nodes import from `shared/` and
38
+ opted-in npm packages, bundled on push to run anywhere, n8n Cloud included.
37
39
  - **Agent-native** — `init` scaffolds Claude Code / Cursor / Codex configs;
38
- the offline `preflight --offline` / `node run` loop gives agents a
39
- credential-free feedback loop.
40
- - **One gate, two depth flags** — a compliance guard, typecheck gate, and
41
- per-node drift guard gate every push; `preflight` folds layout, types and
42
- instance sync facts into a single **read-only, CI-gateable verdict** (never
43
- writes, never runs on your instance). `--simulate` *adds* an offline engine
44
- replay (Docker), `--offline` *drops* the instance reads — and both that
45
- replay and instance-side `test` diff every node against a real captured
46
- execution and exit 1 on divergence. The flow is **`preflight` → `push` →
47
- `test` → `publish`**: verify local, make it the draft, run what you pushed,
48
- go live.
49
- - **Committed, schema-scaffolded scenarios** — `scenario create` turns a
50
- captured execution and/or the workflow's own output schemas
51
- (`--scaffold`, no LLM API) into a reviewable, git-tracked pin-data set that
52
- `test` and `preflight --simulate` replay — the durable counterpart to an
53
- agent's ephemeral
54
- in-session pin flow.
40
+ `preflight --offline` and `node run` give agents a credential-free loop.
41
+ - **Guarded agent access to n8n's MCP, wired by default** — the scaffolded
42
+ `.mcp.json` forwards n8n's full MCP surface except Code-node `jsCode` writes,
43
+ and the read-only `workflow.json` snapshot refreshes itself afterwards.
44
+ - **One gate before you push** — `preflight` folds layout, types and instance
45
+ sync facts into a single read-only, CI-gateable verdict; `--simulate` adds an
46
+ offline engine replay, `--offline` drops the instance reads.
47
+ - **Draft-first by construction** — every push lands on the **draft**;
48
+ `publish` is the deliberate go-live. Flow: **preflight → push → test → publish**.
49
+ - **Committed, schema-scaffolded scenarios** — `scenario create` turns a captured
50
+ execution and/or the workflow's own output schemas into a git-tracked pin-data
51
+ set that `test` and `preflight --simulate` replay.
55
52
  - **Live editing** — `watch` pushes on save; the open n8n editor tab reflects
56
53
  each push live (n8n's own draft-edit refresh, no proxy needed).
57
- - **Guarded agent access to n8n's MCP — wired by default** — the scaffolded
58
- `.mcp.json` spawns `mcp connect`, forwarding the full n8n MCP surface
59
- except writes to a Code node's `jsCode`; no secret to manage. The read-only
60
- `workflow.json` snapshot then **refreshes itself** after an agent
61
- restructures the workflow through the guard — no manual `pull`
62
- (`liveMirror`, on by default).
63
- - **Git-native disaster recovery** — `backup create` captures a versioned,
64
- redeployable full export into a committed `backups/` store (fidelity MCP
65
- can't give — credential refs kept, code stays a placeholder); `backup
66
- restore` redeploys it as a new workflow with **node ids preserved**, a
67
- second version history that outlives the instance.
68
- - **Shared code and small libraries** — `.ts` nodes import helpers/types from
69
- `shared/` and opted-in npm packages, bundled on push into self-contained
70
- nodes that run anywhere, n8n Cloud included.
71
- - **Draft-first by construction** — every push lands on the workflow's
72
- **draft**; `publish` (or `push --publish`) is the deliberate go-live step.
54
+ - **Git-native disaster recovery** — `backup create` captures a redeployable
55
+ full export into a committed `backups/` store; `restore` redeploys it as a new
56
+ workflow with **node ids preserved**.
73
57
 
74
58
  ![Agent demo — a coding agent edits a Code node, verifies it offline, then pushes to the draft](./docs/agent-demo.gif)
75
59
 
@@ -133,7 +117,7 @@ Full flag reference: `n8n-decanter --help`, or the
133
117
  | `diff [workflow…]` | Per-node line diff of local code vs the n8n draft — an inspection view, always exits 0 |
134
118
  | `executions [workflow…]` | Fetch recent execution data (read-only) |
135
119
  | `data-tables [table…]` | Fetch data-table schema + rows (read-only) |
136
- | `test <workflow>` | Pinned run **on the instance** (draft); diffs vs a capture |
120
+ | `test <workflow>` | Grade the **instance's draft**: static check bare, pinned run with `--execution`/`--scenario` |
137
121
  | `preflight [workflow…]` | The one local gate — layout, types, instance sync facts, optional local-engine replay — scored and read-only; run before `push` (exits 1 on *not ready*) |
138
122
  | `scenario create` / `scenario check` | Build / validate a committed scenario (captured and/or schema-scaffolded) |
139
123
  | `backup create` / `restore` / `list` | Git-native disaster recovery — capture / redeploy / list versioned full-export backups |
@@ -149,50 +133,34 @@ tools (through the guard above); the next `pull` reconciles the local mirror.
149
133
 
150
134
  ## How it compares
151
135
 
152
- n8n-decanter is **Code-node-first**: it optimizes the loop of writing, typing,
153
- verifying, and shipping the JavaScript/TypeScript *inside* your workflows. It
154
- builds **on** n8n's own MCP server and skills — they're the foundation it rides
155
- for structure and lifecycle, not a rival. So the comparison below is against the
156
- native editor and against
157
- [n8n-as-code](https://github.com/EtienneLescot/n8n-as-code) — a broader,
158
- whole-workflow authoring toolkit.
159
-
160
- > **Choose [n8n-as-code](https://github.com/EtienneLescot/n8n-as-code) if you…**
161
- > need an enterprise-ready automation framework — where AI agents assemble
162
- > complete workflows, multi-environment Dev → Prod pipelines automatically handle
163
- > credential governance, and full TypeScript GitOps ensures strict auditability
164
- > across your team.
165
- >
166
- > **Choose n8n-decanter if you…** your workflows live or die by their Code nodes
167
- > and you want them as real files — TypeScript with typed n8n globals, shared libraries,
168
- > preflights (offline or instance-side), and code-level git history, synced
169
- > draft-first between your IDE, your coding agent, and n8n (even on Cloud).
136
+ n8n-decanter is **Code-node-first**: it optimizes writing, typing, verifying and
137
+ shipping the JavaScript/TypeScript *inside* your workflows, and builds **on**
138
+ n8n's own MCP server and skills rather than rivalling them. Compared against the
139
+ native editor and [n8n-as-code](https://github.com/EtienneLescot/n8n-as-code),
140
+ a broader whole-workflow authoring toolkit:
170
141
 
171
142
  | Capability | Native n8n (browser) | n8n-as-code | n8n-decanter |
172
143
  |---|---|---|---|
173
- | **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 |
174
- | **Shared types & helpers 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) |
175
- | **Code as individual files** | ❌ no source files (JSON blob) | 🟡 one `.workflow.ts` per workflow | ✅ folder per workflow; each Code node its own `.js`/`.ts` |
176
- | **Code-level git versioning** | 🟡 in-app history (DB snapshots, tiered retention); Git source control is Enterprise-only | ✅ GitOps sync of workflow source | ✅ real git — diffs, PRs, blame per Code node; auto-commit each sync (+ read-only structure snapshot) |
177
- | **Preflights** (`preflight`, then `test`) | 🟡 re-run past executions / pin data, but online in-editor | 🟡 inspect executions against a live env | ✅ `preflight` scores your **local** code (layout, types, instance reads, optional offline `--simulate` engine replay) into one read-only, CI-gateable verdict *before* the push — then instance-side `test` runs the pushed draft; each runtime check diffs every node vs a real capture, exits 1 on divergence |
178
- | **Draft-first code sync** | ✅ editor *Save* vs *Publish* (manual, in-browser) | 🟡 API sync republishes on push (no draft-only) | ✅ pushes land on the **draft**; `publish` is the deliberate go-live (over MCP) |
179
- | **Live editing** | ✅ the canvas (baseline) | 🟡 explicit pull/push, no auto-watch | ✅ `watch`: push on save; the open editor tab reflects each push live (n8n-native) |
180
- | **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 incl. a pre-wired `mcp connect` guard holding the credentials; offline `preflight --offline`/`node run` loop |
181
- | **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 |
182
- | **Agentic workflow creation** | 🟡 AI Workflow Builder (natural language), but Cloud / plan-gated — credits, self-host needs setup | ✅ 537 node schemas + 7,700+ templates + skills | ✅ your agent builds structure over n8n's MCP (through the pre-wired `mcp connect` guard); decanter owns the Code-node source (files + `push`) |
183
- | **Whole-workflow authoring** | ❌ | ✅ `.workflow.ts` decorator classes (structure + links) | 🟡 Code-node source only; your agent builds structure live over MCP (row above), mirrored to a read-only `workflow.json` |
184
- | **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) |
144
+ | **TypeScript for Code nodes** | ❌ JavaScript or Python only | ❌ TS at workflow level, not node logic | ✅ `.ts` nodes, compiled on push, typed n8n globals |
145
+ | **Shared types & helpers in Code nodes** | ❌ self-host `NODE_FUNCTION_ALLOW_*` only | ❌ not part of its model | ✅ `shared/*.ts` + npm bundled into self-contained nodes (Cloud-safe) |
146
+ | **Code as individual files** | ❌ no source files (JSON blob) | 🟡 one `.workflow.ts` per workflow | ✅ folder per workflow; each Code node its own file |
147
+ | **Code-level git versioning** | 🟡 in-app history; Git source control is Enterprise-only | ✅ GitOps sync of workflow source | ✅ real git — diffs, PRs, blame per Code node; auto-commit each sync |
148
+ | **Preflights** (`preflight`, then `test`) | 🟡 re-run past executions, but online in-editor | 🟡 inspect executions against a live env | ✅ one read-only, CI-gateable verdict *before* the push; then `test` runs the pushed draft — each diffs every node vs a real capture |
149
+ | **Draft-first code sync** | ✅ editor *Save* vs *Publish* (in-browser) | 🟡 API sync republishes on push | ✅ pushes land on the **draft**; `publish` is the deliberate go-live |
150
+ | **Live editing** | ✅ the canvas (baseline) | 🟡 explicit pull/push, no auto-watch | ✅ `watch`: push on save; the open editor tab reflects it live |
151
+ | **Agent-native tooling** | 🟡 n8n's canvas AI, not your agent on the codebase | ✅ Agent Workbench, skills, MCP, editor plugins | ✅ scaffolds Claude Code / Cursor / Codex + a pre-wired `mcp connect` guard; offline agent loop |
152
+ | **Model ownership** | ❌ locked to n8n's hosted AI | 🟡 Workbench needs an Anthropic key for Claude | ✅ never calls an LLM itself — your agent/subscription does 100% |
153
+ | **Agentic workflow creation** | 🟡 AI Workflow Builder, but Cloud / plan-gated | ✅ 537 node schemas + 7,700+ templates + skills | ✅ your agent builds structure over n8n's MCP; decanter owns the Code-node source |
154
+ | **Whole-workflow authoring** | ❌ | ✅ `.workflow.ts` decorator classes | 🟡 Code-node source only; structure goes over MCP, mirrored read-only |
155
+ | **Multi-environment promotion** | 🟡 Enterprise source control / environments | ✅ `promote` remaps creds + refs Dev→Prod | 🟡 separate sync dir per instance, no `promote` |
185
156
 
186
157
  Legend: ✅ first-class · 🟡 partial or indirect · ❌ not supported.
187
158
 
188
159
  **Bottom line:** reach for n8n-decanter when your workflows live or die by their
189
- Code nodes — TypeScript, shared libraries, preflights, and code-level git
190
- history, shipped draft-first. It rides n8n's own MCP and skills for everything
191
- structural, so n8n-as-code still shines for whole-workflow authoring/generation
192
- and multi-environment ops, and the native editor stays the live visual canvas
193
- everything syncs back to. And decanter makes no LLM calls of its own — you drive
194
- it with the coding agent you already run, so Claude Code on a Claude subscription
195
- needs no extra API tokens.
160
+ Code nodes. n8n-as-code still shines for whole-workflow authoring and
161
+ multi-environment ops, and the native editor stays the visual canvas everything
162
+ syncs back to. decanter makes no LLM calls of its own — you drive it with the
163
+ coding agent you already run.
196
164
 
197
165
  ## Caveats
198
166
 
package/dist/lib/api.mjs CHANGED
@@ -6,6 +6,34 @@
6
6
  * export a redeployable copy). The daily code path and lifecycle (pull/push/
7
7
  * watch/status/publish) stay on MCP (lib/mcp.mts).
8
8
  */
9
+ /**
10
+ * Which n8n API scope a refused request needed (Plan 63 task 6).
11
+ *
12
+ * A 403 says "valid key, missing scope" and n8n names no scope — so the caller
13
+ * is left guessing which of eight to add. The scope names are already pinned in
14
+ * `template/.env.example` and the smoke suite, and Plan 25 verified the trap
15
+ * this exists for: column and row reads have **distinct** scopes that do not
16
+ * fold into `dataTable:read`, so a key that lists tables fine still 403s on
17
+ * `/columns`.
18
+ */
19
+ function scopeHint(method, pathname) {
20
+ const readOnly = " decanter only ever READS data tables — no write scope is needed.";
21
+ if (pathname.includes("/data-tables/") && pathname.includes("/columns"))
22
+ return `Add \`dataTableColumn:read\` (listing tables uses \`dataTable:list\`, which does NOT cover columns).${readOnly}`;
23
+ if (pathname.includes("/data-tables/") && pathname.includes("/rows"))
24
+ return `Add \`dataTableRow:read\` (separate from \`dataTable:read\`).${readOnly}`;
25
+ if (pathname.startsWith("/api/v1/data-tables"))
26
+ return `Add \`dataTable:list\` and \`dataTable:read\`.${readOnly}`;
27
+ if (/^\/api\/v1\/executions\/\d/.test(pathname))
28
+ return "Add `execution:read`.";
29
+ if (pathname.startsWith("/api/v1/executions"))
30
+ return "Add `execution:list` (and `execution:read` to fetch one).";
31
+ if (pathname.startsWith("/api/v1/workflows") && method === "POST")
32
+ return "Add `workflow:create` (`backup restore` redeploys a backup as a new workflow).";
33
+ if (pathname.startsWith("/api/v1/workflows"))
34
+ return "Add `workflow:read` (and `workflow:list` for init's connection check).";
35
+ return "Check the key's scopes in n8n → Settings → n8n API.";
36
+ }
9
37
  export class N8nApi {
10
38
  #host;
11
39
  #apiKey;
@@ -115,6 +143,13 @@ export class N8nApi {
115
143
  });
116
144
  const text = await res.text();
117
145
  if (!res.ok) {
146
+ // A 403 from the public API means the key is valid but under-scoped —
147
+ // and n8n does not say which scope is missing. Mapped HERE rather than
148
+ // in each caller so every REST surface (executions, data-tables, backup)
149
+ // gets it: `pathname` already identifies what was refused.
150
+ if (res.status === 403) {
151
+ throw new Error(`${method} ${pathname} was refused (403) — N8N_API_KEY is valid but lacks a scope. ${scopeHint(method, pathname)}`);
152
+ }
118
153
  throw new Error(`${method} ${pathname} failed: ${res.status} ${res.statusText}\n${text.slice(0, 2000)}`);
119
154
  }
120
155
  return text ? JSON.parse(text) : undefined;
@@ -1,5 +1,16 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
+ /**
4
+ * The one message every cold start hits — a fresh clone has no `.env`, so this
5
+ * is the first thing a user (or their agent) reads. It names the **flag** form
6
+ * on purpose: [Plan 75](../plans/done/75-init-cold-start-discoverability.md)
7
+ * came out of a blind round where the agent diagnosed the missing `.env` in one
8
+ * command and then sent its human to the *interactive* `init`, because nothing
9
+ * here mentioned that `init` takes the values as arguments. Shared with
10
+ * `createMcpClient`, which used to carry its own copy of the same string.
11
+ */
12
+ export const HOST_UNSET = "N8N_HOST must be set (via .env next to decanter.config.json or the environment)\n" +
13
+ " set it without prompts: n8n-decanter init . --host <host-url> --token <mcp-token>";
3
14
  /** Parse KEY=VALUE lines (optional `export`, quotes stripped) from an env file. */
4
15
  export function parseEnvFile(file) {
5
16
  const values = {};
@@ -57,7 +68,7 @@ export function loadConfig(cwd = process.cwd(), { requireHost = true } = {}) {
57
68
  const host = (process.env.N8N_HOST ?? "").replace(/\/+$/, "");
58
69
  const apiKey = process.env.N8N_API_KEY ?? "";
59
70
  if (requireHost && !host) {
60
- throw new Error("N8N_HOST must be set (via .env next to decanter.config.json or the environment)");
71
+ throw new Error(HOST_UNSET);
61
72
  }
62
73
  return {
63
74
  configDir: dir,
package/dist/lib/init.mjs CHANGED
@@ -384,7 +384,9 @@ export async function init(targetDir, { force = false, host: hostFlag, token: to
384
384
  // --token / any setup flag suppresses every prompt (non-interactive mode).
385
385
  const auth = readAuthFileTolerant(dir, log);
386
386
  if (mcpToken !== "") {
387
- log.info(tokenFlag !== undefined ? "using MCP token from --token" : "using existing MCP token from .env (N8N_MCP_TOKEN)");
387
+ // Doesn't name the flag: `--token` and `--mcp-token` both land here, and
388
+ // echoing a spelling the user didn't type reads like a correction.
389
+ log.info(tokenFlag !== undefined ? "using the MCP token given on the command line" : "using existing MCP token from .env (N8N_MCP_TOKEN)");
388
390
  }
389
391
  else if (auth !== null && auth.host === host) {
390
392
  log.info(`using existing MCP OAuth credentials (${AUTH_FILE})`);
@@ -405,7 +407,9 @@ export async function init(targetDir, { force = false, host: hostFlag, token: to
405
407
  mcpToken = await ask("n8n MCP token (n8n → Settings → MCP → API key) [Enter to skip]: ");
406
408
  }
407
409
  if (mcpToken === "" && !(auth !== null && auth.host === host)) {
408
- log.warn("no MCP credentials yet — sync verbs (pull/push/watch/…) will not work until you re-run init or set N8N_MCP_TOKEN");
410
+ // Names the flag, not just "re-run init": this fires on exactly the
411
+ // host-only `init --host …` a blind agent reaches first (Plan 75).
412
+ log.warn("no MCP credentials yet — sync verbs (pull/push/watch/…) will not work until you re-run init with `--token <mcp-token>` (n8n → Settings → MCP → API key) or set N8N_MCP_TOKEN");
409
413
  }
410
414
  // --- optional public API key (the REST-only surfaces)
411
415
  if (apiKey === "" && !flagDriven) {
@@ -1,5 +1,6 @@
1
1
  import { getWorkflowDetails, publishWorkflowMcp, unpublishWorkflowMcp } from "./mcp.mjs";
2
2
  import { publicationState } from "./util.mjs";
3
+ import { danglingNodeRefs, describeDanglingRefs } from "./validate.mjs";
3
4
  /**
4
5
  * `publish` — take the draft live (MCP `publish_workflow`, Plan 32). Since
5
6
  * pushes are draft-only now, this is THE go-live step: on a published
@@ -12,6 +13,21 @@ export async function publishWorkflow(mcp, id, log) {
12
13
  log.info(`"${before.name}" (${id}) is already published — the draft is live`);
13
14
  return;
14
15
  }
16
+ // Go-live gate (Plan 64 task 3b). Scans the draft we just read — the thing
17
+ // about to become live — NOT the repo folder: `workflow.json` is a snapshot,
18
+ // so grading it here would false-green on a stale mirror and false-red on a
19
+ // fresh clone. The scan is shared with `test`'s static tier, and re-running it
20
+ // is the point rather than waste: the instance can change between the two, so
21
+ // only the check inside `publish` is authoritative for this publish. It costs
22
+ // no extra call — the read above already happened.
23
+ const dangling = danglingNodeRefs(before.nodes);
24
+ if (dangling.length > 0) {
25
+ throw new Error([
26
+ `refusing to publish "${before.name}" — ${dangling.length} dangling $('…') reference(s) on the draft would go live:`,
27
+ ...describeDanglingRefs(dangling),
28
+ `then re-check with: n8n-decanter test ${id}`,
29
+ ].join("\n"));
30
+ }
15
31
  await publishWorkflowMcp(mcp, id);
16
32
  log.ok(`published "${before.name}" (${id}) — code is live now`);
17
33
  }
package/dist/lib/mcp.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // (plans/OPEN-32 spike).
7
7
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
8
8
  import path from "node:path";
9
+ import { HOST_UNSET } from "./config.mjs";
9
10
  import { CODE_NODE_TYPE } from "./util.mjs";
10
11
  export const MCP_PATH = "/mcp-server/http";
11
12
  export const AUTH_FILE = ".decanter-auth.json";
@@ -505,8 +506,26 @@ export class McpClient {
505
506
  ? "the MCP token was rejected (401) — mint a fresh one in n8n (Settings → MCP) and update N8N_MCP_TOKEN (the public API key is not a valid MCP token)"
506
507
  : "MCP authorization rejected (401) — re-run: n8n-decanter init");
507
508
  }
509
+ // 403 is what a SWITCHED-OFF MCP server answers a valid token with, verified
510
+ // on n8n 2.30.7 (Plan 74). It never 404s for that — 404 means the endpoint
511
+ // is not there at all. Without this branch the user got a bare
512
+ // "403 Forbidden" for a state they reach by flipping a documented switch.
513
+ // Note a missing/stale token still 401s while MCP is off, so the 401 above
514
+ // can mask this one; the troubleshooting page says so.
515
+ if (res.status === 403) {
516
+ const detail = (() => {
517
+ try {
518
+ const m = JSON.parse(text).message;
519
+ return typeof m === "string" && m !== "" ? ` (${m})` : "";
520
+ }
521
+ catch {
522
+ return "";
523
+ }
524
+ })();
525
+ throw new Error(`n8n refused the MCP request (403)${detail} — turn MCP access on in n8n (Settings → MCP); if it is on, the token's user may lack access`);
526
+ }
508
527
  if (res.status === 404) {
509
- throw new Error(`no MCP endpoint at ${this.#host}${MCP_PATH} (404) — enable MCP access in n8n (Settings → MCP; needs n8n ≥ ~2.20)`);
528
+ throw new Error(`no MCP endpoint at ${this.#host}${MCP_PATH} (404) — this n8n has no MCP server: check N8N_HOST points at the right instance, and that n8n is ≥ ~2.20 (a server that exists but is switched off answers 403, not 404)`);
510
529
  }
511
530
  if (isNotification)
512
531
  return undefined;
@@ -551,11 +570,12 @@ export class McpClient {
551
570
  */
552
571
  export function createMcpClient(config, log) {
553
572
  if (config.host === "") {
554
- throw new Error("N8N_HOST must be set (via .env next to decanter.config.json or the environment)");
573
+ throw new Error(HOST_UNSET);
555
574
  }
556
575
  const auth = resolveMcpAuth(config.configDir, config.host, log);
557
576
  if (auth === null) {
558
- throw new Error("no MCP credentials — run `n8n-decanter init` to connect via OAuth, or set N8N_MCP_TOKEN (n8n → Settings → MCP → API key)");
577
+ throw new Error("no MCP credentials — run `n8n-decanter init . --token <mcp-token>` (n8n → Settings → MCP → API key),\n" +
578
+ " or bare `n8n-decanter init` to connect via OAuth in a browser, or set N8N_MCP_TOKEN");
559
579
  }
560
580
  return new McpClient({ host: config.host, auth, requestTimeoutMs: config.requestTimeoutMs, log });
561
581
  }