n8n-decanter 0.4.1 → 0.5.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,130 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-07-21
11
+
12
+ ### Changed
13
+
14
+ - **Breaking: verb-first grammar.** The verb now comes first —
15
+ `n8n-decanter <verb> [workflow…]`. Verb-last (`n8n-decanter wf123 push`) is no
16
+ longer accepted and errors with *unknown verb*. Because everything after the
17
+ verb is an argument, a workflow named like a verb needs no special handling:
18
+ `n8n-decanter status push` runs `status` on the workflow named `push`. Flags
19
+ may still appear in any position.
20
+ - **Breaking: node operations moved under a `node` namespace.** `add` →
21
+ `node create <workflow> "<Node name>"`, the two-name node rename →
22
+ `node rename <workflow> "<old node>" "<new node>"`, and `run <node-file>` →
23
+ `node run <node-file>`.
24
+ - New workflow folders are **kebab-case** (`Order Sync` → `workflows/order-sync/`)
25
+ instead of keeping spaces and capitals. **Existing folders are left untouched**
26
+ and still resolve as refs — no migration, no churn.
27
+ - A workflow folder **no longer follows a remote rename**. The folder is a stable
28
+ local slug; the always-current display name lives in `.decanter.json` (see
29
+ Added). Renaming a workflow (locally or on the server) never moves your folder.
30
+
31
+ ### Added
32
+
33
+ - **`data-tables` verb** — a read-only fetch of n8n **data-table** schemas and
34
+ rows (the built-in project-scoped tables, n8n ≥ 2.x) into a top-level,
35
+ gitignored `data-tables/<table>/{meta,columns,rows}.json` dir, for developing
36
+ and debugging against real table contents offline. `--filter '<json>'`,
37
+ `--search`, and `--sort` pull only a slice of a large table server-side (the
38
+ applied filter is recorded in each table's `meta.json`); `--limit`/`--all`
39
+ control page size and exhaustion. It never writes a data table.
40
+ `data-tables clean` removes the dir (offline). Gated by the new **`dataTables`**
41
+ config key (default `true`); when off, the fetch refuses and the recommended
42
+ key needn't carry the data-table read scopes (`dataTable:list`,
43
+ `dataTable:read`, `dataTableColumn:read`, `dataTableRow:read`).
44
+ - `.decanter.json` now caches the workflow's display **`name`** (refreshed on
45
+ every pull), so the picker, `list`, and ref-resolution show the real name even
46
+ though the folder is a kebab slug — and keep working if `workflow.json` is
47
+ missing or corrupt.
48
+ - **`list --json`** emits `[{ name, id, dir }]` for tooling (remote-only
49
+ workflows under `--remote` have `dir: null`).
50
+ - **No-ref → picker.** A ref-taking verb given no workflow, on a terminal, opens
51
+ the interactive picker to choose one and runs the verb on it. Piped/non-TTY
52
+ runs keep the config-default / error behavior, so scripts and CI never block.
53
+ - **`simulate` now replays single-iteration loops.** A workflow whose only
54
+ repeated node is a `splitInBatches` ("Loop Over Items") driver that ran a
55
+ single batch — it runs twice (one batch pass + the final "done" pass) while
56
+ every other node ran once — no longer hard-errors. The loop driver executes
57
+ for real to reproduce the loop, and each node's one captured run pins exactly.
58
+ Multi-batch loops (any node ran more than once) stay out of scope, since
59
+ first-run-only pinning can't feed later iterations.
60
+
61
+ ### Removed
62
+
63
+ - **Breaking: `rename --workflow` flag.** Workflow rename is now the single
64
+ top-level form `rename <workflow> "<new name>"`; node rename lives under
65
+ `node rename`.
66
+
67
+ ## [0.4.5] - 2026-07-21
68
+
69
+ ### Added
70
+
71
+ - **`simulate` in the interactive picker.** The verb menu for a pulled workflow
72
+ now offers `simulate` alongside status/pull/push/watch/check/executions; it
73
+ runs against the workflow's newest capture.
74
+ - **Open a simulation run in the n8n webapp.** In an interactive terminal,
75
+ `simulate` prints a **URL** to the run in a kept-alive local n8n (plus the
76
+ throwaway instance's login) — pure nodes' real output and the pinned nodes,
77
+ node-by-node in the actual execution inspector. No flag, no extra step; a
78
+ fresh viewer replaces the previous one each run (`docker rm -f
79
+ decanter-sim-viewer` to stop it). Scripts, `--json`, and `--network-none`
80
+ runs stay headless and print no URL, so CI is unaffected.
81
+
82
+ ### Changed
83
+
84
+ - **`simulate` no longer requires `--execution`.** With the flag omitted it
85
+ defaults to the **newest capture** in the workflow's `executions/` dir, so
86
+ `n8n-decanter <ref> simulate` works right after an `executions` fetch (and
87
+ lets the picker offer it). Pass `--execution <id>` to pick a specific one.
88
+
89
+ ## [0.4.4] - 2026-07-21
90
+
91
+ ### Changed
92
+
93
+ - **The generated `.claude/settings.local.json` pre-approves more safe verbs.**
94
+ `rename`, `list`, `executions` (incl. `executions clean`), `completion`, and
95
+ `help` — plus a bare `status` — now run without a permission prompt, matching
96
+ the "offline, safe" and "reads remote, no writes" tiers documented in the
97
+ sync-dir `AGENTS.md`. Mutating/destructive verbs (`push`, `pull`, `watch`,
98
+ `publish`, `unpublish`, `create`, `duplicate`, `simulate`, `delete`) still
99
+ prompt, and `delete --force` is now hard-denied alongside `push --force`.
100
+
101
+ ## [0.4.3] - 2026-07-21
102
+
103
+ ### Changed
104
+
105
+ - **`$('Node').item` in the type shim (`n8n-globals.d.ts`) is no longer typed
106
+ `| undefined`.** Accessing `$('Node').item.json` no longer raises a spurious
107
+ "Object is possibly 'undefined'" (TS2532) — the value is non-undefined, like
108
+ `$input.item`, since a missing paired item throws at runtime rather than
109
+ yielding `undefined`. Use `itemMatching(i)`, `first()`, or `last()` when you
110
+ want an index-checked lookup instead.
111
+
112
+ ## [0.4.2] - 2026-07-20
113
+
114
+ ### Added
115
+
116
+ - **`simulate` verb** — `n8n-decanter <ref> simulate --execution <id>` replays
117
+ a whole workflow through a **real n8n engine** (Docker) using a captured
118
+ execution as the mock: side-effect-free nodes (Set, IF, Code, …) execute for
119
+ real, every network/side-effectful node is pinned to its captured output,
120
+ credentials are stripped, and no outbound-capable node survives — a dry,
121
+ engine-true regression check. It diffs each executed node's output against the
122
+ capture and **exits `1` on divergence** (CI-gateable). `--network-none` adds
123
+ an enforced outbound cutoff; `--json` emits the report for tooling.
124
+ - **`simulate --pin <id>`** — copy a capture's network-node outputs into
125
+ committed, provenance-stamped `workflows/<Name>/fixtures/<node>.json`, making
126
+ replays reproducible and committable (prints a PII-review warning).
127
+ - **`n8nVersion` config field** (`decanter.config.json`) — pins the n8n version
128
+ the `simulate` engine runs, so "engine-true" matches your instance;
129
+ `--n8n-version <tag>` overrides it per run. Defaults to the project's pinned
130
+ version with a hint when unset.
131
+ - **`npm run test:sim`** — opt-in engine simulation suite (needs Docker; never
132
+ part of `npm test`); skips cleanly when no Docker daemon is available.
133
+
10
134
  ## [0.4.1] - 2026-07-20
11
135
 
12
136
  ### Changed
package/README.md CHANGED
@@ -26,14 +26,19 @@ editable in your IDE or by your agent, and pushed back through the n8n API.
26
26
  - **TypeScript or typed JS** — write nodes in TS (compiled on push); n8n
27
27
  globals (`$input`, `$('…')`, …) are typed in both.
28
28
  - **Agent-native** — `init` scaffolds Claude Code / Cursor / Codex configs
29
- and verification hooks; offline `check` and `run` give agents a
29
+ and verification hooks; offline `check` and `node run` give agents a
30
30
  credential-free feedback loop.
31
31
  - **Guardrails** — a compliance guard and typecheck gate block broken
32
32
  pushes; a drift guard keeps you from clobbering remote edits.
33
33
  - **Real execution data on tap** — `executions` fetches recent run JSON into
34
34
  a gitignored temp dir, so agents see actual payload shapes (and build
35
- accurate `run` fixtures) instead of guessing; `executions clean` removes
35
+ accurate `node run` fixtures) instead of guessing; `executions clean` removes
36
36
  it when done.
