n8n-decanter 0.3.0 → 0.4.1
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 +116 -0
- package/README.md +37 -9
- package/dist/lib/add.mjs +91 -0
- package/dist/lib/api.mjs +27 -0
- package/dist/lib/executions.mjs +33 -1
- package/dist/lib/init.mjs +142 -21
- package/dist/lib/lifecycle.mjs +123 -0
- package/dist/lib/picker.mjs +47 -26
- package/dist/lib/pull.mjs +8 -4
- package/dist/lib/push.mjs +1 -1
- package/dist/lib/status.mjs +8 -2
- package/dist/lib/template.mjs +45 -0
- package/dist/lib/util.mjs +15 -0
- package/dist/lib/watch.mjs +10 -3
- package/dist/n8n-decanter.mjs +78 -19
- package/dist/scripts/typecheck.mjs +19 -1
- package/package.json +3 -2
- package/template/.claude/settings.local.json.example +1 -1
- package/template/.env.example +4 -2
- package/template/AGENTS.md.example +72 -29
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,122 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1] - 2026-07-20
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Refreshed the scaffolded agent guide (`AGENTS.md`).** It now steers agents
|
|
15
|
+
to the `rename` and `duplicate` verbs (rename led with the command instead of
|
|
16
|
+
a hand-edit checklist, `duplicate` added to the new-workflow and command
|
|
17
|
+
taxonomies), opens with a compact "short version" of the hard invariants,
|
|
18
|
+
points at `n8n-globals.d.ts` as the authoritative globals list instead of an
|
|
19
|
+
inline copy that could drift, and drops a stale reference to a non-existent
|
|
20
|
+
`SCAFFOLD.md`.
|
|
21
|
+
|
|
22
|
+
## [0.4.0] - 2026-07-20
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`add` verb** — `n8n-decanter <ref> add "<Node name>" [--ts]` scaffolds a
|
|
27
|
+
Code node into a pulled workflow in one offline step: it mints the node id,
|
|
28
|
+
writes the `code/` source file (kebab-case, with the `-<id8>` collision
|
|
29
|
+
suffix), adds the node object plus its `//@file:` placeholder, and registers
|
|
30
|
+
it in `.decanter.json`, then re-checks the folder. The node lands
|
|
31
|
+
**disconnected** (wire it in the editor); `--ts` scaffolds a `.ts` source.
|
|
32
|
+
The next `push` propagates it.
|
|
33
|
+
- **`duplicate` verb** — `n8n-decanter <ref> duplicate ["<new name>"]` clones an
|
|
34
|
+
already-pulled workflow into a **new workflow on the server** and pulls the
|
|
35
|
+
copy. The clone carries the repo's current content (placeholders
|
|
36
|
+
reconstituted from `code/`, `.ts` nodes compiled), is born **unpublished**,
|
|
37
|
+
and defaults its name to `"<name> (copy)"`. The source folder and the source
|
|
38
|
+
remote workflow are left untouched.
|
|
39
|
+
|
|
40
|
+
### Removed
|
|
41
|
+
|
|
42
|
+
- **Breaking: the `uuid` verb is gone.** Its only job was minting a node id for
|
|
43
|
+
hand-adding a Code node — now `add` does the whole scaffold (id included) in
|
|
44
|
+
one guard-checked step, so a bare id generator is redundant. Use
|
|
45
|
+
`n8n-decanter <ref> add "<Node name>"` instead.
|
|
46
|
+
|
|
47
|
+
## [0.3.4] - 2026-07-20
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **Modification-aware template refresh.** `init` now records a copy-time
|
|
52
|
+
baseline of every template file in a git-tracked `.decanter-template.json`
|
|
53
|
+
manifest. Re-running `init` uses it to refresh files you haven't touched
|
|
54
|
+
(after a confirm), pull in files newly added to the template, and **leave
|
|
55
|
+
your local edits alone** — reporting them as drift instead of silently
|
|
56
|
+
keeping the old version. Files that changed in both the template and your
|
|
57
|
+
copy are flagged as conflicts and left untouched.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **Re-running `init` is no longer all-or-nothing.** Previously the default
|
|
62
|
+
refused to overwrite anything and `--force` clobbered every template file.
|
|
63
|
+
Now the default is modification-aware (see above); `--force` is unchanged —
|
|
64
|
+
the escape hatch that overwrites everything, now noting which files "had
|
|
65
|
+
local changes" as it goes.
|
|
66
|
+
|
|
67
|
+
## [0.3.3] - 2026-07-20
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- **Interactive picker got a visual refresh.** Each workflow row now leads with
|
|
72
|
+
a `●` (pulled) / `○` (not pulled) status glyph and the ids line up in an
|
|
73
|
+
aligned column; each stage carries a short title (`pick a workflow` over the
|
|
74
|
+
list, the workflow name over its verb menu). The state distinction is now
|
|
75
|
+
carried by the glyph *shape*, so the per-row `(not pulled)` words are gone —
|
|
76
|
+
the key is stated once in a footer legend (`● pulled · ○ not pulled`), and
|
|
77
|
+
the output stays legible under `NO_COLOR`. Behavior (filtering, navigation,
|
|
78
|
+
verbs) is unchanged.
|
|
79
|
+
|
|
80
|
+
## [0.3.2] - 2026-07-20
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- **Globally-installed CLI (`npm i -g n8n-decanter`) could crash on
|
|
85
|
+
`push`/`check`/`watch`'s typecheck gate** — it resolved the `typescript`
|
|
86
|
+
package relative to its own install location instead of the sync dir
|
|
87
|
+
being checked, which only ever worked when the CLI happened to be nested
|
|
88
|
+
inside the sync dir's `node_modules` (e.g. a local `devDependency`
|
|
89
|
+
install). A global install is never nested there, so the gate could fail
|
|
90
|
+
to find `typescript` at all. Now resolved relative to the sync dir first,
|
|
91
|
+
falling back to the CLI's own location.
|
|
92
|
+
|
|
93
|
+
## [0.3.1] - 2026-07-20
|
|
94
|
+
|
|
95
|
+
### Added
|
|
96
|
+
|
|
97
|
+
- **`publish` / `unpublish` verbs** close the n8n 2.x workflow lifecycle from
|
|
98
|
+
the CLI: `n8n-decanter <ref> publish` takes a draft live, `unpublish` returns
|
|
99
|
+
it to draft-only. Already-in-that-state is a no-op with a note, not an error.
|
|
100
|
+
A staged rollout is now `unpublish` → `push` → `publish` without leaving the
|
|
101
|
+
terminal.
|
|
102
|
+
- **`create` verb** — `n8n-decanter create "<name>"` creates a blank workflow
|
|
103
|
+
on the server (born unpublished) and immediately pulls it, so the folder and
|
|
104
|
+
the new id are ready to edit → push → `publish`.
|
|
105
|
+
- **`delete` verb** — `n8n-decanter <ref> delete` removes a workflow from the
|
|
106
|
+
server. It asks for a `y/N` confirmation naming the workflow; non-interactive
|
|
107
|
+
runs require `--force`. The **local folder is left untouched** as the
|
|
108
|
+
git-tracked record, and a stale `decanter.config.json` `workflows` entry is
|
|
109
|
+
flagged. Requires a ref (never deletes config workflows by default), one at a
|
|
110
|
+
time.
|
|
111
|
+
|
|
112
|
+
### Changed
|
|
113
|
+
|
|
114
|
+
- **`status` is version-aware.** On a published workflow whose draft has moved
|
|
115
|
+
ahead of the live version (a UI edit not yet published), `status` now says
|
|
116
|
+
the live version is older than the draft (`push` or `publish` to catch it
|
|
117
|
+
up) instead of the plain `published` note.
|
|
118
|
+
- **`executions` warns on stale fixtures.** When a fetched execution ran a
|
|
119
|
+
published version different from your local draft, the fetch now warns that
|
|
120
|
+
the captured data may not match the code you're editing (still written — a
|
|
121
|
+
warning, not an error).
|
|
122
|
+
- The recommended **scoped API key** now includes `workflow:create`,
|
|
123
|
+
`workflow:delete`, `workflow:activate`, and `workflow:deactivate` so the new
|
|
124
|
+
lifecycle verbs work (`README`, `.env.example`).
|
|
125
|
+
|
|
10
126
|
## [0.3.0] - 2026-07-20
|
|
11
127
|
|
|
12
128
|
### Security
|
package/README.md
CHANGED
|
@@ -2,14 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/buttjer/n8n-decanter/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/n8n-decanter)
|
|
5
|
+
[](https://buttjer.github.io/n8n-decanter/)
|
|
5
6
|
[](LICENSE)
|
|
7
|
+
[](https://claude.com/claude-code)
|
|
6
8
|
|
|
7
9
|
**Work on n8n like a codebase — built for AI coding agents.**
|
|
8
10
|
|
|
11
|
+
**Pre-1.0 — breaking changes to the data model or CLI may ship in minor
|
|
12
|
+
versions until v1.0.**
|
|
13
|
+
|
|
14
|
+
> **Built with AI agents.** Much of this codebase was written by Claude Code
|
|
15
|
+
> under human review. It's tested (CI + a real-n8n integration suite) and used
|
|
16
|
+
> in earnest, but treat pre-1.0 the way the version implies.
|
|
17
|
+
|
|
9
18
|
n8n-decanter syncs your n8n instance into a git-friendly, folder-per-workflow
|
|
10
19
|
layout: every Code node's source becomes its own `.js` or `.ts` file,
|
|
11
20
|
editable in your IDE or by your agent, and pushed back through the n8n API.
|
|
12
21
|
|
|
22
|
+

|
|
23
|
+
|
|
13
24
|
- **Real version control** — meaningful diffs, PRs, blame; every push and
|
|
14
25
|
pull is auto-committed.
|
|
15
26
|
- **TypeScript or typed JS** — write nodes in TS (compiled on push); n8n
|
|
@@ -33,7 +44,9 @@ editable in your IDE or by your agent, and pushed back through the n8n API.
|
|
|
33
44
|
behavior that touches the API is verified against a real n8n 2.x instance
|
|
34
45
|
by an automated integration suite.
|
|
35
46
|
|
|
36
|
-

|
|
48
|
+
|
|
49
|
+
📖 **Full documentation: [buttjer.github.io/n8n-decanter](https://buttjer.github.io/n8n-decanter/)**
|
|
37
50
|
|
|
38
51
|
## Setup
|
|
39
52
|
|
|
@@ -56,9 +69,11 @@ directly, no build needed.
|
|
|
56
69
|
|
|
57
70
|
`init` copies everything in [template/](template/); files named `X.example`
|
|
58
71
|
land as `X` (the suffix keeps agent configs inert in this repo, live in the
|
|
59
|
-
target).
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
target). Re-running `init` is safe and **modification-aware**: it refreshes
|
|
73
|
+
template files you haven't edited (after a confirm), leaves your local changes
|
|
74
|
+
alone (reporting them as drift), and picks up files new to the template —
|
|
75
|
+
tracked via a `.decanter-template.json` baseline. `--force` is the escape hatch
|
|
76
|
+
that overwrites everything (`.env` is never touched). When `.env` already holds both values, init skips the
|
|
62
77
|
prompts and reuses them — edit or delete `.env` to change credentials. It
|
|
63
78
|
also does a best-effort credential check. Alternatively set up manually: `cp .env.example .env` and
|
|
64
79
|
fill it in. Then add workflow ids to `decanter.config.json`:
|
|
@@ -72,6 +87,8 @@ limited to the permissions the CLI actually uses rather than a full-access one,
|
|
|
72
87
|
so a leaked `.env` has a small blast radius:
|
|
73
88
|
|
|
74
89
|
- `workflow:read`, `workflow:list`, `workflow:update` — `pull` / `status` / `push`
|
|
90
|
+
- `workflow:create`, `workflow:delete` — `create` / `delete`
|
|
91
|
+
- `workflow:activate`, `workflow:deactivate` — `publish` / `unpublish`
|
|
75
92
|
- `execution:read`, `execution:list` — the `executions` verb
|
|
76
93
|
|
|
77
94
|
A full-access key also works if your n8n version predates scoped keys.
|
|
@@ -88,7 +105,7 @@ PostToolUse hook that runs `check` after node edits. Verification routes through
|
|
|
88
105
|
the CLI, so `n8n-decanter` must be on the sync dir's PATH: install it globally
|
|
89
106
|
(`npm i -g n8n-decanter`), add it to the sync dir's `devDependencies`, or
|
|
90
107
|
`npm link` a git checkout (build it first — Node won't type-strip `.mts`
|
|
91
|
-
under `node_modules`). The verbs `check`, `run`, and `
|
|
108
|
+
under `node_modules`). The verbs `check`, `run`, `rename`, and `add` are fully
|
|
92
109
|
offline (no credentials, no network).
|
|
93
110
|
|
|
94
111
|
## Commands
|
|
@@ -108,8 +125,16 @@ n8n-decanter [ref...] status [--diff] # drift report (--diff: line diffs);
|
|
|
108
125
|
n8n-decanter [ref...] check # offline layout-compliance + typecheck
|
|
109
126
|
n8n-decanter <ref> rename "<old node>" "<new node>" # rename a node everywhere
|
|
110
127
|
n8n-decanter <ref> rename --workflow "<new name>" # rename the workflow
|
|
128
|
+
n8n-decanter <ref> add "<Node name>" [--ts] # scaffold a Code node (offline)
|
|
111
129
|
n8n-decanter [ref] watch # push a workflow's nodes on save
|
|
112
130
|
# (+ 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)
|
|
113
138
|
n8n-decanter [ref...] executions [--status=success|error|waiting] [--limit=N]
|
|
114
139
|
# fetch recent execution data (run JSON)
|
|
115
140
|
# into workflows/<Name>/executions/
|
|
@@ -120,7 +145,6 @@ n8n-decanter list [--remote] # pulled workflows: name, id, folder
|
|
|
120
145
|
# (--remote adds unpulled ones)
|
|
121
146
|
n8n-decanter completion zsh|bash # print a shell completion script
|
|
122
147
|
n8n-decanter <node-file> run [fixture.json] [--allow-env] # run a node offline, print items
|
|
123
|
-
n8n-decanter uuid [count] # lowercase v4 UUID(s) for new node ids
|
|
124
148
|
```
|
|
125
149
|
|
|
126
150
|
A workflow `<ref>` is its **id, its workflow/folder name, or a unique name
|
|
@@ -209,9 +233,13 @@ already run, so Claude Code on a Claude subscription needs no extra API tokens.
|
|
|
209
233
|
update through the API** (pushing to an *unpublished* workflow only updates
|
|
210
234
|
its draft). This is an n8n API limitation, not decanter's; decanter surfaces
|
|
211
235
|
the outcome — push prints `published: code is live now` vs `unpublished:
|
|
212
|
-
draft only`, and `status`/`watch` show the state.
|
|
213
|
-
unpublish
|
|
214
|
-
|
|
236
|
+
draft only`, and `status`/`watch` show the state. The **`publish`** and
|
|
237
|
+
**`unpublish`** verbs take a draft live / return it to draft-only from the
|
|
238
|
+
CLI, so a staged rollout is `unpublish` → `push` → `publish` (triggers are
|
|
239
|
+
down in between) without leaving the terminal, or push to a staging-copy
|
|
240
|
+
workflow. On a published workflow whose draft has moved ahead in the UI,
|
|
241
|
+
`status` says the live version is older than the draft (`push` or `publish`
|
|
242
|
+
to catch it up).
|
|
215
243
|
- **n8n's optimistic locking isn't reachable through the API.** That same `PUT`
|
|
216
244
|
also forces `forceSave: true` and exposes no version checksum, so the server
|
|
217
245
|
won't reject a stale write. Decanter's **drift guard** is the only thing
|
package/dist/lib/add.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { findWorkflowDir, readState, writeState } from "./state.mjs";
|
|
5
|
+
import { CODE_DIR, CODE_NODE_TYPE, FILE_PLACEHOLDER_PREFIX, kebabCase, stableWorkflowJson } from "./util.mjs";
|
|
6
|
+
import { validateWorkflowDir } from "./validate.mjs";
|
|
7
|
+
/**
|
|
8
|
+
* Starter source for a freshly scaffolded Code node: valid, self-contained
|
|
9
|
+
* (no `$('…')` references, so it lands compliant), and it typechecks under the
|
|
10
|
+
* n8n ambient globals — the same body for `.js` and `.ts`.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_SOURCE = `// New Code node — runs once for all items. Edit me, then push.
|
|
13
|
+
// See https://docs.n8n.io/code/ for the Code node API.
|
|
14
|
+
for (const item of $input.all()) {
|
|
15
|
+
item.json.myNewField = 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return $input.all();
|
|
19
|
+
`;
|
|
20
|
+
/** Rightmost node's x + a step, so a scaffolded node doesn't stack on another. */
|
|
21
|
+
function nextPosition(nodes) {
|
|
22
|
+
let maxX = 0;
|
|
23
|
+
for (const node of nodes) {
|
|
24
|
+
const pos = node.position;
|
|
25
|
+
if (Array.isArray(pos) && typeof pos[0] === "number")
|
|
26
|
+
maxX = Math.max(maxX, pos[0]);
|
|
27
|
+
}
|
|
28
|
+
return [maxX + 220, 0];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Scaffold a `n8n-nodes-base.code` node into a pulled workflow in one atomic,
|
|
32
|
+
* guard-checked step (offline; `push` propagates). Mints a v4 uuid, writes the
|
|
33
|
+
* kebab-case source file under `code/` (sharing pull/rename's `-<id8>` collision
|
|
34
|
+
* suffix), appends the node object (default parameters, `mode:
|
|
35
|
+
* runOnceForAllItems`, a `//@file:` placeholder) to `workflow.json`, and
|
|
36
|
+
* registers it in `.decanter.json`. **No connections are wired** — the node
|
|
37
|
+
* lands disconnected but compliant; wiring stays in the editor. Re-validates
|
|
38
|
+
* afterwards and fails loudly on any violation, exactly like `rename`.
|
|
39
|
+
*/
|
|
40
|
+
export function addCodeNode(root, id, nodeName, { ts = false } = {}, log) {
|
|
41
|
+
nodeName = nodeName.trim();
|
|
42
|
+
if (!nodeName)
|
|
43
|
+
throw new Error("node name must not be empty");
|
|
44
|
+
const dir = findWorkflowDir(root, id, log);
|
|
45
|
+
if (!dir)
|
|
46
|
+
throw new Error(`workflow ${id} not found under ${root} — pull it first`);
|
|
47
|
+
const wfFile = path.join(dir, "workflow.json");
|
|
48
|
+
if (!existsSync(wfFile))
|
|
49
|
+
throw new Error(`missing workflow.json in ${dir} — pull first`);
|
|
50
|
+
let wf;
|
|
51
|
+
try {
|
|
52
|
+
wf = JSON.parse(readFileSync(wfFile, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new Error(`${wfFile}: invalid JSON (${err.message})`);
|
|
56
|
+
}
|
|
57
|
+
if (wf.nodes.some((n) => n.name === nodeName))
|
|
58
|
+
throw new Error(`a node named "${nodeName}" already exists in "${wf.name}"`);
|
|
59
|
+
const nodeId = randomUUID();
|
|
60
|
+
const ext = ts ? ".ts" : ".js";
|
|
61
|
+
mkdirSync(path.join(dir, CODE_DIR), { recursive: true });
|
|
62
|
+
let base = kebabCase(nodeName);
|
|
63
|
+
if (existsSync(path.join(dir, CODE_DIR, `${base}${ext}`))) {
|
|
64
|
+
base = `${base}-${nodeId.slice(0, 8)}`;
|
|
65
|
+
if (existsSync(path.join(dir, CODE_DIR, `${base}${ext}`))) {
|
|
66
|
+
throw new Error(`cannot add "${nodeName}": both kebab-case targets exist (${CODE_DIR}/${base}${ext})`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const file = `${CODE_DIR}/${base}${ext}`;
|
|
70
|
+
writeFileSync(path.join(dir, file), DEFAULT_SOURCE);
|
|
71
|
+
const node = {
|
|
72
|
+
id: nodeId,
|
|
73
|
+
name: nodeName,
|
|
74
|
+
type: CODE_NODE_TYPE,
|
|
75
|
+
typeVersion: 2,
|
|
76
|
+
position: nextPosition(wf.nodes),
|
|
77
|
+
parameters: { mode: "runOnceForAllItems", jsCode: FILE_PLACEHOLDER_PREFIX + file },
|
|
78
|
+
};
|
|
79
|
+
wf.nodes.push(node);
|
|
80
|
+
writeFileSync(wfFile, stableWorkflowJson(wf));
|
|
81
|
+
const state = readState(dir);
|
|
82
|
+
if (state) {
|
|
83
|
+
state.nodes[nodeId] = { file };
|
|
84
|
+
writeState(dir, state);
|
|
85
|
+
}
|
|
86
|
+
log.info(`added Code node "${nodeName}" (${nodeId}) -> ${file} — disconnected; wire it in the editor, then push to propagate`);
|
|
87
|
+
const { errors } = validateWorkflowDir(dir);
|
|
88
|
+
if (errors.length > 0) {
|
|
89
|
+
throw new Error(`add left the workflow non-compliant — files were written, inspect (git diff) and fix:\n${errors.map((e) => ` ${e}`).join("\n")}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/lib/api.mjs
CHANGED
|
@@ -48,6 +48,33 @@ export class N8nApi {
|
|
|
48
48
|
async updateWorkflow(id, body) {
|
|
49
49
|
return this.#request("PUT", `/api/v1/workflows/${encodeURIComponent(id)}`, body);
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a workflow on the server (n8n 2.x `POST /workflows`). The required
|
|
53
|
+
* fields are name + nodes/connections/settings (verified against 2.30.7 —
|
|
54
|
+
* name-only is rejected), so `create` posts empty collections while
|
|
55
|
+
* `duplicate` posts the cloned nodes/connections. Born **unpublished**
|
|
56
|
+
* (`active:false`); the caller pulls the returned id so the folder + state
|
|
57
|
+
* land. Shared by `create` (blank) and `duplicate` (Plan 21).
|
|
58
|
+
*/
|
|
59
|
+
async createWorkflow(body) {
|
|
60
|
+
return this.#request("POST", "/api/v1/workflows", body);
|
|
61
|
+
}
|
|
62
|
+
/** Take the draft live (n8n 2.x publish). Idempotent server-side; returns the workflow. */
|
|
63
|
+
async activateWorkflow(id) {
|
|
64
|
+
return this.#request("POST", `/api/v1/workflows/${encodeURIComponent(id)}/activate`);
|
|
65
|
+
}
|
|
66
|
+
/** Return a published workflow to draft-only. Idempotent server-side; returns the workflow. */
|
|
67
|
+
async deactivateWorkflow(id) {
|
|
68
|
+
return this.#request("POST", `/api/v1/workflows/${encodeURIComponent(id)}/deactivate`);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Hard-delete a workflow (n8n 2.x `DELETE /workflows/:id`). Verified to delete
|
|
72
|
+
* even a *published* workflow outright (no refusal, no archive), so the CLI's
|
|
73
|
+
* confirmation gate is the only guard. Returns the deleted workflow.
|
|
74
|
+
*/
|
|
75
|
+
async deleteWorkflow(id) {
|
|
76
|
+
return this.#request("DELETE", `/api/v1/workflows/${encodeURIComponent(id)}`);
|
|
77
|
+
}
|
|
51
78
|
async #request(method, pathname, body) {
|
|
52
79
|
try {
|
|
53
80
|
// The signal also covers body consumption, so a stalled response
|
package/dist/lib/executions.mjs
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { findWorkflowDir, listWorkflowDirs } from "./state.mjs";
|
|
4
4
|
export const EXECUTIONS_DIR = "executions";
|
|
5
|
+
/**
|
|
6
|
+
* Warn when fetched executions ran a *published* version that differs from the
|
|
7
|
+
* local draft (`workflow.json`'s `versionId`) — the captured data may not match
|
|
8
|
+
* the code you're now editing (PLAN.md's "convenience data, not ground truth"
|
|
9
|
+
* caveat, enforced). A warning, not an error: the data is still useful. Deduped
|
|
10
|
+
* by version so a page of same-version executions warns once; silent when the
|
|
11
|
+
* draft version or the execution version is unavailable (defensive).
|
|
12
|
+
*/
|
|
13
|
+
function warnStaleFixtures(dir, executions, log) {
|
|
14
|
+
const wfFile = path.join(dir, "workflow.json");
|
|
15
|
+
if (!existsSync(wfFile))
|
|
16
|
+
return;
|
|
17
|
+
let draft;
|
|
18
|
+
try {
|
|
19
|
+
draft = JSON.parse(readFileSync(wfFile, "utf8")).versionId;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return; // unparsable workflow.json — nothing to compare against
|
|
23
|
+
}
|
|
24
|
+
if (typeof draft !== "string")
|
|
25
|
+
return;
|
|
26
|
+
const stale = new Set();
|
|
27
|
+
for (const exec of executions) {
|
|
28
|
+
if (typeof exec.workflowVersionId === "string" && exec.workflowVersionId !== draft)
|
|
29
|
+
stale.add(exec.workflowVersionId);
|
|
30
|
+
}
|
|
31
|
+
for (const ran of stale) {
|
|
32
|
+
log.warn(`captured executions ran published version ${ran}; your draft is ${draft} — the data may not match the code you're editing`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
5
35
|
/**
|
|
6
36
|
* Materialize execution JSON under `<workflow>/executions/`. The dir is made
|
|
7
37
|
* self-ignoring via an `executions/.gitignore` containing `*` — execution
|
|
@@ -30,6 +60,7 @@ export async function fetchExecutions(api, root, workflowId, { status, limit },
|
|
|
30
60
|
return;
|
|
31
61
|
}
|
|
32
62
|
writeExecutionFiles(dir, executions, log);
|
|
63
|
+
warnStaleFixtures(dir, executions, log);
|
|
33
64
|
log.ok(`${executions.length} execution${executions.length === 1 ? "" : "s"} -> ${path.relative(process.cwd(), path.join(dir, EXECUTIONS_DIR))} (gitignored — temp data, "executions clean" removes it)`);
|
|
34
65
|
}
|
|
35
66
|
/**
|
|
@@ -45,6 +76,7 @@ export async function fetchExecutionById(api, root, executionId, log) {
|
|
|
45
76
|
if (!dir)
|
|
46
77
|
throw new Error(`execution ${executionId} belongs to workflow ${workflowId}, which is not pulled under ${root} — pull it first`);
|
|
47
78
|
writeExecutionFiles(dir, [exec], log);
|
|
79
|
+
warnStaleFixtures(dir, [exec], log);
|
|
48
80
|
log.ok(`execution ${executionId} -> ${path.relative(process.cwd(), path.join(dir, EXECUTIONS_DIR))}`);
|
|
49
81
|
}
|
|
50
82
|
/**
|
package/dist/lib/init.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { parseEnvFile } from "./config.mjs";
|
|
5
5
|
import { createPrompt } from "./prompt.mjs";
|
|
6
6
|
import { style } from "./style.mjs";
|
|
7
|
+
import { classifyTemplateFile, MANIFEST_FILE, readManifest, writeManifest } from "./template.mjs";
|
|
8
|
+
import { sha256 } from "./util.mjs";
|
|
7
9
|
/**
|
|
8
10
|
* Nearest ancestor of `startDir` holding a package.json — the package root.
|
|
9
11
|
* Works from the checkout (lib/ → repo root) *and* from the published build
|
|
@@ -52,30 +54,151 @@ export function printBanner(log) {
|
|
|
52
54
|
console.log(style.red(row.slice(0, 6)) + style.bold(row.slice(6)));
|
|
53
55
|
console.log(style.dim(`n8n workflows ⇄ agentic code · v${version}`));
|
|
54
56
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Classify every template file against the target dir and the copy-time
|
|
59
|
+
* baseline manifest. Pure scan — no files are written.
|
|
60
|
+
*/
|
|
61
|
+
function scanTemplate(srcDir, destDir, manifest, protect) {
|
|
62
|
+
const entries = [];
|
|
63
|
+
const walk = (src, rel) => {
|
|
59
64
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
60
65
|
const srcPath = path.join(src, entry.name);
|
|
61
66
|
if (entry.isDirectory()) {
|
|
62
|
-
walk(srcPath, path.join(
|
|
67
|
+
walk(srcPath, path.join(rel, entry.name));
|
|
63
68
|
continue;
|
|
64
69
|
}
|
|
65
70
|
const name = entry.name.endsWith(".example") && entry.name !== ".example"
|
|
66
71
|
? entry.name.slice(0, -".example".length)
|
|
67
72
|
: entry.name;
|
|
68
|
-
const
|
|
73
|
+
const materializedRel = path.join(rel, name);
|
|
74
|
+
const destPath = path.join(destDir, materializedRel);
|
|
75
|
+
if (protect.has(destPath))
|
|
76
|
+
continue; // .env: written separately, never manifest-tracked
|
|
77
|
+
const templateHash = sha256(readFileSync(srcPath, "utf8"));
|
|
69
78
|
const exists = existsSync(destPath);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
const targetHash = exists ? sha256(readFileSync(destPath, "utf8")) : undefined;
|
|
80
|
+
const manifestHash = manifest[materializedRel];
|
|
81
|
+
entries.push({
|
|
82
|
+
rel: materializedRel,
|
|
83
|
+
srcPath,
|
|
84
|
+
destPath,
|
|
85
|
+
templateHash,
|
|
86
|
+
targetHash,
|
|
87
|
+
outcome: classifyTemplateFile({ exists, targetHash, templateHash, manifestHash }),
|
|
88
|
+
});
|
|
75
89
|
}
|
|
76
90
|
};
|
|
77
|
-
walk(srcDir,
|
|
78
|
-
return
|
|
91
|
+
walk(srcDir, "");
|
|
92
|
+
return entries;
|
|
93
|
+
}
|
|
94
|
+
function copyEntry(entry) {
|
|
95
|
+
mkdirSync(path.dirname(entry.destPath), { recursive: true });
|
|
96
|
+
copyFileSync(entry.srcPath, entry.destPath);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Modification-aware template refresh (dpkg conffile-style). First init copies
|
|
100
|
+
* everything and records a baseline manifest. Re-init copies files new to the
|
|
101
|
+
* template, offers to refresh files the user hasn't touched (pristine), and
|
|
102
|
+
* leaves locally-modified files alone while reporting the drift. `--force` is
|
|
103
|
+
* the escape hatch: it overwrites every template file regardless.
|
|
104
|
+
*/
|
|
105
|
+
async function refreshTemplate(srcDir, destDir, { force, protect, version }, log) {
|
|
106
|
+
const manifest = readManifest(destDir);
|
|
107
|
+
const firstInit = !existsSync(path.join(destDir, MANIFEST_FILE));
|
|
108
|
+
const entries = scanTemplate(srcDir, destDir, manifest.files, protect);
|
|
109
|
+
const nextFiles = {};
|
|
110
|
+
if (force) {
|
|
111
|
+
let anyExisting = false;
|
|
112
|
+
for (const e of entries) {
|
|
113
|
+
const changed = e.targetHash !== undefined && e.targetHash !== e.templateHash;
|
|
114
|
+
if (e.targetHash !== undefined)
|
|
115
|
+
anyExisting = true;
|
|
116
|
+
copyEntry(e);
|
|
117
|
+
nextFiles[e.rel] = e.templateHash;
|
|
118
|
+
if (e.targetHash !== undefined)
|
|
119
|
+
log.warn(`--force: overwrote ${e.rel} with the template version${changed ? " (had local changes)" : ""}`);
|
|
120
|
+
}
|
|
121
|
+
writeManifest(destDir, { version, files: nextFiles });
|
|
122
|
+
log.info(anyExisting ? `reset template -> ${destDir}` : `copied template -> ${destDir}`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const added = [];
|
|
126
|
+
const pending = [];
|
|
127
|
+
const modified = [];
|
|
128
|
+
const conflicts = [];
|
|
129
|
+
let uptodate = 0;
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
switch (e.outcome) {
|
|
132
|
+
case "added":
|
|
133
|
+
copyEntry(e);
|
|
134
|
+
nextFiles[e.rel] = e.templateHash;
|
|
135
|
+
added.push(e.rel);
|
|
136
|
+
break;
|
|
137
|
+
case "converged":
|
|
138
|
+
nextFiles[e.rel] = e.templateHash; // adopt: on-disk copy now equals the template
|
|
139
|
+
break;
|
|
140
|
+
case "adopt":
|
|
141
|
+
nextFiles[e.rel] = e.targetHash; // legacy dir: trust the on-disk copy as the baseline
|
|
142
|
+
break;
|
|
143
|
+
case "update":
|
|
144
|
+
pending.push(e);
|
|
145
|
+
nextFiles[e.rel] = manifest.files[e.rel]; // provisional; set to templateHash if applied
|
|
146
|
+
break;
|
|
147
|
+
case "drift-modified":
|
|
148
|
+
modified.push(e.rel);
|
|
149
|
+
nextFiles[e.rel] = manifest.files[e.rel];
|
|
150
|
+
break;
|
|
151
|
+
case "drift-conflict":
|
|
152
|
+
conflicts.push(e.rel);
|
|
153
|
+
nextFiles[e.rel] = manifest.files[e.rel];
|
|
154
|
+
break;
|
|
155
|
+
case "uptodate":
|
|
156
|
+
uptodate++;
|
|
157
|
+
nextFiles[e.rel] = e.templateHash;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// On first init everything is "added" — the single "copied template" line
|
|
162
|
+
// below says it; only call out files the template *gained* on a re-init.
|
|
163
|
+
if (!firstInit)
|
|
164
|
+
for (const rel of added)
|
|
165
|
+
log.info(`added ${rel} from the template`);
|
|
166
|
+
if (pending.length > 0) {
|
|
167
|
+
let apply = false;
|
|
168
|
+
if (process.stdin.isTTY) {
|
|
169
|
+
const rl = createPrompt();
|
|
170
|
+
try {
|
|
171
|
+
log.info(`${pending.length} template file(s) have newer versions and are unmodified locally:`);
|
|
172
|
+
for (const e of pending)
|
|
173
|
+
log.info(` ${e.rel}`);
|
|
174
|
+
apply = (await rl.question(`Update ${pending.length} pristine file(s) to the template version? [y/N] `)).trim().toLowerCase().startsWith("y");
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
rl.close();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
log.warn(`${pending.length} pristine template file(s) have updates available — re-run init interactively or with --force to apply: ${pending.map((e) => e.rel).join(", ")}`);
|
|
182
|
+
}
|
|
183
|
+
if (apply) {
|
|
184
|
+
for (const e of pending) {
|
|
185
|
+
copyEntry(e);
|
|
186
|
+
nextFiles[e.rel] = e.templateHash;
|
|
187
|
+
}
|
|
188
|
+
log.info(`updated ${pending.length} file(s) from the template`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (modified.length > 0)
|
|
192
|
+
log.warn(`left unchanged (modified locally): ${modified.join(", ")}`);
|
|
193
|
+
if (conflicts.length > 0)
|
|
194
|
+
log.warn(`left unchanged (changed in both the template and your copy — resolve manually or --force to reset): ${conflicts.join(", ")}`);
|
|
195
|
+
writeManifest(destDir, { version, files: nextFiles });
|
|
196
|
+
// "copied" only when the whole tree was genuinely fresh; a dir that pre-dates
|
|
197
|
+
// the manifest (files present, no baseline) adopts in place — report as such.
|
|
198
|
+
if (added.length === entries.length)
|
|
199
|
+
log.info(`copied template -> ${destDir}`);
|
|
200
|
+
else
|
|
201
|
+
log.info(`template up to date (${uptodate} unchanged${added.length ? `, ${added.length} added` : ""})`);
|
|
79
202
|
}
|
|
80
203
|
/** Interactive bootstrap: prompt for credentials, write .env, copy template/. */
|
|
81
204
|
export async function init(targetDir, { force = false } = {}, log) {
|
|
@@ -107,16 +230,14 @@ export async function init(targetDir, { force = false } = {}, log) {
|
|
|
107
230
|
writeFileSync(envFile, `N8N_HOST=${host}\nN8N_API_KEY=${apiKey}\n`);
|
|
108
231
|
log.info(`wrote ${envFile}`);
|
|
109
232
|
}
|
|
110
|
-
// Copy the template
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
233
|
+
// Copy the template (whatever it contains, recursively), recording a
|
|
234
|
+
// per-file baseline in .decanter-template.json. Re-init is modification-aware:
|
|
235
|
+
// pristine files can be refreshed (after confirm), locally-edited files are
|
|
236
|
+
// left alone with drift reported, and `--force` overwrites everything. `.env`
|
|
237
|
+
// is protected (just written with real credentials) and never tracked.
|
|
114
238
|
// Files named `X.example` are inert in this repo (so agent tooling ignores
|
|
115
239
|
// them while working on the CLI itself) and materialize as `X` in the target.
|
|
116
|
-
|
|
117
|
-
log.info(`copied template -> ${dir}`);
|
|
118
|
-
for (const rel of overwritten)
|
|
119
|
-
log.warn(`--force: overwrote ${rel} with the template version`);
|
|
240
|
+
await refreshTemplate(TEMPLATE_DIR, dir, { force, protect: new Set([envFile]), version: cliVersion() }, log);
|
|
120
241
|
const configFile = path.join(dir, "decanter.config.json");
|
|
121
242
|
if (!existsSync(configFile)) {
|
|
122
243
|
writeFileSync(configFile, JSON.stringify({ root: "./workflows", workflows: [] }, null, 2) + "\n");
|