n8n-decanter 0.7.0 → 0.8.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,326 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-07-27
11
+
12
+ ### Added
13
+
14
+ - **The interactive picker offers a `--force` retry when a `push` hits the drift
15
+ guard.** Previously the error was printed and you were dropped back at the
16
+ menu, having to leave the picker and re-run `push --force` by hand — even
17
+ though the CLI had just told you `--force` would fix it. Now it asks:
18
+
19
+ ```
20
+ ✗ remote code changed since last sync — pull first (or repeat with --force to overwrite the draft)
21
+ retry with --force and overwrite the remote draft? [y/N]
22
+ ```
23
+
24
+ The default is **No**: a bare `Enter`, or anything other than `y`/`yes`,
25
+ returns to the menu exactly as before. Answering `y` re-runs the same menu row
26
+ (flags included) with `--force`, overwriting the n8n **draft** only — the
27
+ published version is untouched.
28
+
29
+ **It only appears for failures `--force` can actually fix.** A layout
30
+ compliance error never prompts, because forcing does not bypass it. And this
31
+ is the interactive picker session only: piped and non-interactive runs never
32
+ prompt — they print the `--force` hint and exit non-zero, unchanged.
33
+
34
+ - **`diff` — the new verb for "show me the actual changed lines".** It is the
35
+ promoted half of `status --diff`: per-node unified line diffs of your local
36
+ code against the n8n draft, `.ts` compiled first (bundling `shared/*`, so a
37
+ helper edit shows every importing node). Nodes that are in sync are omitted
38
+ entirely, and a clean tree says so in one line. Multi-ref like `pull`/`push`;
39
+ no workflow on a terminal opens the picker.
40
+
41
+ **It always exits 0.** `diff` is an inspection view, like `git diff` — the
42
+ gate is `preflight`. See the migration note under Removed.
43
+
44
+ - **`preflight --viewer`** (with `--simulate`): leaves a browsable throwaway
45
+ n8n running so you can open the replayed run in the UI — the interactive half
46
+ of the old `simulate` verb, now explicit instead of implied by a TTY. It does
47
+ **not** relax preflight's safety contract: the graded run stays headless with
48
+ `--network-none`, and the viewer is a second, separate container. A workflow
49
+ with a multi-batch loop reports `simulate` as **skipped** under `--viewer`
50
+ ("a preview, not a pass/fail check"), never as a pass.
51
+
52
+ - **`preflight --no-typecheck`** skips the `types` check — the escape hatch the
53
+ retired `check` verb had.
54
+
55
+ - **The agent guard now logs a startup line and an audit trail** (`mcp connect`
56
+ and `mcp serve` alike, on stderr):
57
+
58
+ ```
59
+ guard: connected to <host> — forwarding all n8n MCP tools, blocking jsCode writes in update_workflow
60
+ guard: forwarded search_workflows
61
+ ```
62
+
63
+ Previously the guard spoke **only** when it blocked something, so an empty log
64
+ meant either "ran, blocked nothing" or "never started" — indistinguishable,
65
+ and opposite in meaning. The startup line settles that; the per-call lines
66
+ make the guard the one place that can answer *what did an agent actually do
67
+ to my n8n instance?*, since every MCP call passes through it.
68
+
69
+ **Tool names only — arguments are never logged**, so the log stays safe to
70
+ attach to a bug report.
71
+
72
+ - **Every `preflight` finding can now carry `details[]`** — the full list behind
73
+ the one-line message: *every* layout violation, *every* `tsc` error, the
74
+ drifted node names, the viewer URL. Printed indented under the check line,
75
+ and present in `--json`. This is how the information `check` printed in full
76
+ survives its removal; without it, folding `check` into `preflight` would have
77
+ truncated a 12-violation layout failure to its first line.
78
+
79
+ ### Changed
80
+
81
+ - **The picker lists pulled workflows newest-synced first**, instead of the
82
+ folder's alphabetical order — the workflow you last pulled or pushed is under
83
+ the cursor when the picker opens. Unpulled remote rows keep their place after
84
+ the local ones. The signal is each workflow folder's sync timestamp, so it is
85
+ *local activity*, not committed history: right after a fresh `git clone`
86
+ everything looks equally recent and the list falls back to alphabetical until
87
+ your first pull or push. Scripted `list` output is deliberately unchanged.
88
+
89
+ - **The CLI banner's `n8n` wordmark now uses the brand orange, matching the
90
+ website** (`#E18528`, derived from the site's accent color) rather than ANSI
91
+ red. It degrades gracefully — a 256-color terminal gets the nearest orange, a
92
+ 16-color one keeps the old red — and piped output and `NO_COLOR` stay plain,
93
+ exactly as before.
94
+
95
+ - **Breaking: `preflight`'s profiles are replaced by two orthogonal flags.**
96
+ `--full` and the `Profile` model are gone. Depth is now `--simulate`
97
+ (**additive** — appends the local-engine run of your code) and `--offline`
98
+ (**subtractive** — drops the instance-reads tier), and they compose:
99
+
100
+ ```
101
+ preflight static + instance reads (the default gate)
102
+ preflight --simulate + a local-engine run of your code
103
+ preflight --offline static only — no instance contact
104
+ preflight --offline --simulate static + local engine, no instance
105
+ ```
106
+
107
+ **Migration:** `--full` → `--simulate`. And read the next entry carefully —
108
+ `--offline` still exists but means something narrower.
109
+
110
+ - **Breaking: `preflight --offline` no longer runs the local-engine replay.**
111
+ It used to mean "static + engine, no instance"; it now means "static only".
112
+ The flag name is unchanged, so **nothing will error** — an air-gapped CI job
113
+ on `preflight --offline` simply stops running the engine and quietly loses
114
+ that coverage. **Migration:** `preflight --offline --simulate` is the old
115
+ `--offline`. (This narrowing is also what makes `--offline` fast enough for
116
+ the per-edit hook: it now spawns no Docker container.)
117
+
118
+ - **Breaking: `preflight --json` replaces `profile` with `flags`.** Where the
119
+ report carried `"profile": "default" | "full" | "offline"` it now carries
120
+ `"flags": {"simulate": false, "offline": false}`. Agents key on this. Every
121
+ other field is unchanged, and each entry in `checks[]` gains an optional
122
+ `details: string[]`.
123
+
124
+ - **`preflight` with no workflow and an empty `"workflows"` config now checks
125
+ every *pulled* workflow** instead of erroring with "no workflow ids" — the
126
+ behaviour the `check` verb had, kept now that `preflight` absorbs it.
127
+
128
+ - **A workflow folder with an unreadable `.decanter.json` no longer fails your
129
+ gate.** `check` scanned *folders*, so a corrupt state file anywhere under
130
+ `workflows/` was a hard error for the whole run. `preflight` grades resolved
131
+ *workflows*, and a folder whose state won't parse can't resolve to one — so
132
+ it is named in a warning (`corrupt .decanter.json (…) — skipping this
133
+ folder`) and skipped, while every healthy workflow is still graded. The fact
134
+ is still reported; it just no longer blocks work on unrelated workflows.
135
+
136
+ - **`preflight --simulate` accepts multiple workflows.** The old `simulate`
137
+ verb took exactly one; preflight loops, so a multi-ref run spins one engine
138
+ container per workflow, serially.
139
+
140
+ - **The scaffolded template now runs `preflight --offline` where it ran
141
+ `check`** — the PostToolUse verify hook, both `package.json` scripts, and the
142
+ agent-facing prose in `AGENTS.md` / `CLAUDE.md`. *Existing sync dirs keep
143
+ their files*: re-run `n8n-decanter init` to be offered the refresh, and note
144
+ that init leaves locally-modified files alone, so a hand-edited hook or
145
+ `package.json` still invokes a removed verb until you update it yourself.
146
+
147
+ - The scaffolded agent permission allowlist swaps its `check`/`status`/
148
+ `simulate` rules for a `diff` pair (both the bare and `npx` shapes);
149
+ `preflight --simulate` is already covered by the existing `preflight:*` rule.
150
+
151
+ - The interactive picker's action menu is now `preflight`, `preflight
152
+ --simulate`, `diff`, `pull`, `push`, `watch`, `executions` — a menu row may
153
+ carry flags, which is how the browsable local-engine run survives the fold.
154
+
155
+ - **The scaffolded agent contract now treats `push` as part of finishing the
156
+ work, and reserves "ask the user first" for `publish`.** A push lands on the
157
+ workflow's **draft** and never changes what is running; only `publish` /
158
+ `push --publish` / `unpublish` do. The old rule gated both behind "only when
159
+ the user asks", so an agent handed "build me an hourly job that tags orders"
160
+ would build the structure in n8n, write and verify all the Code, and then
161
+ **stop** — leaving every Code node empty on the instance and the real code in
162
+ the repo, reporting "ready to push". Correct by the old rule, and not what
163
+ anybody asked for. Agents are now told to push once offline checks pass, to
164
+ say what landed, and to still ask first when the workflow is published/active
165
+ or a teammate is editing it. Going live remains a deliberate, user-requested
166
+ step. Affects `template/AGENTS.md.example` (copied into new sync dirs by
167
+ `init`) and the `/docs/agents` surfaces. *(Surfaced by the Plan 35 blind field
168
+ test, where the "failing" agent was following the old contract exactly.)*
169
+
170
+ - **The scaffolded agent contract now follows the `preflight → push → test →
171
+ publish` flow.** `preflight` is local-only (it no longer runs on the instance),
172
+ so `test` — which runs the workflow's **draft** — is only meaningful *after* a
173
+ push. The old contract framed `test` as a pre-push runtime check and ended its
174
+ loop at `preflight → push`; both are now reconciled to the new order. Affects
175
+ `template/AGENTS.md.example` and the `/docs/agents` surfaces. *(Same surface
176
+ the Plan 60 verb reorder changed — kept in lockstep so the blind field test
177
+ grades agents against a contract that matches the tool.)*
178
+
179
+ - **Breaking: `preflight` no longer runs the instance-side `test` stage.** It
180
+ ran `test_workflow` against n8n's **draft**, while every other stage graded
181
+ your **local files** — so whenever a push was pending, one score described
182
+ two different versions of the workflow, flagged only by a `-10` parity warn.
183
+ A report could read *caution, 90/100* while its runtime evidence was about
184
+ code you weren't shipping. `preflight` now grades local code only; the
185
+ instance is read for sync facts and never executed.
186
+
187
+ The documented flow is **`preflight` → `push` → `test` → `publish`**: verify
188
+ local code, make it the draft, run what you actually pushed, then go live.
189
+ Nothing was removed from the toolbox — the instance run moved to where it
190
+ means something.
191
+
192
+ **Migration:** a CI job that gates on a plain `preflight` no
193
+ longer gets an instance run inside that gate — the draft is never executed by
194
+ preflight. To keep instance-run coverage, add `n8n-decanter test` as its own
195
+ step **after** your push step (`--require=test` users get a hard error with
196
+ this guidance; default-profile users get this note).
197
+
198
+ - **Breaking: `preflight --require=test` is rejected**, with a message pointing
199
+ at the new flow rather than a bare "unknown check". The `test` id is gone
200
+ from `--require`, from `--json` `checks[]`, and from `coverage`.
201
+
202
+ - **`preflight` auto-fetches a capture only under `--simulate`** — the flag
203
+ that adds the one stage which consumes a capture. Without it there is no
204
+ runtime stage, so preflight no longer fetches, and a missing or stale capture
205
+ is reported as `info` rather than `warn` — nothing would consume one
206
+ (`--offline` never reaches the instance to fetch either way).
207
+
208
+ - The `parity` warn is reworded. It was a caveat about the runtime tier
209
+ grading the wrong artifact; that's no longer possible, so it is now the plain
210
+ next step: *"local code differs from the draft in N node(s) — push to make it
211
+ the draft, then test"*.
212
+
213
+ - **The local-engine replay is the sole runtime stage** (`preflight --simulate`)
214
+ and needs Docker. For runtime evidence without Docker, push and then run
215
+ `test`.
216
+
217
+ - **Node-file type checking moved off TypeScript's legacy `node10` module
218
+ resolution** to `moduleResolution: "bundler"` (with `module: "preserve"`), in
219
+ the scaffolded `tsconfig.json`. This matches what push actually does — `.ts`
220
+ nodes are compiled with esbuild in bundling mode — and keeps the documented
221
+ extensionless import style working (`import { total } from
222
+ "../../shared/money"`). It also unblocks TypeScript 6, which turns `node10`
223
+ into a hard error (`TS5107`), and TypeScript 7, which removes it outright.
224
+ `node16`/`nodenext` were **not** chosen: they reject extensionless relative
225
+ imports (`TS2835`) and would force every node file to be rewritten with `.js`
226
+ extensions. No change to which Node.js versions are supported. *Existing sync
227
+ dirs keep their current `tsconfig.json`* — re-run `n8n-decanter init` to be
228
+ offered the refresh; if you have hand-edited yours (or created it before
229
+ template baselines existed), init reports it and leaves it alone, so apply
230
+ the two-line change yourself when you move to TypeScript 6+.
231
+
232
+ ### Removed
233
+
234
+ - **Breaking: the `check`, `status`, and `simulate` verbs.** All three were
235
+ variations on "check my thing", and telling them apart was the single most
236
+ confusing part of the surface. They fold into two:
237
+
238
+ | You used to run | Now run |
239
+ | --- | --- |
240
+ | `check [workflow…]` | `preflight --offline [workflow…]` |
241
+ | `check --no-typecheck` | `preflight --offline --no-typecheck` |
242
+ | `status [workflow…]` | `preflight [workflow…]` (the scored summary) |
243
+ | `status --diff` | `diff [workflow…]` (the changed lines) |
244
+ | `simulate <workflow>` | `preflight <workflow> --offline --simulate` |
245
+ | `simulate --network-none` | `preflight --simulate` (always network-none) |
246
+
247
+ Each removed verb exits non-zero naming its replacement, so a stale script
248
+ fails loudly rather than silently. **Note the `simulate` row:** the verb
249
+ needed no credentials, and a bare `preflight --simulate` still runs the
250
+ instance tier — add `--offline` for the credential-free equivalent.
251
+
252
+ Nothing was lost with the verbs. The compliance guard still gates every push
253
+ and watch save (only the standalone *view* is gone, and preflight's `layout`
254
+ finding now lists every violation); the publish state, live-lags-draft note
255
+ and snapshot-stale hint are preflight's `lifecycle` and `snapshot` findings.
256
+
257
+ - **Breaking: `status`'s CI exit codes.** `status` exited 1 on a code conflict
258
+ or remote drift, and pipelines gated on that. `diff` always exits 0.
259
+ **Migration:** gate on `preflight`, whose `drift` check **fails** on a
260
+ CONFLICT and warns on remote drift (add `--fail-on=warn` to gate on the warn
261
+ too).
262
+
263
+ - **`simulate --network-none`.** `preflight` always runs the graded engine
264
+ replay with no network, so the flag had nothing left to turn off.
265
+
266
+ - **Breaking: the `preflight --quick` profile.** With the `test` stage gone it
267
+ was byte-identical to the default profile, and rather than redefine it into a
268
+ meaning users would have to learn and then unlearn, it is gone. Static-only
269
+ checking is now `preflight --offline`; the local-engine replay is
270
+ `preflight --simulate` (see the profile→flags entry above, which retired
271
+ `--full` and the whole profile vocabulary in the same release).
272
+
273
+ **Neither `--quick` nor `--full` is recognized any more — they are simply
274
+ gone, not rejected with a migration.** The CLI ignores flags it does not
275
+ know, so `preflight --full` now runs the *default* gate (no engine) and
276
+ `preflight --quick` runs it too, both exiting 0. **If you have either in a CI
277
+ job, update it in the same step as this upgrade** — nothing will tell you at
278
+ runtime. `--full` → `--simulate`; `--quick` → `--offline`.
279
+ - **Breaking: `preflight --trigger <node>`.** It existed only to feed the
280
+ removed instance `test` stage; since that stage's removal it parsed and did
281
+ nothing. `test --trigger <node>` (the post-push instance run) keeps the flag
282
+ — that is where trigger selection acts.
283
+
284
+ ### Fixed
285
+
286
+ - **Renaming a `.ts` node in n8n no longer leaves it stuck on "push pending".**
287
+ A `.ts` node that imports from `shared/` (or an opted-in npm package) is
288
+ compiled with esbuild's bundler, and esbuild labels every bundled module with
289
+ a `// <path>` comment. That label used the node's **own filename**, so it
290
+ landed inside the compiled bytes — and inside the `@ts-n8n sha256:` marker.
291
+ Rename the node in n8n, `pull` renames `compute.ts` → `ümläut-nödé.ts`, and
292
+ the artifact changed even though **not one line of your source did**: `diff`
293
+ reported a difference that was purely the comment line, and the node read
294
+ "local changes — push pending" until you pushed a no-op. The entry label is
295
+ now a fixed name, so a pure rename round-trip is byte-stable and comes back
296
+ clean.
297
+
298
+ **One-time effect when you upgrade:** because the compiled bytes changed, the
299
+ first `diff`/`pull` after upgrading reports **"modified, not yet pushed"** for
300
+ every `.ts` node **that has imports** — a comment-line difference only. One
301
+ `push` per workflow clears it, and it is a plain push, not a conflict: the
302
+ remote code is untouched, so the drift guard does not trip and `--force` is
303
+ not needed. `.ts` nodes **without** imports compile through a different path
304
+ that never embedded the name, and `.js` nodes are unaffected.
305
+
306
+ - **The scaffolded MCP guard now starts under a *local* install, not only a
307
+ global one.** `init`'s `.mcp.json` / `opencode.json` spawned the guard as a
308
+ bare `n8n-decanter mcp connect`, which only resolves when the CLI is on the
309
+ agent's `PATH` — i.e. a **global** install. With decanter installed as a
310
+ **local** project dependency the command silently failed to start, so the
311
+ agent got no guarded route and fell back to whatever other n8n MCP it had,
312
+ unguarded. The scaffolded command is now `npx --no-install n8n-decanter mcp
313
+ connect`, which resolves the local `node_modules` bin **and** a global
314
+ install; `--no-install` never downloads from npm, so a genuinely missing
315
+ install fails loudly instead of silently. (Plan 58.)
316
+
317
+ - **The scaffolded agent permissions now cover `npx n8n-decanter …` — including
318
+ the `push --force` denial.** The same local-install gap applies to the CLI
319
+ calls an agent makes in a shell: under a local (devDependency) install a bare
320
+ `n8n-decanter <verb>` is not on `PATH`, so the working form is
321
+ `npx n8n-decanter <verb>`. The permission matcher keys on the command prefix,
322
+ so that form previously matched **neither** the allow rules (every safe call
323
+ would stop to ask) **nor the `push --force` deny rule** — meaning the
324
+ force-push guard rail could be sidestepped simply by invoking through `npx`.
325
+ Both lists now carry the `npx` forms (Claude Code `settings.json` and
326
+ opencode), and the scaffolded `AGENTS.md` tells agents to add the prefix when
327
+ the bare command is not found. Installing globally is **not** required — a
328
+ per-sync-dir devDependency remains fully supported. (Plan 58.)
329
+
10
330
  ## [0.7.0] - 2026-07-24
11
331
 
12
332
  ### Added
package/README.md CHANGED
@@ -35,17 +35,22 @@ clean git diffs.
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
37
  - **Agent-native** — `init` scaffolds Claude Code / Cursor / Codex configs;
38
- offline `check` and `node run` give agents a credential-free feedback loop.
39
- - **Guardrails & preflights** — a compliance guard, typecheck gate, and
40
- per-node drift guard gate every push; `check` (offline), `simulate`
41
- (offline engine replay, Docker), and `test` (instance-side) each diff every
42
- node against a real captured execution and exit 1 on divergence — and
43
- `preflight` runs the whole ladder as one **scored, read-only, CI-gateable
44
- verdict** (never mutates), the single gate an agent runs before `push`.
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.
45
49
  - **Committed, schema-scaffolded scenarios** — `scenario create` turns a
46
50
  captured execution and/or the workflow's own output schemas
47
51
  (`--scaffold`, no LLM API) into a reviewable, git-tracked pin-data set that
48
- `test`/`simulate` replay — the durable counterpart to an agent's ephemeral
52
+ `test` and `preflight --simulate` replay — the durable counterpart to an
53
+ agent's ephemeral
49
54
  in-session pin flow.
50
55
  - **Live editing** — `watch` pushes on save; the open n8n editor tab reflects
51
56
  each push live (n8n's own draft-edit refresh, no proxy needed).
@@ -87,7 +92,7 @@ On a terminal, `pull` with no argument lists your n8n workflows — pick one and
87
92
  it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
88
93
  <id-or-name>` pulls it directly (scriptable, no TTY needed).
89
94
  (`decanter.config.json`'s optional `"workflows"` array just sets the default
90
- set a bare `pull`/`push`/`status` acts on.)
95
+ set a bare `pull`/`push`/`diff` acts on.)
91
96
 
92
97
  **Credentials:** OAuth by default (via `init`); `N8N_MCP_TOKEN` for
93
98
  headless/CI; `N8N_API_KEY` is optional, needed only for `executions`,
@@ -112,7 +117,8 @@ Code, Codex, or any skills.sh-supported agent, listing the one it detects first.
112
117
 
113
118
  ## Commands
114
119
 
115
- Bare `n8n-decanter` (no verb) opens an interactive picker on a terminal.
120
+ Bare `n8n-decanter` (no verb) opens an interactive picker on a terminal —
121
+ newest-synced workflow first.
116
122
  Full flag reference: `n8n-decanter --help`, or the
117
123
  [command overview](docs/cli/overview.md).
118
124
 
@@ -124,13 +130,11 @@ Full flag reference: `n8n-decanter --help`, or the
124
130
  | `push [workflow…]` | Push Code-node source to the workflow's **draft** |
125
131
  | `watch [workflow]` | Push on save (draft-only); editor updates live |
126
132
  | `publish` / `unpublish [workflow…]` | Take the draft live / back to draft-only |
127
- | `status [workflow…]` | Drift report — exits 1 on conflict or remote drift |
128
- | `check [workflow…]` | Offline layout-compliance check + typecheck |
133
+ | `diff [workflow…]` | Per-node line diff of local code vs the n8n draft — an inspection view, always exits 0 |
129
134
  | `executions [workflow…]` | Fetch recent execution data (read-only) |
130
135
  | `data-tables [table…]` | Fetch data-table schema + rows (read-only) |
131
136
  | `test <workflow>` | Pinned run **on the instance** (draft); diffs vs a capture |
132
- | `simulate <workflow>` | Offline engine replay (Docker); diffs vs a capture |
133
- | `preflight [workflow…]` | The whole verification ladder as one scored, read-only gate (exits 1 on *not ready*) |
137
+ | `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*) |
134
138
  | `scenario create` / `scenario check` | Build / validate a committed scenario (captured and/or schema-scaffolded) |
135
139
  | `backup create` / `restore` / `list` | Git-native disaster recovery — capture / redeploy / list versioned full-export backups |
136
140
  | `list [--remote]` | Pulled workflows (`--remote` adds unpulled ones) |
@@ -170,13 +174,13 @@ whole-workflow authoring toolkit.
170
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) |
171
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` |
172
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) |
173
- | **Preflights** (`check` / `simulate` / `test` / `preflight`) | 🟡 re-run past executions / pin data, but online in-editor | 🟡 inspect executions against a live env | ✅ offline `check` + `simulate`, instance-side `test`; each diffs every node vs a real capture, exits 1 on divergence — and `preflight` scores the whole ladder into one read-only, CI-gateable verdict |
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 |
174
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) |
175
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) |
176
- | **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 `check`/`node run` loop |
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 |
177
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 |
178
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`) |
179
- | **Whole-workflow authoring** | ❌ | ✅ `.workflow.ts` decorator classes (structure + links) | ❌ not as repo code — Code-node source only; your agent builds structure live over MCP (row above), mirrored to a read-only `workflow.json` |
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` |
180
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) |
181
185
 
182
186
  Legend: ✅ first-class · 🟡 partial or indirect · ❌ not supported.
@@ -11,6 +11,15 @@ import { build, transform } from "esbuild";
11
11
  const GLOBAL_NAME = "__n8n_node";
12
12
  const SIZE_WARN_BYTES = 100_000;
13
13
  const BUILTINS = new Set(builtinModules);
14
+ // esbuild labels every bundled module with a `// <path>` comment, so whatever
15
+ // we call the stdin entry lands *inside* the compiled bytes — and therefore
16
+ // inside the `@ts-n8n sha256:` marker. Using the node's own filename made a
17
+ // pure remote rename change the artifact: `pull` renames `compute.ts` ->
18
+ // `ümläut-nödé.ts`, the comment follows, and the node reads "push pending"
19
+ // forever on a comment-only diff. A fixed name keeps the entry label stable
20
+ // across renames; the surrounding dir still comes from `resolveDir`, which is
21
+ // sticky (a workflow folder never follows a remote rename — Plan 27).
22
+ const ENTRY_SOURCEFILE = "node.ts";
14
23
  /**
15
24
  * Split a node file into its leading top-level import block and the body.
16
25
  * Dependency-free and line-precise: the imports-at-top rule means only the
@@ -118,7 +127,7 @@ function packageName(spec) {
118
127
  * Offline import rules for a node file (plans/14): relative imports stay
119
128
  * inside the sync dir, bare specifiers need a `bundleDependencies` opt-in,
120
129
  * builtins can never be bundled. Shared by the compliance guard and the
121
- * compiler, so `check` and `push` disagree on nothing.
130
+ * compiler, so preflight's `layout` check and `push` disagree on nothing.
122
131
  */