37
+ - **Engine-true simulation** — `simulate` replays a whole workflow through a
38
+ real n8n engine using a captured execution as the mock: pure nodes run for
39
+ real, network nodes pinned, credentials stripped. It diffs each node against
40
+ the capture (exits 1 on divergence — a CI-gateable regression check) and, in
41
+ a terminal, prints a URL to open the run in the n8n webapp.
37
42
  - **Live editing** — `watch` pushes on save and auto-reloads the n8n editor
38
43
  tab via a local proxy.
39
44
  - **Shared code and small libraries** — `.ts` nodes import helpers/types
@@ -90,6 +95,8 @@ so a leaked `.env` has a small blast radius:
90
95
  - `workflow:create`, `workflow:delete` — `create` / `delete`
91
96
  - `workflow:activate`, `workflow:deactivate` — `publish` / `unpublish`
92
97
  - `execution:read`, `execution:list` — the `executions` verb
98
+ - `dataTable:list`, `dataTable:read`, `dataTableColumn:read`, `dataTableRow:read`
99
+ — the `data-tables` verb (only needed while `"dataTables"` is on, its default)
93
100
 
94
101
  A full-access key also works if your n8n version predates scoped keys.
95
102
 
@@ -105,7 +112,7 @@ PostToolUse hook that runs `check` after node edits. Verification routes through
105
112
  the CLI, so `n8n-decanter` must be on the sync dir's PATH: install it globally
