n8n-decanter 0.10.0 → 0.10.2
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 +350 -0
- package/README.md +25 -4
- package/dist/lib/backup.mjs +1 -1
- package/dist/lib/compile.mjs +56 -17
- package/dist/lib/config.mjs +131 -3
- package/dist/lib/init.mjs +181 -0
- package/dist/lib/mcp.mjs +1 -1
- package/dist/lib/mcpconnect.mjs +78 -14
- package/dist/lib/mcpserve.mjs +40 -2
- package/dist/lib/mirror.mjs +14 -1
- package/dist/lib/preflight.mjs +24 -2
- package/dist/lib/pull.mjs +12 -2
- package/dist/lib/push.mjs +29 -6
- package/dist/lib/run.mjs +56 -36
- package/dist/lib/simulate.mjs +182 -18
- package/dist/lib/status.mjs +15 -2
- package/dist/lib/testrun.mjs +68 -5
- package/dist/lib/util.mjs +28 -0
- package/dist/lib/validate.mjs +36 -10
- package/dist/n8n-decanter.mjs +44 -12
- package/dist/scripts/typecheck.mjs +38 -4
- package/package.json +2 -2
- package/template/.claude/hooks/mcp-route-check.mjs.example +140 -15
- package/template/.claude/hooks/rename-refs.mjs.example +20 -4
- package/template/.claude/hooks/verify.mjs.example +21 -3
- package/template/AGENTS.md.example +105 -14
- package/template/CLAUDE.md.example +1 -1
- package/template/opencode.json.example +2 -3
- package/template/tsconfig.json.example +10 -8
- package/dist/lib/add.mjs +0 -101
- package/dist/lib/proxy.mjs +0 -204
- package/dist/lib/rename.mjs +0 -96
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,356 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.2] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **"Missing `n8n-instance` tools? Restart" is no longer the only answer we
|
|
15
|
+
give — in a nested sync dir it was a dead end.** Agent wiring loads at
|
|
16
|
+
startup *from the dir the agent was started in*, so tools declared in
|
|
17
|
+
`.mcp.json` can be absent for two reasons: the wiring is new (a restart fixes
|
|
18
|
+
it), or the wiring sits **below** the launch dir, where no restart will ever
|
|
19
|
+
load it. Every surface that taught only the first now teaches both, with the
|
|
20
|
+
discriminator you can apply yourself — *is that `.mcp.json` below the
|
|
21
|
+
directory you started the agent in?* — and the two working routes (start the
|
|
22
|
+
agent in the sync dir, or wire the root with `N8N_DECANTER_DIR` plus a
|
|
23
|
+
root-resolvable command): the scaffolded `AGENTS.md`, `init`'s own output
|
|
24
|
+
(which now prints the nested guidance **instead of** the restart line when it
|
|
25
|
+
scaffolds into a nested dir), [Working with coding
|
|
26
|
+
agents](/docs/agents/overview/), [init](/docs/cli/init/) and a new
|
|
27
|
+
[troubleshooting](/docs/faq/troubleshooting/) entry. The CLI never needed
|
|
28
|
+
that wiring, so `pull`/`push`/`preflight` keep working either way — the docs
|
|
29
|
+
now say so too.
|
|
30
|
+
|
|
31
|
+
- **The oversized-scenario warning now names a remedy you can actually take.**
|
|
32
|
+
Above 1 MB `scenario create` said "Trim it before that" — but there is no
|
|
33
|
+
trim flag, so the advice pointed nowhere. It now says what is true and what
|
|
34
|
+
to do: nothing is committed yet (`scenario create` never commits — only the
|
|
35
|
+
next `pull`/`push` sweeps the folder into history), so you can still cut
|
|
36
|
+
items out of `data.resultData.runData` by hand, or re-create the scenario
|
|
37
|
+
with `--scaffold` instead of `--execution` and author the pins yourself.
|
|
38
|
+
`docs/cli/scenario.md` spells out both, including which parts of a capture
|
|
39
|
+
are unsafe to delete.
|
|
40
|
+
|
|
41
|
+
- **The agent contract now says to orient *before* the first edit, not only
|
|
42
|
+
before the push.** `preflight` was always the read-only report of the
|
|
43
|
+
instance's side (`drift`, `CONFLICT`, a pending `parity`), but every surface
|
|
44
|
+
framed it as the pre-push gate — so an agent edited first and learned about a
|
|
45
|
+
colleague's UI edit afterwards. The scaffolded `AGENTS.md` and both agent doc
|
|
46
|
+
pages now open the loop with it: on drift, `pull` and carry on; on a
|
|
47
|
+
`CONFLICT`, show `diff` and ask before either side is overwritten.
|
|
48
|
+
- **The scaffold stopped advertising a deny rule it no longer has.** The
|
|
49
|
+
Claude Code `settings.json` denies `.decanter.json`, `.env` and
|
|
50
|
+
`push --force` — but the scaffolded `CLAUDE.md` and `opencode.json` still
|
|
51
|
+
claimed `*.remote.js` was blocked too. Those conflict artifacts were removed
|
|
52
|
+
in the MCP pivot; both files now describe the policy that actually ships.
|
|
53
|
+
- **Docs: `mcp connect` / `mcp serve` no longer read as if the guard obtained
|
|
54
|
+
credentials itself.** "decanter's own credentials", "the agent never holds an
|
|
55
|
+
n8n credential" and "no secret to manage" led readers (and agents) to believe
|
|
56
|
+
the guard handles the n8n login. It does not: it only *reads* what `init`
|
|
57
|
+
wrote to `.env` / `.decanter-auth.json`, and can at most refresh an OAuth
|
|
58
|
+
token. The mcp-connect / mcp-serve pages and the template `AGENTS.md` now say
|
|
59
|
+
so outright — obtaining credentials is exclusively `init`'s job, and a
|
|
60
|
+
"no MCP credentials" answer means run `init`, not retry.
|
|
61
|
+
|
|
62
|
+
- **"decanter.config.json not found" now points at `init` — with its flags.**
|
|
63
|
+
The classic half-setup is a hand-written `.env`: an agent that cannot run the
|
|
64
|
+
browser OAuth flow asks its human to paste `N8N_MCP_TOKEN` into a file and
|
|
65
|
+
stops there, leaving no config, template, `.gitignore` or agent wiring behind.
|
|
66
|
+
The error now says the dir is not a sync dir yet, that `.env` alone is not
|
|
67
|
+
enough, and prints the prompt-free command that fixes it
|
|
68
|
+
(`n8n-decanter init . --host <host-url> --token <mcp-token>`). The docs
|
|
69
|
+
(init, configuration, troubleshooting, README) say the same thing: headless is
|
|
70
|
+
not a reason to skip `init` — it takes the same token as a flag.
|
|
71
|
+
|
|
72
|
+
- **`init`'s restart reminder now covers everything it wires, not just
|
|
73
|
+
permission rules.** MCP servers (`.mcp.json` / `opencode.json` — including the
|
|
74
|
+
guarded `n8n-instance` server), permission rules and hooks are all read at
|
|
75
|
+
agent **startup**, and `init` normally runs inside the session it configures.
|
|
76
|
+
The reminder now fires when any of those files is newly scaffolded and says
|
|
77
|
+
what it means: this session is still unconfigured, restart the agent (or
|
|
78
|
+
`/reload`). README, init and the agents docs say the same, and the scaffolded
|
|
79
|
+
`AGENTS.md` tells the agent to **ask for a restart** when the `n8n-instance`
|
|
80
|
+
tools are missing instead of connecting to the instance directly.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- **The scaffolded hooks now work when your sync dir is not where the agent was
|
|
85
|
+
started.** All three found the sync dir by assuming it was the current
|
|
86
|
+
directory, which only holds for an agent launched inside it. With the sync dir
|
|
87
|
+
nested in a bigger repo — a layout the docs explicitly allow — the agent runs
|
|
88
|
+
at the repo root and every one of them misbehaved: the rename-reference guard
|
|
89
|
+
became a **silent no-op**, so `$('Old Name')` references left behind by a
|
|
90
|
+
`renameNode` went unreported until a later `push` refused them; the verify hook
|
|
91
|
+
spawned the CLI without a directory, so it **blocked every node-file edit** with
|
|
92
|
+
a "not a sync dir" error; and the MCP routing check scanned the wrong tree.
|
|
93
|
+
Each hook now locates the sync dir from its own installed path, so it behaves
|
|
94
|
+
the same wherever the agent starts.
|
|
95
|
+
- **The routing check no longer misses direct-route servers in your user
|
|
96
|
+
config.** Its lookup for this project's entry in `~/.claude.json` matched the
|
|
97
|
+
current directory, but that file is keyed by the repository root — so in any
|
|
98
|
+
sync dir inside a git repo the check silently found nothing. It now matches the
|
|
99
|
+
project entry for the sync dir or any parent of it.
|
|
100
|
+
- **The verify hook finds a locally installed CLI.** It only ever looked for
|
|
101
|
+
`n8n-decanter` on `PATH`, so with a local (non-global) install it stayed quiet
|
|
102
|
+
and no verification ran at all. It now prefers the sync dir's
|
|
103
|
+
`node_modules/.bin` and falls back to `PATH`.
|
|
104
|
+
- **"decanter.config.json not found" no longer sends you to `init` when the sync
|
|
105
|
+
dir is simply somewhere else.** Run from *above* a perfectly good sync dir —
|
|
106
|
+
what happens whenever an agent starts at the repo root — the error read as if
|
|
107
|
+
nothing had ever been set up, and advised scaffolding a second sync dir on top
|
|
108
|
+
of the working one. It now looks below the directory it searched from, names
|
|
109
|
+
the sync dir it finds there, and prints the `--dir` / `N8N_DECANTER_DIR` form
|
|
110
|
+
that reaches it. When there really is no sync dir, the `init` advice is
|
|
111
|
+
unchanged.
|
|
112
|
+
|
|
113
|
+
### Added
|
|
114
|
+
|
|
115
|
+
- **The routing check also looks at parent directories, up to your repository
|
|
116
|
+
root.** Agents merge `.mcp.json` from every directory above the one they start
|
|
117
|
+
in, so a server pointing straight at your n8n instance can sit in the repo
|
|
118
|
+
root's config and still route this session. The scan stops at the repository
|
|
119
|
+
boundary, so it never reaches into unrelated parent directories, and an
|
|
120
|
+
offender found above the sync dir is named by its relative path
|
|
121
|
+
(`../.mcp.json`) so you can tell which file it means.
|
|
122
|
+
|
|
123
|
+
- **`--dir <path>` (or `N8N_DECANTER_DIR`) points any verb at a sync dir that is
|
|
124
|
+
not the current directory.** The layout the docs allow but nothing supported:
|
|
125
|
+
the sync dir nested inside a bigger repo, with the agent started at the repo
|
|
126
|
+
root. The MCP entry `init` writes into the sync dir is invisible from up
|
|
127
|
+
there, and hoisting it to the repo root spawned the guard where no
|
|
128
|
+
`decanter.config.json` could be found — so the guard did not work at all. The
|
|
129
|
+
search still only walks *up*; this says where it starts. In an agent's server
|
|
130
|
+
entry the environment variable is the form to reach for
|
|
131
|
+
(`"env": { "N8N_DECANTER_DIR": "flows" }`), and relative values resolve
|
|
132
|
+
against the working directory, so a repo-relative one keeps working for
|
|
133
|
+
everyone who clones. `init` does not take `--dir` — it still takes the
|
|
134
|
+
directory to scaffold as an argument.
|
|
135
|
+
|
|
136
|
+
- **`init` now tells you how to wire an agent when your sync dir is nested in a
|
|
137
|
+
bigger project.** Agents look for `.mcp.json`, `opencode.json` and
|
|
138
|
+
`.claude/settings.json` from the directory they were *started* in and never in
|
|
139
|
+
one below it, so everything `init` scaffolds is inert for an agent started at
|
|
140
|
+
the repo root above the sync dir. When init sees a project around it (a `.git` or
|
|
141
|
+
`package.json` in a parent) it prints both shapes that work: starting the
|
|
142
|
+
agent inside the sync dir — recommended, nothing further to configure — or the
|
|
143
|
+
paste-ready MCP, opencode and hooks/permissions blocks for the project root,
|
|
144
|
+
with every path and glob already prefixed. That prefixing is the point: copied
|
|
145
|
+
up verbatim, `Read(.env)` / `Edit(.env)` guard the *root's* `.env` and quietly
|
|
146
|
+
stop protecting your credentials. `init` prints this; it never writes into a
|
|
147
|
+
parent directory. Only on the run that first scaffolds the agent files, and
|
|
148
|
+
never for a standalone sync dir.
|
|
149
|
+
|
|
150
|
+
## [0.10.1] - 2026-08-16
|
|
151
|
+
|
|
152
|
+
### Added
|
|
153
|
+
|
|
154
|
+
- **The live mirror now tells the agent when it overwrote your work.** It runs a
|
|
155
|
+
full `pull` after a structure edit, so it can replace an unpushed local code
|
|
156
|
+
edit with what is on the instance. It always warned about that — on stderr,
|
|
157
|
+
which is the one stream an MCP agent structurally cannot read, so the party
|
|
158
|
+
able to react never heard it. The warning now rides the **result of the
|
|
159
|
+
agent's next tool call**, naming the files and how to recover them from the
|
|
160
|
+
safety commit. Delivered once, never repeated.
|
|
161
|
+
|
|
162
|
+
Only on `mcp connect` (the transport `init` scaffolds). `mcp serve` pipes
|
|
163
|
+
upstream responses through untouched — including SSE — and buffering them to
|
|
164
|
+
inject an advisory line would break streaming for every response to deliver it
|
|
165
|
+
on some. On that transport the stderr warning stays the only signal.
|
|
166
|
+
|
|
167
|
+
- **`init` now says that its permission rules only bind the next session.** It
|
|
168
|
+
writes `.claude/settings.json` with the deny rules that keep an agent off
|
|
169
|
+
`.decanter.json`, `.env` and `push --force` — but agents read permission config
|
|
170
|
+
at startup, not on change, and `init` is normally run *from inside* the session
|
|
171
|
+
those rules are meant to constrain. They were silently inert until a restart,
|
|
172
|
+
and the docs mentioned a restart only for the skills plugin — so the rules that
|
|
173
|
+
actually gate the agent went unmentioned. Printed once, when the file is first
|
|
174
|
+
written; a re-init in a set-up directory stays quiet.
|
|
175
|
+
|
|
176
|
+
- **`node run` fixtures can pin a node's other outputs, so `$('Node').all(1)`
|
|
177
|
+
finally answers.** Give a node one items array **per output** and the branch
|
|
178
|
+
is readable offline:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
"nodes": { "Decide": [[{ "json": { "side": "true" } }], [{ "json": { "side": "false" } }]] }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`all(1)` / `first(1)` / `last(1)` and `$items('Decide', 1)` read output 1, and
|
|
185
|
+
an **empty** array is a real answer — that branch took no items. `input` takes
|
|
186
|
+
the same shape, indexed by the node's *input* (a Merge node's second input).
|
|
187
|
+
A plain items array still means a single output, so existing fixtures are
|
|
188
|
+
unchanged; asking for an output the fixture doesn't supply still refuses,
|
|
189
|
+
now saying how many it has. Until now every such call was refused outright,
|
|
190
|
+
because a fixture could only express one array per node.
|
|
191
|
+
|
|
192
|
+
- **`preflight --simulate` now replays a pinned node's other outputs too.** The
|
|
193
|
+
stand-in decanter substitutes for a network node is a Code node, which has one
|
|
194
|
+
output — so an error output (or any second branch) captured in your execution
|
|
195
|
+
was replayed nowhere, and everything behind it sat with no input, emitted
|
|
196
|
+
nothing, and let the run pass. Each populated output now gets **its own
|
|
197
|
+
stand-in**, wired to the same input as the original and feeding exactly that
|
|
198
|
+
output's targets, so the branch really runs. Deliberately not wired to the
|
|
199
|
+
synthetic trigger: a stand-in fires only when the original would, so a replay
|
|
200
|
+
whose real nodes take a different path can't have the old branch's items
|
|
201
|
+
injected into it anyway. The `simulate` check names the splits in its details
|
|
202
|
+
(terminal and `--json`). **`test` still replays `main[0]` only** — n8n's
|
|
203
|
+
`pinData` is one flat items array per node, with no output dimension — and
|
|
204
|
+
`scenario check` now spells out which of the two you are looking at.
|
|
205
|
+
|
|
206
|
+
- **`test` now reports what the run actually moved, not just that it
|
|
207
|
+
finished** — a coverage line over the nodes that executed (enabled and
|
|
208
|
+
unpinned; a pinned node's items are the input you supplied):
|
|
209
|
+
`coverage: 7/9 unpinned node(s) emitted items — 2 emitted none: Group
|
|
210
|
+
products, Write rows`. A node counts as emitting if it put an item on **any**
|
|
211
|
+
output. Some empty nodes are normal — a filter that dropped everything — so
|
|
212
|
+
the line warns and nothing more.
|
|
213
|
+
|
|
214
|
+
**But a run in which not one unpinned node emitted an item now fails
|
|
215
|
+
(exit 1), even with synthetic pins.** n8n calls such a run `success` and
|
|
216
|
+
it is: nothing errored. No data moved either, so nothing was demonstrated,
|
|
217
|
+
and reporting it as a pass was the check lying. The message names the usual
|
|
218
|
+
cause — a pin replays a node's first output only.
|
|
219
|
+
|
|
220
|
+
- **`scenario check` warns about what the replay will throw away.** Both replay
|
|
221
|
+
paths (`test`'s `pinData`, `preflight --simulate`'s stand-in node) read
|
|
222
|
+
`main[0]` only, while the validator happily accepts — and `✓ valid`s — a
|
|
223
|
+
scenario carrying items on several outputs. The check now says so offline:
|
|
224
|
+
once for a node whose data populates more than one output, naming the indices
|
|
225
|
+
that get dropped, and once for a node source that reads a pinned node's
|
|
226
|
+
non-first output (`$('Enrich').all(1)`, `$items('Enrich', 1)`) — the call that
|
|
227
|
+
returns nothing and leaves the node emitting nothing. Warnings only; the
|
|
228
|
+
scenario stays valid for the outputs that do replay.
|
|
229
|
+
|
|
230
|
+
### Changed
|
|
231
|
+
|
|
232
|
+
- **An explicit `.ts` extension in a node file's import no longer fails the
|
|
233
|
+
typecheck.** The scaffolded `tsconfig.json` now sets
|
|
234
|
+
`allowImportingTsExtensions`, so `import { total } from
|
|
235
|
+
"../../../shared/money.ts"` type-checks — until now it was rejected
|
|
236
|
+
(TS5097) even though `push` bundles it without complaint, which made the
|
|
237
|
+
gate and the bundler disagree over a pure spelling choice. Extensionless
|
|
238
|
+
stays the recommended form (it survives a helper later becoming `.js`);
|
|
239
|
+
both spellings resolve everywhere. Existing sync dirs are offered the
|
|
240
|
+
updated `tsconfig.json` on the next `init` — a `tsconfig.json` you edited
|
|
241
|
+
yourself is reported as drift and left alone, so add the option by hand
|
|
242
|
+
there.
|
|
243
|
+
- **Two of the four import rules for `.ts` nodes now warn instead of
|
|
244
|
+
blocking a push**: a relative import resolving outside the sync dir, and an
|
|
245
|
+
absolute-path import. Both only endanger the author's own portability — the
|
|
246
|
+
bundle still builds locally and fails loudly (`Could not resolve`) wherever
|
|
247
|
+
the target is genuinely absent — so blocking them was decanter making the
|
|
248
|
+
user's call. The advisory prints on every surface (`preflight`'s `layout`
|
|
249
|
+
details, `push`, `node run`) and exactly once per push;
|
|
250
|
+
`preflight --fail-on=warn` is the strict variant for CI. **Node builtins
|
|
251
|
+
and npm packages not opted into `bundleDependencies` still block** —
|
|
252
|
+
esbuild is silent about both, so without the block the failure would
|
|
253
|
+
surface at runtime on the n8n instance.
|
|
254
|
+
- **The scaffolded `mcp-route-check.mjs` session hook now also inspects
|
|
255
|
+
user-level agent config for direct n8n MCP routes** — Claude Code's
|
|
256
|
+
`~/.claude.json` (including its entry for the current project), Cursor's
|
|
257
|
+
`~/.cursor/mcp.json`, the VS Code user profile, and opencode's global
|
|
258
|
+
config. Previously it read only project files, so an `n8n` server added
|
|
259
|
+
with `claude mcp add -s user` (or any other user-scoped config) bypassed
|
|
260
|
+
the decanter guard without a word — exactly the "second door" the hook
|
|
261
|
+
exists to catch. Still a warning, never a gate. Re-run `init` in an
|
|
262
|
+
existing sync dir (or re-copy the hook from the template) to pick it up.
|
|
263
|
+
- **The scaffolded `tsconfig.json` now covers the whole sync dir**, not just
|
|
264
|
+
`shared/` and `workflows/` — helper code may live in any folder inside the
|
|
265
|
+
sync dir (`shared/` is only the scaffolded default), so the typecheck and
|
|
266
|
+
the editor's tsserver now own every root without a config edit. Existing
|
|
267
|
+
sync dirs keep their scaffolded file; to match, widen `include` to
|
|
268
|
+
`["n8n-globals.d.ts", "**/*.ts", "**/*.js"]` and add
|
|
269
|
+
`"**/backups/**", "**/executions/**", "decanter-ts-plugin", "dist"` to
|
|
270
|
+
`exclude`. Two consequences worth knowing: a loose node-shaped scratch file
|
|
271
|
+
(top-level `return` outside any workflow's `code/`) is now part of the
|
|
272
|
+
program and reports TS1108 — move it into a workflow or add its folder to
|
|
273
|
+
`exclude`; and when `init` scaffolds into an **existing project** that had
|
|
274
|
+
no `tsconfig.json`, the new config sweeps that project's own `.ts`/`.js`
|
|
275
|
+
into the node-file typecheck — add your app dirs to `exclude` if they
|
|
276
|
+
shouldn't gate pushes.
|
|
277
|
+
|
|
278
|
+
### Fixed
|
|
279
|
+
|
|
280
|
+
- **`diff` and `preflight` no longer report a `CONFLICT` for a node with no
|
|
281
|
+
recorded sync hash.** "Changed both locally and remotely" is measured against
|
|
282
|
+
the last-sync baseline in `.decanter.json`; with no baseline nothing is
|
|
283
|
+
*known* to have moved on the instance, and `push` has always treated that as
|
|
284
|
+
pushable. The two disagreed, so the report described a dead end the CLI did
|
|
285
|
+
not have — worse, its documented exit (`push --force`) is denied to agents by
|
|
286
|
+
the permission rules `init` scaffolds. Such a node now reads as
|
|
287
|
+
`push pending`, which is what `push` does with it. `pull` no longer warns
|
|
288
|
+
`CONFLICT` for the same case on `.ts` nodes.
|
|
289
|
+
- **A `.js` → `.ts` conversion no longer reads as data loss.** Re-pointing a
|
|
290
|
+
node's `//@file:` placeholder is the sanctioned way to convert, and `push`
|
|
291
|
+
and `pull` both adopt it before doing anything — but `diff` and `preflight`
|
|
292
|
+
looked the file up in `.decanter.json` alone and announced `local file
|
|
293
|
+
code/<node>.js missing` for the file you had just replaced. They now read the
|
|
294
|
+
placeholder too, so a converted node reports `local changes in
|
|
295
|
+
code/<node>.ts — push pending`, identically for every converted node.
|
|
296
|
+
- **`preflight` no longer prints `✓ parity local code matches the draft`
|
|
297
|
+
directly above `✗ drift CONFLICT`.** Both checks read the same facts, so
|
|
298
|
+
`parity` may claim a match only when every node is in sync; divergence that
|
|
299
|
+
`drift` owns is reported as an `info` line pointing at it.
|
|
300
|
+
- **`mcp connect` survives an unreachable n8n instead of dying at the
|
|
301
|
+
handshake.** `initialize` was forwarded like any other message, so a
|
|
302
|
+
connection failure answered the *handshake* with an error: the agent's MCP
|
|
303
|
+
client got no `serverInfo` and tore the session down before a single tool
|
|
304
|
+
call could report what was wrong. The guard now completes the handshake
|
|
305
|
+
itself when n8n does not answer, and the failure surfaces on the tool call
|
|
306
|
+
that needed the instance. Once n8n is reachable, the handshake is replayed
|
|
307
|
+
upstream so the session it uses is a real one. The startup line now reads
|
|
308
|
+
`guard: ready — forwarding all n8n MCP tools to <host>` instead of
|
|
309
|
+
`connected to <host>`, which claimed a connection nothing had made yet.
|
|
310
|
+
- **The scaffolded `AGENTS.md` now names the n8n-side prerequisites.** MCP
|
|
311
|
+
access is a switch per *instance* **and** one per *workflow* ("Available in
|
|
312
|
+
MCP"), and until a workflow's is on, `pull`/`push`/`diff`/`preflight`/`watch`
|
|
313
|
+
all fail for it. The CLI's error says so; the file an agent reads *before*
|
|
314
|
+
running anything did not.
|
|
315
|
+
- **`preflight`'s `types` check now reports type errors in shared helper
|
|
316
|
+
files** instead of passing green while `push` fails on them. The scoped
|
|
317
|
+
typecheck dropped every diagnostic outside the graded workflow's own dir —
|
|
318
|
+
and helper code lives outside every workflow dir by definition — so a
|
|
319
|
+
workflow could grade `ready` on code `push` then rejected. Helper
|
|
320
|
+
diagnostics (under `shared/` or any other folder) now surface in every
|
|
321
|
+
workflow's `types` line, while another workflow's *node* errors stay out of
|
|
322
|
+
scope as before.
|
|
323
|
+
- **The documented shared-import path was one level short** for the `code/`
|
|
324
|
+
layout: `../../shared/money` in the TypeScript-nodes docs and the scaffolded
|
|
325
|
+
`AGENTS.md` resolved to `workflows/shared/money` and failed on copy-paste.
|
|
326
|
+
The correct depth from `workflows/<folder>/code/` is `../../../shared/money`.
|
|
327
|
+
- **Compiled module labels — and therefore sync hashes — are now stable when
|
|
328
|
+
the sync dir is reached through a symlinked path** (macOS `/tmp`, a
|
|
329
|
+
symlinked checkout). esbuild resolves bundled files to their realpaths, so
|
|
330
|
+
an un-realpathed label base produced machine-specific `../…`-climbing
|
|
331
|
+
labels inside the hashed bytes — nodes reading "push pending" across
|
|
332
|
+
machines with nobody touching code. The same spelling mismatch made a
|
|
333
|
+
*scoped* typecheck silently drop every node diagnostic under a symlinked
|
|
334
|
+
path; both now realpath. If a sync dir lives behind a symlink, the first
|
|
335
|
+
push after this release re-baselines the affected nodes' hashes once.
|
|
336
|
+
- **The route-check hook now reads opencode's real config shape**
|
|
337
|
+
(`mcp.<name>`). It previously looked only for `mcpServers` / `mcp.servers` /
|
|
338
|
+
`servers` bags, so an `opencode.json` routing straight at the instance was
|
|
339
|
+
listed as checked but never actually flagged.
|
|
340
|
+
- **A `watch` save now runs the same folder-wide compliance guard as `push`.** It
|
|
341
|
+
used to check only the saved file, which cannot see the workflow's node names —
|
|
342
|
+
so it could not catch a dangling `$('Renamed Node')` and pushed straight to the
|
|
343
|
+
draft what a manual `push` refuses outright. The break then surfaced at run time
|
|
344
|
+
in n8n instead of at save time. Four surfaces already claimed the guards were
|
|
345
|
+
the same; now they are.
|
|
346
|
+
|
|
347
|
+
**The abort is scoped, deliberately:** a save is refused when the violation is
|
|
348
|
+
in the file you just saved, and violations elsewhere in the folder are printed
|
|
349
|
+
on every save without blocking it. Repairing a rename means fixing several
|
|
350
|
+
files, and a folder-wide abort would stop every save until the last fix — which
|
|
351
|
+
would disable `watch` during exactly the job it is for.
|
|
352
|
+
|
|
353
|
+
- **`liveMirror` is documented as what it is: a full `pull`.** The shipped agent
|
|
354
|
+
contract and the config reference both described it as refreshing the
|
|
355
|
+
`workflow.json` snapshot. It also rewrites the `code/` files and
|
|
356
|
+
`.decanter.json` and moves files on a rename, so it can overwrite an unpushed
|
|
357
|
+
local edit — which is worth knowing before you restructure. No behaviour change;
|
|
358
|
+
the docs now say what the code has always done.
|
|
359
|
+
|
|
10
360
|
## [0.10.0] - 2026-08-07
|
|
11
361
|
|
|
12
362
|
### Added
|
package/README.md
CHANGED
|
@@ -34,8 +34,9 @@ clean git diffs.
|
|
|
34
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
|
|
38
|
-
|
|
37
|
+
- **Shared code and small libraries** — `.ts` nodes import helper files from
|
|
38
|
+
anywhere in the sync dir (`shared/` by default) and opted-in npm packages,
|
|
39
|
+
bundled on push to run anywhere, n8n Cloud included.
|
|
39
40
|
- **Agent-native** — `init` scaffolds Claude Code / Cursor / Codex configs;
|
|
40
41
|
`preflight --offline` and `node run` give agents a credential-free loop.
|
|
41
42
|
- **Guarded agent access to n8n's MCP, wired by default** — the scaffolded
|
|
@@ -72,6 +73,13 @@ n8n-decanter init [dir] # OAuth in your browser (or a pasted MCP token)
|
|
|
72
73
|
n8n-decanter pull # pick a workflow from the list → it pulls
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
**Restart your coding agent after `init`** — the MCP servers, permission rules
|
|
77
|
+
and hooks it scaffolds are read at agent startup, so the session that ran `init`
|
|
78
|
+
is still unconfigured. If the sync dir is nested in a bigger repo and you start
|
|
79
|
+
the agent at the repo root, a restart is *not* enough — that wiring loads only
|
|
80
|
+
from the dir the agent starts in
|
|
81
|
+
([which file loads from where](docs/agents/overview.md#where-the-agent-wiring-loads-from)).
|
|
82
|
+
|
|
75
83
|
On a terminal, `pull` with no argument lists your n8n workflows — pick one and
|
|
76
84
|
it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
|
|
77
85
|
<id-or-name>` pulls it directly (scriptable, no TTY needed).
|
|
@@ -79,7 +87,9 @@ it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
|
|
|
79
87
|
set a bare `pull`/`push`/`diff` acts on.)
|
|
80
88
|
|
|
81
89
|
**Credentials:** OAuth by default (via `init`); `N8N_MCP_TOKEN` for
|
|
82
|
-
headless/CI
|
|
90
|
+
headless/CI — pass it to `init --host <url> --token <mcp-token>` rather than
|
|
91
|
+
hand-writing `.env`, which leaves the config, template, `.gitignore` and agent
|
|
92
|
+
configs unscaffolded; `N8N_API_KEY` is optional, needed only for `executions`,
|
|
83
93
|
`data-tables`, and `backup`. Details: [Installation](docs/getting-started/installation.md),
|
|
84
94
|
[init](docs/cli/init.md), [Configuration](docs/concepts/configuration.md).
|
|
85
95
|
|
|
@@ -131,6 +141,17 @@ renaming, and archiving workflows — and adding or renaming nodes — are
|
|
|
131
141
|
**n8n's acts**: do them in the editor or let your agent do it over n8n's MCP
|
|
132
142
|
tools (through the guard above); the next `pull` reconciles the local mirror.
|
|
133
143
|
|
|
144
|
+
**Sync dir nested in a bigger repo?** A global `--dir <path>` (or
|
|
145
|
+
`N8N_DECANTER_DIR`) starts the upward `decanter.config.json` search there
|
|
146
|
+
instead of in the working directory — every verb honours it except `init`,
|
|
147
|
+
which takes the directory to scaffold as its argument. A coding agent launched
|
|
148
|
+
at the repo root needs it too — agents look for `.mcp.json` and
|
|
149
|
+
`.claude/settings.json` from where they were *started*, never in a directory
|
|
150
|
+
below it, so either start the agent in the sync dir or wire the root
|
|
151
|
+
explicitly:
|
|
152
|
+
[Working with coding agents](docs/agents/overview.md#where-the-agent-wiring-loads-from),
|
|
153
|
+
[mcp connect](docs/cli/mcp-connect.md#when-your-sync-dir-is-not-your-project-root).
|
|
154
|
+
|
|
134
155
|
## How it compares
|
|
135
156
|
|
|
136
157
|
n8n-decanter is **Code-node-first**: it optimizes writing, typing, verifying and
|
|
@@ -142,7 +163,7 @@ a broader whole-workflow authoring toolkit:
|
|
|
142
163
|
| Capability | Native n8n (browser) | n8n-as-code | n8n-decanter |
|
|
143
164
|
|---|---|---|---|
|
|
144
165
|
| **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 | ✅
|
|
166
|
+
| **Shared types & helpers in Code nodes** | ❌ self-host `NODE_FUNCTION_ALLOW_*` only | ❌ not part of its model | ✅ helper `.ts` anywhere in the sync dir + npm, bundled into self-contained nodes (Cloud-safe) |
|
|
146
167
|
| **Code as individual files** | ❌ no source files (JSON blob) | 🟡 one `.workflow.ts` per workflow | ✅ folder per workflow; each Code node its own file |
|
|
147
168
|
| **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
169
|
| **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 |
|
package/dist/lib/backup.mjs
CHANGED
|
@@ -135,7 +135,7 @@ async function assembleForRestore(dir, backup, log) {
|
|
|
135
135
|
if (file === null)
|
|
136
136
|
continue; // inline code (untracked node) — leave as-is
|
|
137
137
|
try {
|
|
138
|
-
const { jsCode } = await buildNodeCode(dir, file, log);
|
|
138
|
+
const { jsCode } = await buildNodeCode(dir, file, log, { quietImportWarnings: true });
|
|
139
139
|
node.parameters.jsCode = jsCode;
|
|
140
140
|
}
|
|
141
141
|
catch {
|
package/dist/lib/compile.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { builtinModules } from "node:module";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { build, transform } from "esbuild";
|
|
@@ -119,38 +119,53 @@ export function findBundleContext(fromDir) {
|
|
|
119
119
|
dir = parent;
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
/** realpath when resolvable, the input untouched otherwise (missing dirs). */
|
|
123
|
+
function realDir(dir) {
|
|
124
|
+
try {
|
|
125
|
+
return realpathSync(dir);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return dir;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
122
131
|
/** Package name of a bare specifier (`@scope/pkg/sub` → `@scope/pkg`). */
|
|
123
132
|
function packageName(spec) {
|
|
124
133
|
return spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
|
|
125
134
|
}
|
|
126
135
|
/**
|
|
127
|
-
* Offline import rules for a node file (plans/14)
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
136
|
+
* Offline import rules for a node file (plans/14), split by who the rule
|
|
137
|
+
* protects (Plan 79 task 7): a Node builtin or an un-opted-in npm package is
|
|
138
|
+
* **blocking** — esbuild is silent about both, so without the block the
|
|
139
|
+
* failure (a `__require` shim, a silently inlined package) surfaces at
|
|
140
|
+
* runtime on the instance. A relative import leaving the sync dir, or an
|
|
141
|
+
* absolute path, is **advisory** — it only endangers the author's own
|
|
142
|
+
* portability, and esbuild fails loudly wherever the target is genuinely
|
|
143
|
+
* absent. Shared by the compliance guard and the compiler, so preflight's
|
|
144
|
+
* `layout` check and `push` disagree on nothing.
|
|
131
145
|
*/
|
|
132
146
|
export function checkNodeImports(file, specifiers, ctx) {
|
|
133
|
-
const
|
|
147
|
+
const blocking = [];
|
|
148
|
+
const advisory = [];
|
|
134
149
|
for (const spec of specifiers) {
|
|
135
150
|
if (spec.startsWith("node:") || BUILTINS.has(packageName(spec))) {
|
|
136
|
-
|
|
151
|
+
blocking.push(`imports the Node builtin "${spec}" — builtins cannot be bundled into a Code node (whether n8n allows them at runtime is the instance's NODE_FUNCTION_ALLOW_BUILTIN policy); inline the logic instead`);
|
|
137
152
|
}
|
|
138
153
|
else if (spec.startsWith("./") || spec.startsWith("../")) {
|
|
139
154
|
if (ctx.syncRoot !== null) {
|
|
140
155
|
const resolved = path.resolve(path.dirname(file), spec);
|
|
141
156
|
if (resolved !== ctx.syncRoot && !resolved.startsWith(ctx.syncRoot + path.sep)) {
|
|
142
|
-
|
|
157
|
+
advisory.push(`imports "${spec}", which resolves outside the sync dir (${ctx.syncRoot}) — it bundles, but anyone whose checkout lacks the target can't build this node`);
|
|
143
158
|
}
|
|
144
159
|
}
|
|
145
160
|
}
|
|
146
161
|
else if (path.isAbsolute(spec)) {
|
|
147
|
-
|
|
162
|
+
advisory.push(`imports the absolute path "${spec}" — it bundles on this machine only; prefer a relative import inside the sync dir`);
|
|
148
163
|
}
|
|
149
164
|
else if (!ctx.bundleDependencies.includes(packageName(spec))) {
|
|
150
|
-
|
|
165
|
+
blocking.push(`imports the npm package "${packageName(spec)}" without opting it in — add it to "bundleDependencies" in decanter.config.json to bundle it into the pushed node`);
|
|
151
166
|
}
|
|
152
167
|
}
|
|
153
|
-
return
|
|
168
|
+
return { blocking, advisory };
|
|
154
169
|
}
|
|
155
170
|
/**
|
|
156
171
|
* One-way compile of a .ts node file to the JS that runs inside n8n.
|
|
@@ -159,7 +174,7 @@ export function checkNodeImports(file, specifiers, ctx) {
|
|
|
159
174
|
* imports, the file is bundled self-contained (see the header comment); the
|
|
160
175
|
* output is still a function body ending in a top-level `return`.
|
|
161
176
|
*/
|
|
162
|
-
export async function compileTs(file, log) {
|
|
177
|
+
export async function compileTs(file, log, opts) {
|
|
163
178
|
const source = readFileSync(file, "utf8");
|
|
164
179
|
const { importBlock, specifiers, body } = scanNodeImports(source);
|
|
165
180
|
if (specifiers.length === 0) {
|
|
@@ -172,11 +187,35 @@ export async function compileTs(file, log) {
|
|
|
172
187
|
return result.code.endsWith("\n") ? result.code : result.code + "\n";
|
|
173
188
|
}
|
|
174
189
|
const ctx = findBundleContext(path.dirname(file));
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
177
|
-
throw new Error(`${file}:\n${
|
|
190
|
+
const { blocking, advisory } = checkNodeImports(file, specifiers, ctx);
|
|
191
|
+
if (blocking.length > 0) {
|
|
192
|
+
throw new Error(`${file}:\n${blocking.map((p) => ` ${p}`).join("\n")}`);
|
|
193
|
+
}
|
|
194
|
+
// Advisory findings warn and let the compile proceed (Plan 79 task 7). The
|
|
195
|
+
// push paths pass quietImportWarnings because their guard tier already
|
|
196
|
+
// printed the same findings — without it every violation would print twice
|
|
197
|
+
// per push, once per channel.
|
|
198
|
+
if (!opts?.quietImportWarnings) {
|
|
199
|
+
for (const p of advisory)
|
|
200
|
+
log?.warn(`${file}: ${p}`);
|
|
178
201
|
}
|
|
179
|
-
|
|
202
|
+
// Realpath the label base: esbuild resolves every bundled module to its
|
|
203
|
+
// realpath, so a symlink anywhere in the sync dir's own path (macOS /tmp,
|
|
204
|
+
// a symlinked checkout) would otherwise make `path.relative` climb across
|
|
205
|
+
// the symlink — machine-specific `../…` module labels inside the hashed
|
|
206
|
+
// bytes, i.e. the exact cross-machine drift the sync-root-relative labels
|
|
207
|
+
// exist to prevent (Plan 79 task 4).
|
|
208
|
+
const workingDir = realDir(ctx.syncRoot ?? path.dirname(file));
|
|
209
|
+
// resolveDir follows, but ONLY when its realpath stays inside the real sync
|
|
210
|
+
// root (the whole-tree-behind-a-symlink shape, where realpathing keeps the
|
|
211
|
+
// entry label clean). When a symlink BETWEEN the root and the node file
|
|
212
|
+
// realpaths out of the tree, keep the caller's spelling: imports must
|
|
213
|
+
// resolve exactly where checkNodeImports approved them, or the guard and
|
|
214
|
+
// the bundler disagree — approving X/shared and then bundling (or failing
|
|
215
|
+
// on) something else entirely.
|
|
216
|
+
const spelledDir = path.dirname(file);
|
|
217
|
+
const realNodeDir = realDir(spelledDir);
|
|
218
|
+
const resolveDir = realNodeDir === workingDir || realNodeDir.startsWith(workingDir + path.sep) ? realNodeDir : spelledDir;
|
|
180
219
|
// The entry must contain NO `export` syntax: n8n's task-runner sandbox
|
|
181
220
|
// neuters getter property descriptors (Object.defineProperty with `get`
|
|
182
221
|
// reads back undefined), and esbuild lowers module exports to exactly such
|
|
@@ -194,7 +233,7 @@ export async function compileTs(file, log) {
|
|
|
194
233
|
stdin: {
|
|
195
234
|
contents: entry,
|
|
196
235
|
loader: "ts",
|
|
197
|
-
resolveDir
|
|
236
|
+
resolveDir,
|
|
198
237
|
sourcefile: ENTRY_SOURCEFILE,
|
|
199
238
|
},
|
|
200
239
|
bundle: true,
|