123
132
  export function checkNodeImports(file, specifiers, ctx) {
124
133
  const problems = [];
@@ -186,7 +195,7 @@ export async function compileTs(file, log) {
186
195
  contents: entry,
187
196
  loader: "ts",
188
197
  resolveDir: path.dirname(file),
189
- sourcefile: path.basename(file),
198
+ sourcefile: ENTRY_SOURCEFILE,
190
199
  },
191
200
  bundle: true,
192
201
  format: "iife",
package/dist/lib/diff.mjs CHANGED
@@ -1,6 +1,17 @@
1
- // Minimal unified line diff for `status --diff` (plans/3 B). Zero deps by
2
- // design (Plan 11's rule); classic LCS backtracking is plenty at Code-node
3
- // scale, with a size cutoff instead of a fancier algorithm.
1
+ // The `diff` verb (Plan 59) and the minimal unified line diff behind it
2
+ // (plans/3 B). Zero deps by design (Plan 11's rule); classic LCS backtracking
3
+ // is plenty at Code-node scale, with a size cutoff instead of a fancier
4
+ // algorithm.
5
+ //
6
+ // `diff` is the `git diff` half of the old `status` verb: it shows the actual
7
+ // changed LINES for every node that differs from the draft, and nothing else.
8
+ // The summary half (`git status`) is `preflight`. Like `git diff` it is an
9
+ // inspection view, not a gate — it always exits 0.
10
+ import path from "node:path";
11
+ import { getWorkflowDetails } from "./mcp.mjs";
12
+ import { findWorkflowDir } from "./state.mjs";
13
+ import { computeSyncFacts } from "./status.mjs";
14
+ import { style } from "./style.mjs";
4
15
  function toLines(s) {
5
16
  if (s === "")
6
17
  return [];
@@ -72,3 +83,64 @@ export function unifiedDiff(a, b, context = 2) {
72
83
  }
73
84
  return out;
74
85
  }
86
+ /** A node whose local build differs from the draft — the only thing `diff` prints. */
87
+ function isDifferent(node) {
88
+ return node.state !== "in-sync";
89
+ }
90
+ /** One node's state headline; the line diff (when there is one) follows it. */
91
+ function headline(node) {
92
+ switch (node.state) {
93
+ case "unknown-locally":
94
+ return { level: "warn", text: "remote code node unknown locally — pull" };
95
+ case "local-missing":
96
+ return { level: "warn", text: `local file ${node.file} missing` };
97
+ case "changed-remotely":
98
+ return { level: "warn", text: "changed remotely — pull" };
99
+ case "conflict":
100
+ return { level: "error", text: "CONFLICT — changed both locally and remotely" };
101
+ default:
102
+ return { level: "info", text: `local changes in ${node.file} — push pending` };
103
+ }
104
+ }
105
+ /**
106
+ * Print the per-node line diffs between local code and the n8n draft. For `.ts`
107
+ * nodes the local side is the **compiled** JS — the exact bytes `push` would
108
+ * send, and the same bodies the sync hashes compare.
109
+ *
110
+ * Always exits 0 (the caller sets no exit code): this is `git diff`, not a
111
+ * gate. `preflight` is the gate.
112
+ */
113
+ export async function diffWorkflow(mcp, root, id, log) {
114
+ const remote = await getWorkflowDetails(mcp, id);
115
+ const dir = findWorkflowDir(root, id, log);
116
+ if (!dir) {
117
+ log.warn(`${remote.name} (${id}): not pulled yet — n8n-decanter pull ${id}`);
118
+ return;
119
+ }
120
+ log.info(`${remote.name} (${id}) [${path.relative(process.cwd(), dir)}]`);
121
+ const facts = await computeSyncFacts(remote, dir);
122
+ const changed = facts.nodes.filter(isDifferent);
123
+ if (changed.length === 0 && facts.deleted.length === 0) {
124
+ log.info(style.dim(" no differences — every tracked node matches the draft"));
125
+ return;
126
+ }
127
+ for (const node of changed) {
128
+ // Replay this node's captured compileTs warnings before its line — they are
129
+ // about the very build being diffed.
130
+ for (const w of node.warnings ?? [])
131
+ log.warn(w);
132
+ const { level, text } = headline(node);
133
+ log[level](` ${node.name}: ${text}`);
134
+ if (node.remoteBody === undefined || node.localBody === undefined || node.localBody === null || node.file === undefined)
135
+ continue;
136
+ log.info(` ${style.dim("--- remote (n8n)")}`);
137
+ log.info(` ${style.dim(`+++ local (${node.file})`)}`);
138
+ for (const line of unifiedDiff(node.remoteBody, node.localBody)) {
139
+ const styled = line.startsWith("+") ? style.green(line) : line.startsWith("-") ? style.red(line) : style.dim(line);
140
+ log.info(` ${styled}`);
141
+ }
142
+ }
143
+ for (const node of facts.deleted) {
144
+ log.warn(` ${node.file}: node ${node.id} deleted remotely`);
145
+ }
146
+ }
@@ -1,4 +1,5 @@
1
- // Plan 7 — the engine backend for `simulate`. Runs a transformed simulation
1
+ // Plan 7 — the engine backend for preflight's `--simulate` stage. Runs a
2
+ // transformed simulation
2
3
  // workflow through a *real* n8n via route B (validated by the Plan 7 spike):
3
4
  // `n8n import:workflow` + `n8n execute --id --rawOutput` in a throwaway
4
5
  // container, no server and no credentials, output scraped from the result JSON.
@@ -21,7 +22,7 @@ export const DEFAULT_N8N_VERSION = "2.30.7";
21
22
  const SIM_WORKFLOW_ID = "decantersim0000";
22
23
  const EXEC_MARKER = "---DECANTER-EXEC---";
23
24
  const docker = (args, opts = {}) => execFile("docker", args, { encoding: "utf8", maxBuffer: 128 * 1024 * 1024, timeout: opts.timeoutMs ?? 120_000 });
24
- /** True when a Docker daemon is reachable — lets `simulate`/`test:sim` skip cleanly. */
25
+ /** True when a Docker daemon is reachable — lets the simulate stage / `test:sim` skip cleanly. */
25
26
  export async function dockerAvailable() {
26
27
  try {
27
28
  await docker(["version", "--format", "{{.Server.Version}}"], { timeoutMs: 10_000 });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Typed CLI errors — the ones a *caller* has to branch on, not just print.
3
+ */
4
+ /**
5
+ * A gate `--force` really can bypass (Plan 29). Today that is exactly one: the
6
+ * per-node code-drift guard in `push` (`assertNoDrift`). The compliance guard
7
+ * throws a plain `Error` on purpose — `--force` does **not** bypass a layout
8
+ * violation, so offering a force-retry there would be a lie.
9
+ *
10
+ * The interactive picker loop keys on this type to offer "retry with --force?"
11
+ * after a failure. Message-string sniffing was the alternative and is brittle;
12
+ * marking the error is explicit, and any future forceable gate opts in by
13
+ * throwing this instead of `Error`.
14
+ */
15
+ export class ForceableError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "ForceableError";
19
+ }
20
+ }
@@ -8,12 +8,12 @@ export const EXECUTIONS_DIR = "executions";
8
8
  * captured execution (or scaffolded from the workflow's schemas), with any gap
9
9
  * nodes flagged for filling. Tracked in git (unlike gitignored `executions/`),
10
10
  * so replays are reproducible for teammates and CI. Selected explicitly by slug
11
- * (`simulate --scenario <slug>`), not auto-preferred.
11
+ * (`preflight --simulate --scenario <slug>`), not auto-preferred.
12
12
  */
13
13
  export const SCENARIOS_DIR = "scenarios";
14
14
  /** Pre-Plan-37 dir name for scenarios; auto-migrated to `scenarios/` on any verb that touches it. */
15
15
  export const LEGACY_MOCKS_DIR = "mocks";
16
- /** Retired per-node pin dir (`simulate --pin`); a leftover one is now a hard error naming the replacement. */
16
+ /** Retired per-node pin dir (the old `--pin` flag); a leftover one is now a hard error naming the replacement. */
17
17
  export const LEGACY_FIXTURES_DIR = "fixtures";
18
18
  /**
19
19
  * Auto-migrate a workflow folder's pre-Plan-37 `mocks/` dir to `scenarios/`
@@ -36,7 +36,7 @@ export function migrateScenariosDir(dir, log) {
36
36
  }
37
37
  /**
38
38
  * Hard-error on a leftover `fixtures/` dir (Plan 37 fold decision 1: no
39
- * deprecation read-path). `simulate --pin` and per-node `fixtures/` are removed;
39
+ * deprecation read-path). The old `--pin` flag and per-node `fixtures/` are removed;
40
40
  * their role is absorbed by self-contained scenarios. Names the replacement.
41
41
  * No-op when there's no `fixtures/` dir with `.json` files in it.
42
42
  */
@@ -46,14 +46,14 @@ export function assertNoLegacyFixtures(dir) {
46
46
  return;
47
47
  if (!readdirSync(fx).some((e) => e.endsWith(".json")))
48
48
  return;
49
- throw new Error(`legacy ${LEGACY_FIXTURES_DIR}/ dir found in ${path.relative(process.cwd(), dir)} — per-node fixtures and \`simulate --pin\` were removed (Plan 37). ` +
49
+ throw new Error(`legacy ${LEGACY_FIXTURES_DIR}/ dir found in ${path.relative(process.cwd(), dir)} — per-node fixtures and the old \`--pin\` flag were removed (Plan 37). ` +
50
50
  `Recreate the data as a committed scenario (\`n8n-decanter scenario create <workflow> --execution <id>\`), then delete ${LEGACY_FIXTURES_DIR}/.`);
51
51
  }
52
52
  /**
53
53
  * The newest captured execution id in a workflow folder's `executions/` dir, or
54
54
  * null when none are captured. n8n execution ids are incrementing integers, so
55
55
  * "newest" is the highest numeric filename; non-numeric files are ignored. Lets
56
- * `simulate`/`scenario create` (and the picker, which can't supply an id) default
56
+ * preflight's simulate stage / `scenario create` (and the picker, which can't supply an id) default
57
57
  * to the latest capture. Scenarios are slug-named, not "latest"-ordered, so
58
58
  * they're chosen explicitly and don't participate here.
59
59
  */
package/dist/lib/init.mjs CHANGED
@@ -66,7 +66,8 @@ export function cliVersion() {
66
66
  }
67
67
  // Wordmark in the 2×2 quadrant-block minifont (Block Elements). The top row's
68
68
  // leading offset is load-bearing (ascenders of 8/d/t) — keep it verbatim.
69
- // Columns 0-5 are the "n8n" part (brand red on a TTY), the rest "decanter".
69
+ // Columns 0-5 are the "n8n" part (brand orange on a TTY — the website's accent,
70
+ // see style.brand), the rest "decanter".
70
71
  const LOGO_ROWS = [
71
72
  " ▄▖ ▌ ▗",
72
73
  "▛▌▙▌▛▌ ▛▌█▌▛▘▀▌▛▌▜▘█▌▛▘",
@@ -80,7 +81,7 @@ export function printBanner(log) {
80
81
  return;
81
82
  }
82
83
  for (const row of LOGO_ROWS)
83
- console.log(style.red(row.slice(0, 6)) + style.bold(row.slice(6)));
84
+ console.log(style.brand(row.slice(0, 6)) + style.bold(row.slice(6)));
84
85
  console.log(style.dim(`n8n workflows ⇄ agentic code · v${version}`));
85
86
  }
86
87
  /**