106
113
  (`npm i -g n8n-decanter`), add it to the sync dir's `devDependencies`, or
107
114
  `npm link` a git checkout (build it first — Node won't type-strip `.mts`
108
- under `node_modules`). The verbs `check`, `run`, `rename`, and `add` are fully
115
+ under `node_modules`). The verbs `check`, `node run`, `rename`, and `node create` are fully
109
116
  offline (no credentials, no network).
110
117
 
111
118
  ## Commands
@@ -117,45 +124,65 @@ n8n-decanter # interactive picker (terminal, inited
117
124
  # then run verbs (unpulled: Enter pulls);
118
125
  # stays in the workflow's menu between
119
126
  # verbs, Esc backs out, Esc Esc quits
127
+ # Setup
120
128
  n8n-decanter init [dir] # interactive bootstrap (see Setup)
121
- n8n-decanter [ref...] pull # remote -> workflows/<Name>/
122
- n8n-decanter [ref...] push [--force] [--no-typecheck]
123
- n8n-decanter [ref...] status [--diff] # drift report (--diff: line diffs);
124
- # exits 1 on conflict/remote drift
125
- n8n-decanter [ref...] check # offline layout-compliance + typecheck
126
- n8n-decanter <ref> rename "<old node>" "<new node>" # rename a node everywhere
127
- n8n-decanter <ref> rename --workflow "<new name>" # rename the workflow
128
- n8n-decanter <ref> add "<Node name>" [--ts] # scaffold a Code node (offline)
129
- n8n-decanter [ref] watch # push a workflow's nodes on save
129
+ n8n-decanter completion zsh|bash # print a shell completion script
130
+
131
+ # Sync
132
+ n8n-decanter pull [workflow…] # remote -> workflows/<kebab>/
133
+ n8n-decanter push [workflow…] [--force] [--no-typecheck]
134
+ n8n-decanter watch [workflow] # push a workflow's nodes on save
130
135
  # (+ browser live-reload, opt-in)
