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 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 from `shared/` and
38
- opted-in npm packages, bundled on push to run anywhere, n8n Cloud included.
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; `N8N_API_KEY` is optional, needed only for `executions`,
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 | ✅ `shared/*.ts` + npm bundled into self-contained nodes (Cloud-safe) |
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 |
@@ -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 {
@@ -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): relative imports stay
128
- * inside the sync dir, bare specifiers need a `bundleDependencies` opt-in,
129
- * builtins can never be bundled. Shared by the compliance guard and the
130
- * compiler, so preflight's `layout` check and `push` disagree on nothing.
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 problems = [];
147
+ const blocking = [];
148
+ const advisory = [];
134
149
  for (const spec of specifiers) {
135
150
  if (spec.startsWith("node:") || BUILTINS.has(packageName(spec))) {
136
- problems.push(`imports the Node builtin "${spec}" — builtins cannot be bundled into a Code node (whether n8n allows them at runtime is the instance's NODE_FUNCTION_ALLOW_BUILTIN policy); inline the logic instead`);
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
- problems.push(`imports "${spec}", which resolves outside the sync dir (${ctx.syncRoot}) — shared code must live inside it`);
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
- problems.push(`imports the absolute path "${spec}" — use a relative import inside the sync dir`);
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
- problems.push(`imports the npm package "${packageName(spec)}" without opting it in — add it to "bundleDependencies" in decanter.config.json to bundle it into the pushed node`);
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 problems;
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 problems = checkNodeImports(file, specifiers, ctx);
176
- if (problems.length > 0) {
177
- throw new Error(`${file}:\n${problems.map((p) => ` ${p}`).join("\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
- const workingDir = ctx.syncRoot ?? path.dirname(file);
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: path.dirname(file),
236
+ resolveDir,
198
237
  sourcefile: ENTRY_SOURCEFILE,
199
238
  },
200
239
  bundle: true,