131
- n8n-decanter [ref...] publish # take the draft(s) live
132
- n8n-decanter [ref...] unpublish # (unpublish returns to draft-only)
133
- n8n-decanter create "<name>" # create a blank workflow, then pull it
134
- n8n-decanter <ref> duplicate ["<new name>"] # clone a workflow, then pull it
135
- n8n-decanter <ref> delete [--force] # delete a workflow from the server
136
- # (y/N confirm; --force skips it; the
137
- # local folder is left untouched)
138
- n8n-decanter [ref...] executions [--status=success|error|waiting] [--limit=N]
136
+ n8n-decanter publish [workflow…] # take the draft(s) live
137
+ n8n-decanter unpublish [workflow…] # (unpublish returns to draft-only)
138
+
139
+ # Workflow lifecycle
140
+ n8n-decanter create "<name>" # create a blank workflow, then pull it
141
+ n8n-decanter duplicate <workflow> ["<name>"] # clone a workflow, then pull it
142
+ n8n-decanter delete <workflow> [--force] # delete from the server (folder kept)
143
+ n8n-decanter rename <workflow> "<new name>" # rename the workflow (offline; folder stays)
144
+
145
+ # Inspect & test
146
+ n8n-decanter status [workflow…] [--diff] # drift report (--diff: line diffs);
147
+ # exits 1 on conflict/remote drift
148
+ n8n-decanter check [workflow…] # offline layout-compliance + typecheck
149
+ n8n-decanter executions [workflow…] [--status=success|error|waiting] [--limit=N]
139
150
  # fetch recent execution data (run JSON)
140
- # into workflows/<Name>/executions/
141
- # (gitignored temp files; a numeric arg
142
- # fetches that one execution by id)
143
- n8n-decanter [ref...] executions clean # delete fetched execution data (offline)
144
- n8n-decanter list [--remote] # pulled workflows: name, id, folder
145
- # (--remote adds unpulled ones)
146
- n8n-decanter completion zsh|bash # print a shell completion script
147
- n8n-decanter <node-file> run [fixture.json] [--allow-env] # run a node offline, print items
151
+ # into workflows/<folder>/executions/
152
+ # (gitignored; numeric arg = one by id)
153
+ n8n-decanter executions [workflow…] clean # delete fetched execution data (offline)
154
+ n8n-decanter data-tables [table…] [--filter='<json>'] [--search=…] [--sort=col:asc|desc] [--limit=N] [--all]
155
+ # fetch data-table schema + rows (read-only)
156
+ # into a gitignored top-level data-tables/;
157
+ # filter/search/sort pull a slice server-side
158
+ n8n-decanter data-tables [table…] clean # delete fetched data-table data (offline)
159
+ n8n-decanter simulate <workflow> [--execution <execution-id>] [--network-none] [--json]
160
+ # replay the whole workflow through a real
161
+ # n8n engine (Docker): pure nodes run for
162
+ # real, network nodes pinned from a capture,
163
+ # credentials stripped; diffs each node vs
164
+ # the capture, exits 1 on divergence. In a
165
+ # terminal, prints a URL to open the run in
166
+ # n8n. Defaults to the newest capture.
167
+ n8n-decanter simulate <workflow> --pin <execution-id> # save a capture's outputs as fixtures/
168
+ n8n-decanter list [--remote] [--json] # pulled workflows: name, id, folder
169
+ # (--remote adds unpulled ones; --json for tooling)
170
+
171
+ # Node
172
+ n8n-decanter node create <workflow> "<Node name>" [--ts] # scaffold a Code node (offline)
173
+ n8n-decanter node rename <workflow> "<old node>" "<new node>" # rename a node everywhere (offline)
174
+ n8n-decanter node run <node-file> [fixture.json] [--allow-env] # run a node offline, print items
148
175
  ```
149
176
 
150
- A workflow `<ref>` is its **id, its workflow/folder name, or a unique name
151
- prefix** — `n8n-decanter "Order Sync" push` and `n8n-decanter order push` both
177
+ A `<workflow>` is its **id, its workflow/folder name, or a unique name
178
+ prefix** — `n8n-decanter push "Order Sync"` and `n8n-decanter push order` both
152
179
  work. Matching is case-insensitive and never prompts: an ambiguous or unknown
153
180
  name errors with the candidate list. `pull` resolves not-yet-pulled names
154
- against the server's workflow list; a workflow literally named like a verb
155
- must be addressed by id. Without refs, all workflows from the config are
156
- processed. The verb may sit anywhere among the arguments (`push wf123` ==
157
- `wf123 push`) — the first token matching a known verb is the command; flags
158
- may appear in any position too.
181
+ against the server's workflow list. Without a workflow argument, all workflows
182
+ from the config are processed (or, on a terminal, the picker opens). **The verb
183
+ comes first** (`n8n-decanter push wf123`); everything after it is an argument,
184
+ so a workflow named like a verb needs no special rule and verb-last errors with
185
+ *unknown verb*. Flags may still appear in any position.
159
186
 
160
187
  Output is styled (color, `✓`/`!`/`✗` glyphs, progress) **only when writing to
161
188
  a terminal** and respects `NO_COLOR`/`FORCE_COLOR`; piped or redirected output
@@ -171,25 +198,32 @@ exit 0 — scripts and CI can gate on it like on `check`. `status --diff`
171
198
  shows the actual line diff under each drifted node, so you see what a push
172
199
  would overwrite before running it. API requests time out after 30 s (set
173
200
  `"requestTimeoutMs"` in `decanter.config.json` for slow instances), and
174
- `DEBUG=1` prints full stack traces on errors. `run` fakes the full Code-node
201
+ `DEBUG=1` prints full stack traces on errors. `node run` fakes the full Code-node
175
202
  context including `$getWorkflowStaticData`, seeded from `workflow.json` and
176
- overridable per fixture. Like n8n's own scoped `$env`, `run`'s `$env` is
203
+ overridable per fixture. Like n8n's own scoped `$env`, `node run`'s `$env` is
177
204
  **empty by default** — set it explicitly with the fixture's `"env"` field, or
178
205
  pass `--allow-env` to inherit the CLI process's environment (which may include
179
206
  `N8N_API_KEY` and other secrets), so a node that prints `$env` never leaks the
180
207
  host environment by accident.
181
208
 
182
209
  `executions` is read-only against the API and writes each execution as
183
- `workflows/<Name>/executions/<execId>.json` — real run data (the items every
184
- node produced), meant as temporary reference for building `run` fixtures.
210
+ `workflows/<folder>/executions/<execution-id>.json` — real run data (the items every
211
+ node produced), meant as temporary reference for building `node run` fixtures.
185
212
  The dir is written self-gitignored (run data can contain credentials/PII and
186
213
  must never land in git); executions reflect the *published* workflow version
187
214
  (n8n 2.x), so treat them as convenience data, not ground truth, and remove
188
215
  them with `executions clean` when done.
189
216
 
190
- <!--
191
- "How it compares" is temporarily hidden pending the n8n-as-code maintainer's
192
- sign-off on the comparison scorings (asked 2026-07-20). Uncomment to restore.
217
+ `data-tables` is the data-table analogue: also read-only against the API, it
218
+ fetches each n8n data table's schema (`columns.json`) and rows (`rows.json`)
219
+ into a **top-level, gitignored** `data-tables/<table>/` dir (data tables are
220
+ project-scoped, not owned by a workflow) so you can develop and debug against
221
+ real table contents. `--filter='<json>'`, `--search`, and `--sort` pull only a
222
+ slice of a large table server-side (the applied filter is recorded in each
223
+ table's `meta.json`); `--limit`/`--all` control page size and exhaustion. It
224
+ never writes a data table. It's gated by the `"dataTables"` config key
225
+ (default on); `data-tables clean` removes the dir (offline). Data tables need
226
+ n8n ≥ 2.x.
193
227
 
194
228
  ## How it compares
195
229
 
@@ -199,6 +233,16 @@ how that focus stacks up against the native editor and against
199
233
  [n8n-as-code](https://github.com/EtienneLescot/n8n-as-code) — a broader,
200
234
  whole-workflow authoring toolkit.
201
235
 
236
+ > **Choose [n8n-as-code](https://github.com/EtienneLescot/n8n-as-code) if you…**
237
+ > need an enterprise-ready automation framework — where AI agents assemble
238
+ > complete workflows, multi-environment Dev → Prod pipelines automatically handle
239
+ > credential governance, and full TypeScript GitOps ensures strict auditability
240
+ > across your team.
241
+ >
242
+ > **Choose n8n-decanter if you…** focus on developer experience inside the Code
243
+ > node — enabling typed TypeScript, shared utility libraries, and live
244
+ > hot-reloading DX directly between VS Code and your n8n canvas (even on Cloud).
245
+
202
246
  | Capability | Native n8n (browser) | n8n-as-code | n8n-decanter |
203
247
  |---|---|---|---|
204
248
  | **TypeScript for Code nodes** | ❌ JavaScript or Python only | ❌ TS is at workflow level, not node logic | ✅ Code nodes as `.ts`, compiled on push, typed n8n globals |
@@ -206,10 +250,10 @@ whole-workflow authoring toolkit.
206
250
  | **Code as individual files** | ❌ no source files (JSON blob) | 🟡 one `.workflow.ts` per workflow | ✅ folder per workflow; each Code node its own `.js`/`.ts` |
207
251
  | **Versioning** | 🟡 in-app history (DB snapshots, tiered retention); Git source control is Enterprise-only | ✅ GitOps sync of workflow source | ✅ real git — diffs, PRs, blame; auto-commit each push/pull |
208
252
  | **Live editing** | ✅ the canvas (baseline) | 🟡 explicit pull/push, no auto-watch | ✅ `watch`: push on save + auto-reload the editor tab |
209
- | **Offline testing on historic executions** | 🟡 re-run past executions / pin data, but online in-editor | 🟡 inspect executions against a live env | ✅ fetch real run JSON → offline `run` fixtures, no creds/network |
210
- | **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 + MCP; offline `check`/`run` loop; guardrails |
253
+ | **Offline testing on historic executions** | 🟡 re-run past executions / pin data, but online in-editor | 🟡 inspect executions against a live env | ✅ fetch real run JSON → offline `node run` fixtures, no creds/network |
254
+ | **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 + MCP; offline `check`/`node run` loop; guardrails |
211
255
  | **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 |
212
- | **Agentic workflow creation** | 🟡 AI Workflow Builder (natural language), but Cloud / plan-gated — credits, self-host needs setup | ✅ 537 node schemas + 7,700+ templates + skills | 🟡 today via scaffolded n8n-mcp; first-party repo-authored creation planned (`add` + `push --create`) |
256
+ | **Agentic workflow creation** | 🟡 AI Workflow Builder (natural language), but Cloud / plan-gated — credits, self-host needs setup | ✅ 537 node schemas + 7,700+ templates + skills | 🟡 today via scaffolded n8n-mcp; first-party repo-authored creation planned (`node create` + `push --create`) |
213
257
  | **Whole-workflow TypeScript authoring** | ❌ | ✅ `.workflow.ts` decorator classes (structure + links) | ❌ keeps `workflow.json`; extracts Code-node source only |
214
258
  | **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) |
215
259
 
@@ -221,7 +265,6 @@ n8n-as-code shines for whole-workflow authoring/generation and multi-environment
221
265
  ops; the native editor stays the live visual canvas everything syncs back to. And
222
266
  decanter makes no LLM calls of its own — you drive it with the coding agent you
223
267
  already run, so Claude Code on a Claude subscription needs no extra API tokens.
224
- -->
225
268
 
226
269
  ## Caveats
227
270
 
package/dist/lib/api.mjs CHANGED
@@ -45,6 +45,49 @@ export class N8nApi {
45
45
  async getExecution(id) {
46
46
  return this.#request("GET", `/api/v1/executions/${encodeURIComponent(id)}?includeData=true`);
47
47
  }
48
+ /**
49
+ * All data tables (n8n ≥ 2.x built-in project tables), cursor-paginated like
50
+ * `listWorkflows`. Read-only by design — the data-table API is never written
51
+ * through (see lib/datatables.mts). On pre-2.x instances the endpoint 404s;
52
+ * the caller surfaces that as a friendly hint.
53
+ */
54
+ async listDataTables() {
55
+ const all = [];
56
+ let cursor;
57
+ do {
58
+ const query = new URLSearchParams({ limit: "100", ...(cursor !== undefined && { cursor }) });
59
+ const page = (await this.#request("GET", `/api/v1/data-tables?${query}`));
60
+ all.push(...page.data);
61
+ cursor = page.nextCursor ?? undefined;
62
+ } while (cursor !== undefined);
63
+ return all;
64
+ }
65
+ /**
66
+ * A data table's columns (its schema). Tolerates both a bare array and a
67
+ * `{ data: [...] }` envelope so a shape difference between n8n versions
68
+ * doesn't break the fetch. Read-only.
69
+ */
70
+ async getDataTableColumns(id) {
71
+ const res = (await this.#request("GET", `/api/v1/data-tables/${encodeURIComponent(id)}/columns`));
72
+ return Array.isArray(res) ? res : res.data;
73
+ }
74
+ /**
75
+ * One page of a data table's rows. The server-side `filter` (a JSON string of
76
+ * conditions), `search` (free text over string columns), and `sortBy`
77
+ * (`col:asc|desc`) narrow the result so callers pull only the rows they need
78
+ * from a potentially huge table; `limit` (default 100, API cap 250) + `cursor`
79
+ * paginate. Returns the page plus `nextCursor`. Read-only.
80
+ */
81
+ async getDataTableRows(id, { limit = 100, cursor, filter, search, sortBy } = {}) {
82
+ const query = new URLSearchParams({
83
+ limit: String(limit),
84
+ ...(cursor !== undefined && { cursor }),
85
+ ...(filter !== undefined && { filter }),
86
+ ...(search !== undefined && { search }),
87
+ ...(sortBy !== undefined && { sortBy }),
88
+ });
89
+ return (await this.#request("GET", `/api/v1/data-tables/${encodeURIComponent(id)}/rows?${query}`));
90
+ }
48
91
  async updateWorkflow(id, body) {
49
92
  return this.#request("PUT", `/api/v1/workflows/${encodeURIComponent(id)}`, body);
50
93
  }
@@ -49,6 +49,8 @@ export function loadConfig(cwd = process.cwd(), { requireCredentials = true } =
49
49
  browserReload: cfg.browserReload === "proxy" ? "proxy" : "off",
50
50
  proxyPort: typeof cfg.proxyPort === "number" ? cfg.proxyPort : 5679,
51
51
  requestTimeoutMs: typeof cfg.requestTimeoutMs === "number" && cfg.requestTimeoutMs > 0 ? cfg.requestTimeoutMs : 30_000,
52
+ n8nVersion: typeof cfg.n8nVersion === "string" && cfg.n8nVersion !== "" ? cfg.n8nVersion : undefined,
53
+ dataTables: cfg.dataTables !== false,
52
54
  host,
53
55
  apiKey,
54
56
  };
@@ -0,0 +1,124 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { kebabCase } from "./util.mjs";
4
+ /** Top-level dir (next to decanter.config.json) holding fetched data tables. */
5
+ export const DATA_TABLES_DIR = "data-tables";
6
+ /** Default rows fetched per page — the API caps a page at 250. */
7
+ export const DEFAULT_ROW_LIMIT = 100;
8
+ /**
9
+ * Folder slug for a fetched table: the kebab of its name with the id appended
10
+ * (data-table names aren't guaranteed unique, so the id disambiguates). The id
11
+ * is stripped of anything non-alphanumeric to stay filesystem-safe.
12
+ */
13
+ export function dataTableSlug(id, name) {
14
+ const safeId = String(id).replace(/[^A-Za-z0-9]+/g, "") || "id";
15
+ return `${kebabCase(name)}-${safeId}`;
16
+ }
17
+ /** Pick the tables named by `tableRefs` (id or exact name); empty = all. */
18
+ function selectTables(tables, tableRefs) {
19
+ if (tableRefs.length === 0)
20
+ return tables;
21
+ const picked = new Map();
22
+ for (const ref of tableRefs) {
23
+ const lc = ref.toLowerCase();
24
+ const hits = tables.filter((t) => String(t.id) === ref || t.name.toLowerCase() === lc);
25
+ if (hits.length === 0) {
26
+ const known = tables.map((t) => `"${t.name}" (${t.id})`).join(", ");
27
+ throw new Error(`no data table matches "${ref}"${known ? ` — tables: ${known}` : " — no data tables on the server"}`);
28
+ }
29
+ for (const t of hits)
30
+ picked.set(String(t.id), t);
31
+ }
32
+ return [...picked.values()];
33
+ }
34
+ /** Human summary fragment naming the applied server-side filter, or "". */
35
+ function filterSummary(opts) {
36
+ const parts = [];
37
+ if (opts.filter !== undefined)
38
+ parts.push(`filter ${opts.filter}`);
39
+ if (opts.search !== undefined)
40
+ parts.push(`search "${opts.search}"`);
41
+ if (opts.sortBy !== undefined)
42
+ parts.push(`sort ${opts.sortBy}`);
43
+ return parts.length > 0 ? ` [${parts.join(", ")}]` : "";
44
+ }
45
+ /**
46
+ * Fetch data-table schemas + (filtered) rows into a single top-level,
47
+ * self-ignored `data-tables/` dir — mirrors `executions` (read-only, temp,
48
+ * gitignored). Data tables are project-scoped (not owned by a workflow), so
49
+ * they land next to `decanter.config.json`, not under a workflow folder. Each
50
+ * table becomes `data-tables/<slug>/{meta,columns,rows}.json`; `meta.json`
51
+ * records the applied filter/search/sort/limit and the row count so a filtered
52
+ * `rows.json` is self-describing. The CLI never writes any data-table endpoint.
53
+ */
54
+ export async function fetchDataTables(api, configDir, opts, log) {
55
+ let tables;
56
+ try {
57
+ tables = await api.listDataTables();
58
+ }
59
+ catch (err) {
60
+ const msg = err.message;
61
+ if (/\b404\b/.test(msg)) {
62
+ throw new Error(`the n8n data-tables API is not available (404) — data tables need n8n ≥ 2.x; a full-access key still works if your instance predates them\n${msg}`);
63
+ }
64
+ throw err;
65
+ }
66
+ const selected = selectTables(tables, opts.tableRefs ?? []);
67
+ if (selected.length === 0) {
68
+ log.info("no data tables on the server");
69
+ return;
70
+ }
71
+ const outRoot = path.join(configDir, DATA_TABLES_DIR);
72
+ mkdirSync(outRoot, { recursive: true });
73
+ // Self-ignoring dir: data tables can hold PII — same reasoning as executions/.
74
+ writeFileSync(path.join(outRoot, ".gitignore"), "*\n");
75
+ const rel = (p) => path.relative(process.cwd(), p);
76
+ const filterDesc = filterSummary(opts);
77
+ for (const table of selected) {
78
+ const dir = path.join(outRoot, dataTableSlug(table.id, table.name));
79
+ mkdirSync(dir, { recursive: true });
80
+ const columns = await api.getDataTableColumns(String(table.id));
81
+ const rows = [];
82
+ let cursor;
83
+ do {
84
+ const page = await api.getDataTableRows(String(table.id), {
85
+ limit: opts.limit ?? DEFAULT_ROW_LIMIT,
86
+ cursor,
87
+ filter: opts.filter,
88
+ search: opts.search,
89
+ sortBy: opts.sortBy,
90
+ });
91
+ rows.push(...page.data);
92
+ cursor = opts.all ? (page.nextCursor ?? undefined) : undefined;
93
+ } while (cursor !== undefined);
94
+ const meta = {
95
+ id: table.id,
96
+ name: table.name,
97
+ projectId: table.projectId ?? null,
98
+ fetchedAt: new Date().toISOString(),
99
+ rowCount: rows.length,
100
+ // A filtered rows.json is a slice, never the whole table — record what
101
+ // produced it so it can't be mistaken for a full export.
102
+ filter: opts.filter ?? null,
103
+ search: opts.search ?? null,
104
+ sortBy: opts.sortBy ?? null,
105
+ limit: opts.limit ?? DEFAULT_ROW_LIMIT,
106
+ all: Boolean(opts.all),
107
+ };
108
+ writeFileSync(path.join(dir, "meta.json"), JSON.stringify(meta, null, 2) + "\n");
109
+ writeFileSync(path.join(dir, "columns.json"), JSON.stringify(columns, null, 2) + "\n");
110
+ writeFileSync(path.join(dir, "rows.json"), JSON.stringify(rows, null, 2) + "\n");
111
+ log.info(`wrote ${rel(dir)}/{meta,columns,rows}.json (${columns.length} column${columns.length === 1 ? "" : "s"}, ${rows.length} row${rows.length === 1 ? "" : "s"})`);
112
+ }
113
+ log.ok(`${selected.length} data table${selected.length === 1 ? "" : "s"}${filterDesc} -> ${rel(outRoot)} (gitignored — temp data, "data-tables clean" removes it)`);
114
+ }
115
+ /** Offline delete of the local `data-tables/` dir (mirrors cleanExecutions). */
116
+ export function cleanDataTables(configDir, log) {
117
+ const outRoot = path.join(configDir, DATA_TABLES_DIR);
118
+ if (!existsSync(outRoot)) {
119
+ log.info("no data-tables/ dir to clean");
120
+ return;
121
+ }
122
+ rmSync(outRoot, { recursive: true, force: true });
123
+ log.ok(`removed ${path.relative(process.cwd(), outRoot)}`);
124
+ }