n8n-decanter 0.3.2 → 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 +70 -0
- package/README.md +13 -5
- package/dist/lib/add.mjs +91 -0
- package/dist/lib/api.mjs +8 -7
- package/dist/lib/init.mjs +142 -21
- package/dist/lib/lifecycle.mjs +39 -2
- package/dist/lib/picker.mjs +25 -7
- package/dist/lib/push.mjs +1 -1
- package/dist/lib/template.mjs +45 -0
- package/dist/n8n-decanter.mjs +34 -19
- package/package.json +2 -1
- package/template/.claude/settings.local.json.example +1 -1
- package/template/AGENTS.md.example +58 -28
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,76 @@ 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
|
+
|
|
10
80
|
## [0.3.2] - 2026-07-20
|
|
11
81
|
|
|
12
82
|
### Fixed
|
package/README.md
CHANGED
|
@@ -4,12 +4,17 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/n8n-decanter)
|
|
5
5
|
[](https://buttjer.github.io/n8n-decanter/)
|
|
6
6
|
[](LICENSE)
|
|
7
|
+
[](https://claude.com/claude-code)
|
|
7
8
|
|
|
8
9
|
**Work on n8n like a codebase — built for AI coding agents.**
|
|
9
10
|
|
|
10
11
|
**Pre-1.0 — breaking changes to the data model or CLI may ship in minor
|
|
11
12
|
versions until v1.0.**
|
|
12
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
|
+
|
|
13
18
|
n8n-decanter syncs your n8n instance into a git-friendly, folder-per-workflow
|
|
14
19
|
layout: every Code node's source becomes its own `.js` or `.ts` file,
|
|
15
20
|
editable in your IDE or by your agent, and pushed back through the n8n API.
|
|
@@ -64,9 +69,11 @@ directly, no build needed.
|
|
|
64
69
|
|
|
65
70
|
`init` copies everything in [template/](template/); files named `X.example`
|
|
66
71
|
land as `X` (the suffix keeps agent configs inert in this repo, live in the
|
|
67
|
-
target).
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
70
77
|
prompts and reuses them — edit or delete `.env` to change credentials. It
|
|
71
78
|
also does a best-effort credential check. Alternatively set up manually: `cp .env.example .env` and
|
|
72
79
|
fill it in. Then add workflow ids to `decanter.config.json`:
|
|
@@ -98,7 +105,7 @@ PostToolUse hook that runs `check` after node edits. Verification routes through
|
|
|
98
105
|
the CLI, so `n8n-decanter` must be on the sync dir's PATH: install it globally
|
|
99
106
|
(`npm i -g n8n-decanter`), add it to the sync dir's `devDependencies`, or
|
|
100
107
|
`npm link` a git checkout (build it first — Node won't type-strip `.mts`
|
|
101
|
-
under `node_modules`). The verbs `check`, `run`, and `
|
|
108
|
+
under `node_modules`). The verbs `check`, `run`, `rename`, and `add` are fully
|
|
102
109
|
offline (no credentials, no network).
|
|
103
110
|
|
|
104
111
|
## Commands
|
|
@@ -118,11 +125,13 @@ n8n-decanter [ref...] status [--diff] # drift report (--diff: line diffs);
|
|
|
118
125
|
n8n-decanter [ref...] check # offline layout-compliance + typecheck
|
|
119
126
|
n8n-decanter <ref> rename "<old node>" "<new node>" # rename a node everywhere
|
|
120
127
|
n8n-decanter <ref> rename --workflow "<new name>" # rename the workflow
|
|
128
|
+
n8n-decanter <ref> add "<Node name>" [--ts] # scaffold a Code node (offline)
|
|
121
129
|
n8n-decanter [ref] watch # push a workflow's nodes on save
|
|
122
130
|
# (+ browser live-reload, opt-in)
|
|
123
131
|
n8n-decanter [ref...] publish # take the draft(s) live
|
|
124
132
|
n8n-decanter [ref...] unpublish # (unpublish returns to draft-only)
|
|
125
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
|
|
126
135
|
n8n-decanter <ref> delete [--force] # delete a workflow from the server
|
|
127
136
|
# (y/N confirm; --force skips it; the
|
|
128
137
|
# local folder is left untouched)
|
|
@@ -136,7 +145,6 @@ n8n-decanter list [--remote] # pulled workflows: name, id, folder
|
|
|
136
145
|
# (--remote adds unpulled ones)
|
|
137
146
|
n8n-decanter completion zsh|bash # print a shell completion script
|
|
138
147
|
n8n-decanter <node-file> run [fixture.json] [--allow-env] # run a node offline, print items
|
|
139
|
-
n8n-decanter uuid [count] # lowercase v4 UUID(s) for new node ids
|
|
140
148
|
```
|
|
141
149
|
|
|
142
150
|
A workflow `<ref>` is its **id, its workflow/folder name, or a unique name
|
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
|
@@ -49,14 +49,15 @@ export class N8nApi {
|
|
|
49
49
|
return this.#request("PUT", `/api/v1/workflows/${encodeURIComponent(id)}`, body);
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
* Create a
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* the
|
|
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).
|
|
57
58
|
*/
|
|
58
|
-
async createWorkflow(
|
|
59
|
-
return this.#request("POST", "/api/v1/workflows",
|
|
59
|
+
async createWorkflow(body) {
|
|
60
|
+
return this.#request("POST", "/api/v1/workflows", body);
|
|
60
61
|
}
|
|
61
62
|
/** Take the draft live (n8n 2.x publish). Idempotent server-side; returns the workflow. */
|
|
62
63
|
async activateWorkflow(id) {
|
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");
|
package/dist/lib/lifecycle.mjs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { createPrompt } from "./prompt.mjs";
|
|
3
4
|
import { pullWorkflow } from "./pull.mjs";
|
|
5
|
+
import { assertCompliant, buildNodeCode } from "./push.mjs";
|
|
4
6
|
import { findWorkflowDir } from "./state.mjs";
|
|
5
|
-
import { publicationState } from "./util.mjs";
|
|
7
|
+
import { isJsCodeNode, placeholderFile, publicationState, sanitizeForPut } from "./util.mjs";
|
|
8
|
+
import { validateWorkflowDir } from "./validate.mjs";
|
|
6
9
|
/**
|
|
7
10
|
* `publish` — take an unpublished draft live (n8n 2.x activate). A workflow
|
|
8
11
|
* that is already published is a no-op-with-a-note, not an error: a push to a
|
|
@@ -39,13 +42,47 @@ export async function unpublishWorkflow(api, id, log) {
|
|
|
39
42
|
* Plan 21's `duplicate`.
|
|
40
43
|
*/
|
|
41
44
|
export async function createWorkflow(api, config, name, log) {
|
|
42
|
-
const created = await api.createWorkflow(name);
|
|
45
|
+
const created = await api.createWorkflow({ name, nodes: [], connections: {}, settings: {} });
|
|
43
46
|
const id = created.id;
|
|
44
47
|
log.ok(`created "${name}" (${id}) on the server — unpublished draft`);
|
|
45
48
|
const { dir } = await pullWorkflow(api, config.root, id, { commitOnPull: config.commitOnPull }, log);
|
|
46
49
|
log.info(`edit code/, push, then "publish" to go live`);
|
|
47
50
|
return { dir, id };
|
|
48
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* `<ref> duplicate ["<new name>"]` — clone an already-pulled workflow into a
|
|
54
|
+
* brand-new remote one, then pull the copy. The body is assembled from the
|
|
55
|
+
* **local** folder exactly as `push` does (placeholders reconstituted from
|
|
56
|
+
* `code/`, `.ts` nodes compiled), so the clone carries the repo's current
|
|
57
|
+
* content — even edits not yet pushed to the source. The default name is
|
|
58
|
+
* `"<name> (copy)"` (matching the n8n UI). Born **unpublished**, like any
|
|
59
|
+
* create; the source folder and the source remote workflow are left untouched.
|
|
60
|
+
* Pull-first is preserved: the copy is born server-side and materialized via a
|
|
61
|
+
* fresh pull. Reuses `api.createWorkflow` (Plan 20) with a full body.
|
|
62
|
+
*/
|
|
63
|
+
export async function duplicateWorkflow(api, config, id, newName, log) {
|
|
64
|
+
const dir = findWorkflowDir(config.root, id, log);
|
|
65
|
+
if (!dir)
|
|
66
|
+
throw new Error(`workflow ${id} not found under ${config.root} — pull it first`);
|
|
67
|
+
assertCompliant(validateWorkflowDir(dir), log, `"${path.basename(dir)}"`);
|
|
68
|
+
const wf = JSON.parse(readFileSync(path.join(dir, "workflow.json"), "utf8"));
|
|
69
|
+
const sourceName = wf.name;
|
|
70
|
+
for (const node of wf.nodes) {
|
|
71
|
+
if (!isJsCodeNode(node))
|
|
72
|
+
continue;
|
|
73
|
+
const file = placeholderFile(node);
|
|
74
|
+
if (file === null)
|
|
75
|
+
continue;
|
|
76
|
+
node.parameters.jsCode = (await buildNodeCode(dir, file, log)).jsCode;
|
|
77
|
+
}
|
|
78
|
+
const body = sanitizeForPut(wf);
|
|
79
|
+
body.name = newName?.trim() || `${sourceName} (copy)`;
|
|
80
|
+
const created = await api.createWorkflow(body);
|
|
81
|
+
log.ok(`duplicated "${sourceName}" -> "${body.name}" (${created.id}) on the server — unpublished draft`);
|
|
82
|
+
const { dir: newDir } = await pullWorkflow(api, config.root, created.id, { commitOnPull: config.commitOnPull }, log);
|
|
83
|
+
log.info(`edit code/, push, then "publish" to go live`);
|
|
84
|
+
return { dir: newDir, id: created.id };
|
|
85
|
+
}
|
|
49
86
|
/**
|
|
50
87
|
* `<ref> delete` — hard-delete a workflow from the server, deliberately.
|
|
51
88
|
* Destructive and outward-facing, so consent is explicit: on a TTY a `y/N`
|
package/dist/lib/picker.mjs
CHANGED
|
@@ -103,7 +103,17 @@ const LIST_HEIGHT = 10;
|
|
|
103
103
|
// loads: light-gray blocks shaped like "name id", varied widths on purpose.
|
|
104
104
|
const SKELETON_WIDTHS = [[14, 8], [9, 8], [17, 8]];
|
|
105
105
|
const truncate = (text, max) => (text.length > max ? text.slice(0, max - 1) + "…" : text);
|
|
106
|
-
|
|
106
|
+
/** Filled = pulled, hollow = not pulled — differs by *shape*, so the state
|
|
107
|
+
* survives NO_COLOR/monochrome without the old trailing `(not pulled)` words. */
|
|
108
|
+
const statusGlyph = (pulled) => (pulled ? style.green("●") : style.yellow("○"));
|
|
109
|
+
/**
|
|
110
|
+
* Pure view: `PickerState` → the lines to paint. Fed the docs-site
|
|
111
|
+
* simulation's polish (Plan 23) — an aligned id column, `●`/`○` shape-based
|
|
112
|
+
* status glyphs, and a per-stage title. Color is decoration only; every
|
|
113
|
+
* distinction is carried by shape or words too, so `NO_COLOR` output stays
|
|
114
|
+
* legible. Exported for the render unit tests (no TTY needed).
|
|
115
|
+
*/
|
|
116
|
+
export function renderLines(state) {
|
|
107
117
|
const d = style.dim;
|
|
108
118
|
const lines = [];
|
|
109
119
|
if (state.stage === "verb") {
|
|
@@ -116,26 +126,34 @@ function renderLines(state) {
|
|
|
116
126
|
return lines;
|
|
117
127
|
}
|
|
118
128
|
const filtered = filterEntries(state.entries, state.query);
|
|
119
|
-
lines.push(
|
|
129
|
+
lines.push(style.bold("pick a workflow"));
|
|
130
|
+
lines.push(`${d("?")} ${state.query === "" ? d("type to filter") : state.query}`);
|
|
120
131
|
const { start, end } = visibleWindow(filtered.length, state.cursor, LIST_HEIGHT);
|
|
132
|
+
// Pad names to the widest one in the current window so the dim ids line up.
|
|
133
|
+
let nameWidth = 0;
|
|
134
|
+
for (let i = start; i < end; i++)
|
|
135
|
+
nameWidth = Math.max(nameWidth, truncate(filtered[i].name, 48).length);
|
|
121
136
|
for (let i = start; i < end; i++) {
|
|
122
137
|
const e = filtered[i];
|
|
123
138
|
const pointer = i === state.cursor ? style.green("❯") : " ";
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
lines.push(`${pointer} ${
|
|
139
|
+
const display = truncate(e.name, 48);
|
|
140
|
+
const name = (e.pulled ? style.green(display) : style.yellow(display)) + " ".repeat(nameWidth - display.length);
|
|
141
|
+
lines.push(`${pointer} ${statusGlyph(e.pulled)} ${name} ${d(e.id)}`);
|
|
127
142
|
}
|
|
128
143
|
if (filtered.length === 0 && !state.loadingRemote)
|
|
129
144
|
lines.push(d(state.entries.length === 0 ? " nothing pulled yet" : " no match"));
|
|
130
145
|
if (end < filtered.length)
|
|
131
146
|
lines.push(d(` … ${filtered.length - end} more`));
|
|
132
147
|
if (state.loadingRemote) {
|
|
133
|
-
for (const [
|
|
134
|
-
lines.push(` ${d("░".repeat(
|
|
148
|
+
for (const [nameW, idWidth] of SKELETON_WIDTHS) {
|
|
149
|
+
lines.push(` ${d("░".repeat(nameW))} ${d("░".repeat(idWidth))}`);
|
|
135
150
|
}
|
|
136
151
|
}
|
|
137
152
|
if (state.notice)
|
|
138
153
|
lines.push(d(state.notice));
|
|
154
|
+
// Legend states the glyph key once (footer), not on every row.
|
|
155
|
+
if (filtered.length > 0)
|
|
156
|
+
lines.push(d(`${statusGlyph(true)} pulled · ${statusGlyph(false)} not pulled`));
|
|
139
157
|
const enterHint = filtered[state.cursor]?.pulled === false ? "enter pull" : "enter select";
|
|
140
158
|
lines.push(d(`↑↓ move · ${enterHint} · esc quit`));
|
|
141
159
|
return lines;
|
package/dist/lib/push.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { findWorkflowDir, readState, writeState } from "./state.mjs";
|
|
|
7
7
|
import { isJsCodeNode, placeholderFile, publicationState, sanitizeForPut, sha256, splitMarker, withMarker, workflowStructureHash, } from "./util.mjs";
|
|
8
8
|
import { validateNodeFile, validateWorkflowDir } from "./validate.mjs";
|
|
9
9
|
/** Layout-compliance gate: warnings pass through, errors abort the push. */
|
|
10
|
-
function assertCompliant({ errors, warnings }, log, what) {
|
|
10
|
+
export function assertCompliant({ errors, warnings }, log, what) {
|
|
11
11
|
for (const w of warnings)
|
|
12
12
|
log.warn(w);
|
|
13
13
|
if (errors.length === 0)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Sync-dir manifest recording the `sha256:` of each template file *as copied*
|
|
5
|
+
* by `init` (dpkg conffile-style baseline). Comparing this baseline against the
|
|
6
|
+
* file currently on disk and the current template lets re-init tell a file the
|
|
7
|
+
* user edited from a pristine one, so template improvements can be pulled in
|
|
8
|
+
* without clobbering local changes. Git-tracked (shared across a team).
|
|
9
|
+
*/
|
|
10
|
+
export const MANIFEST_FILE = ".decanter-template.json";
|
|
11
|
+
/** Tolerant read: a missing or corrupt manifest is an empty baseline. */
|
|
12
|
+
export function readManifest(dir) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(readFileSync(path.join(dir, MANIFEST_FILE), "utf8"));
|
|
15
|
+
const files = raw.files && typeof raw.files === "object" ? raw.files : {};
|
|
16
|
+
return { version: typeof raw.version === "string" ? raw.version : "0.0.0", files };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { version: "0.0.0", files: {} };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function writeManifest(dir, manifest) {
|
|
23
|
+
// sorted keys → stable, review-friendly diffs
|
|
24
|
+
const files = Object.fromEntries(Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b)));
|
|
25
|
+
writeFileSync(path.join(dir, MANIFEST_FILE), JSON.stringify({ version: manifest.version, files }, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
/** True when a sync dir already has a manifest (i.e. this is a re-init). */
|
|
28
|
+
export function hasManifest(dir) {
|
|
29
|
+
return existsSync(path.join(dir, MANIFEST_FILE));
|
|
30
|
+
}
|
|
31
|
+
export function classifyTemplateFile(input) {
|
|
32
|
+
const { exists, targetHash, templateHash, manifestHash } = input;
|
|
33
|
+
if (!exists)
|
|
34
|
+
return "added";
|
|
35
|
+
if (manifestHash === undefined)
|
|
36
|
+
return targetHash === templateHash ? "uptodate" : "adopt";
|
|
37
|
+
const pristine = targetHash === manifestHash;
|
|
38
|
+
if (templateHash === manifestHash)
|
|
39
|
+
return pristine ? "uptodate" : "drift-modified";
|
|
40
|
+
if (pristine)
|
|
41
|
+
return "update";
|
|
42
|
+
if (targetHash === templateHash)
|
|
43
|
+
return "converged";
|
|
44
|
+
return "drift-conflict";
|
|
45
|
+
}
|
package/dist/n8n-decanter.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
2
|
import path from "node:path";
|
|
3
|
+
import { addCodeNode } from "./lib/add.mjs";
|
|
4
4
|
import { N8nApi } from "./lib/api.mjs";
|
|
5
5
|
import { loadConfig } from "./lib/config.mjs";
|
|
6
6
|
import { cleanExecutions, fetchExecutionById, fetchExecutions } from "./lib/executions.mjs";
|
|
7
7
|
import { init, printBanner } from "./lib/init.mjs";
|
|
8
|
-
import { createWorkflow, deleteWorkflow, publishWorkflow, unpublishWorkflow } from "./lib/lifecycle.mjs";
|
|
8
|
+
import { createWorkflow, deleteWorkflow, duplicateWorkflow, publishWorkflow, unpublishWorkflow } from "./lib/lifecycle.mjs";
|
|
9
9
|
import { mergeRemote, runPicker } from "./lib/picker.mjs";
|
|
10
10
|
import { pullWorkflow } from "./lib/pull.mjs";
|
|
11
11
|
import { pushWorkflow } from "./lib/push.mjs";
|
|
@@ -44,7 +44,8 @@ const usage = () => {
|
|
|
44
44
|
${d("choose a workflow, then verbs — stays in")}
|
|
45
45
|
${d("the workflow's menu until Esc")}
|
|
46
46
|
${b("n8n-decanter init")} [dir] [--force] ${d("interactive setup: .env, starter files, config")}
|
|
47
|
-
${d("(
|
|
47
|
+
${d("(re-init refreshes unedited template files, keeps")}
|
|
48
|
+
${d("your edits; --force overwrites everything)")}
|
|
48
49
|
${b("n8n-decanter")} [ref...] ${b("pull")} ${d("pull workflows (default: all in decanter.config.json)")}
|
|
49
50
|
${b("n8n-decanter")} [ref...] ${b("push")} [--force] [--no-typecheck]
|
|
50
51
|
${b("n8n-decanter")} [ref...] ${b("status")} [--diff] ${d("drift report (--diff shows line diffs);")}
|
|
@@ -52,6 +53,8 @@ const usage = () => {
|
|
|
52
53
|
${b("n8n-decanter")} [ref...] ${b("check")} [--no-typecheck] ${d("offline layout-compliance check")}
|
|
53
54
|
${b("n8n-decanter")} <ref> ${b("rename")} "<old node>" "<new node>" ${d("rename a node everywhere (offline)")}
|
|
54
55
|
${b("n8n-decanter")} <ref> ${b("rename")} --workflow "<new name>" ${d("rename the workflow itself")}
|
|
56
|
+
${b("n8n-decanter")} <ref> ${b("add")} "<Node name>" [--ts] ${d("scaffold a disconnected Code node (offline)")}
|
|
57
|
+
${b("n8n-decanter")} <ref> ${b("duplicate")} ["<new name>"] ${d("clone a workflow into a new remote one, then pull it")}
|
|
55
58
|
${b("n8n-decanter")} [ref...] ${b("publish")} ${d("take the draft(s) live (unpublish returns to draft-only)")}
|
|
56
59
|
${b("n8n-decanter")} [ref...] ${b("unpublish")}
|
|
57
60
|
${b("n8n-decanter create")} "<name>" ${d("create a blank workflow on the server, then pull it")}
|
|
@@ -70,7 +73,6 @@ const usage = () => {
|
|
|
70
73
|
${b("n8n-decanter completion")} zsh|bash ${d("print a shell completion script for your rc file")}
|
|
71
74
|
${b("n8n-decanter")} <node-file> ${b("run")} [fixture.json] [--allow-env] ${d("run a node locally (offline;")}
|
|
72
75
|
${d("$env is empty unless the fixture sets env or --allow-env inherits process.env)")}
|
|
73
|
-
${b("n8n-decanter uuid")} [count] ${d("print lowercase v4 UUID(s) for new node ids")}
|
|
74
76
|
|
|
75
77
|
A workflow <ref> is its id, its workflow/folder name, or a unique name prefix
|
|
76
78
|
(case-insensitive; ambiguity is an error, never a prompt). A workflow named
|
|
@@ -81,7 +83,7 @@ and flags may appear in any position too.
|
|
|
81
83
|
Config: decanter.config.json (searched upward from cwd), credentials from .env
|
|
82
84
|
next to it or the environment (N8N_HOST, N8N_API_KEY).`;
|
|
83
85
|
};
|
|
84
|
-
const VERBS = new Set(["init", "pull", "push", "status", "check", "rename", "
|
|
86
|
+
const VERBS = new Set(["init", "pull", "push", "status", "check", "rename", "add", "duplicate", "watch", "run", "list", "executions", "publish", "unpublish", "create", "delete", "completion", "__complete", "help"]);
|
|
85
87
|
/** Verbs whose workflow arguments go through name resolution. */
|
|
86
88
|
const REF_VERBS = new Set(["pull", "push", "status", "check", "watch", "publish", "unpublish", "delete"]);
|
|
87
89
|
// Both scripts delegate to the hidden `__complete` verb at completion time,
|
|
@@ -135,6 +137,7 @@ async function main() {
|
|
|
135
137
|
const workflowFlag = args.includes("--workflow");
|
|
136
138
|
const remoteFlag = args.includes("--remote");
|
|
137
139
|
const diffFlag = args.includes("--diff");
|
|
140
|
+
const tsFlag = args.includes("--ts");
|
|
138
141
|
const positional = args.filter((a) => !a.startsWith("--"));
|
|
139
142
|
// id-first support: the first token matching a known verb is the command,
|
|
140
143
|
// wherever it sits — `push wf123` and `wf123 push` are equivalent.
|
|
@@ -169,14 +172,6 @@ async function main() {
|
|
|
169
172
|
return;
|
|
170
173
|
}
|
|
171
174
|
// Offline, config-free verbs — no decanter.config.json or credentials needed.
|
|
172
|
-
if (command === "uuid") {
|
|
173
|
-
const count = rest[0] ? Number(rest[0]) : 1;
|
|
174
|
-
if (!Number.isInteger(count) || count < 1)
|
|
175
|
-
throw new Error("uuid count must be a positive integer");
|
|
176
|
-
for (let i = 0; i < count; i++)
|
|
177
|
-
console.log(randomUUID());
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
175
|
if (command === "run") {
|
|
181
176
|
if (rest.length < 1)
|
|
182
177
|
throw new Error("run needs a node file argument: n8n-decanter run <node-file> [fixture.json]");
|
|
@@ -194,7 +189,7 @@ async function main() {
|
|
|
194
189
|
// hidden helper backing the completion scripts: verbs, flags, and local
|
|
195
190
|
// workflow names/ids — offline, credentials-free, silent without a config
|
|
196
191
|
const words = [...VERBS].filter((v) => v !== "__complete" && v !== "help");
|
|
197
|
-
words.push("--force", "--no-typecheck", "--workflow", "--remote", "--diff", "--status=", "--limit=", "--allow-env", "--help");
|
|
192
|
+
words.push("--force", "--no-typecheck", "--workflow", "--remote", "--diff", "--ts", "--status=", "--limit=", "--allow-env", "--help");
|
|
198
193
|
try {
|
|
199
194
|
const config = loadConfig(process.cwd(), { requireCredentials: false });
|
|
200
195
|
for (const ref of listWorkflowRefs(config.root))
|
|
@@ -206,10 +201,10 @@ async function main() {
|
|
|
206
201
|
console.log([...new Set(words)].join("\n"));
|
|
207
202
|
return;
|
|
208
203
|
}
|
|
209
|
-
await dispatch(command, rest, { force, noTypecheck, workflowFlag, remoteFlag, diffFlag, valueFlags });
|
|
204
|
+
await dispatch(command, rest, { force, noTypecheck, workflowFlag, remoteFlag, diffFlag, tsFlag, valueFlags });
|
|
210
205
|
}
|
|
211
206
|
/** Flag defaults for picker-launched verbs (no CLI flags in play). */
|
|
212
|
-
const PICKER_FLAGS = { force: false, noTypecheck: false, workflowFlag: false, remoteFlag: false, diffFlag: false, valueFlags: new Map() };
|
|
207
|
+
const PICKER_FLAGS = { force: false, noTypecheck: false, workflowFlag: false, remoteFlag: false, diffFlag: false, tsFlag: false, valueFlags: new Map() };
|
|
213
208
|
/**
|
|
214
209
|
* Interactive session (Plan 19 + loop follow-up): banner, then pick → run →
|
|
215
210
|
* back in the same workflow's verb menu until Esc (workflow list, then quit)
|
|
@@ -258,8 +253,8 @@ async function pickerLoop(config) {
|
|
|
258
253
|
}
|
|
259
254
|
/** Config-needing verbs: load config, resolve refs, run the verb switch. */
|
|
260
255
|
async function dispatch(command, rest, flags) {
|
|
261
|
-
const { force, noTypecheck, workflowFlag, remoteFlag, diffFlag, valueFlags } = flags;
|
|
262
|
-
const offline = command === "check" || command === "rename" || (command === "list" && !remoteFlag)
|
|
256
|
+
const { force, noTypecheck, workflowFlag, remoteFlag, diffFlag, tsFlag, valueFlags } = flags;
|
|
257
|
+
const offline = command === "check" || command === "rename" || command === "add" || (command === "list" && !remoteFlag)
|
|
263
258
|
|| (command === "executions" && rest.includes("clean"));
|
|
264
259
|
const config = loadConfig(process.cwd(), { requireCredentials: !offline });
|
|
265
260
|
const api = new N8nApi(config);
|
|
@@ -295,7 +290,9 @@ async function dispatch(command, rest, flags) {
|
|
|
295
290
|
for (const r of rest)
|
|
296
291
|
refs.push(await resolveRef(r));
|
|
297
292
|
}
|
|
298
|
-
else if (command === "rename" && rest.length > 0) {
|
|
293
|
+
else if ((command === "rename" || command === "add" || command === "duplicate") && rest.length > 0) {
|
|
294
|
+
// ref-plus-literals verbs: only the first argument is a workflow ref;
|
|
295
|
+
// the rest are names (node names, a new workflow name) — not resolved.
|
|
299
296
|
refs = [await resolveRef(rest[0]), ...rest.slice(1)];
|
|
300
297
|
}
|
|
301
298
|
const ids = refs.length > 0 ? refs : config.workflows;
|
|
@@ -505,6 +502,24 @@ async function dispatch(command, rest, flags) {
|
|
|
505
502
|
}
|
|
506
503
|
break;
|
|
507
504
|
}
|
|
505
|
+
case "add": {
|
|
506
|
+
const [id, ...names] = refs;
|
|
507
|
+
if (!id)
|
|
508
|
+
throw new Error('add needs a workflow ref and a node name: n8n-decanter <ref> add "<Node name>" [--ts]');
|
|
509
|
+
if (names.length !== 1)
|
|
510
|
+
throw new Error('add needs exactly one node name: n8n-decanter <ref> add "<Node name>" [--ts]');
|
|
511
|
+
addCodeNode(config.root, id, names[0], { ts: tsFlag }, log);
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
case "duplicate": {
|
|
515
|
+
const [id, ...names] = refs;
|
|
516
|
+
if (!id)
|
|
517
|
+
throw new Error('duplicate needs a workflow ref: n8n-decanter <ref> duplicate ["<new name>"]');
|
|
518
|
+
if (names.length > 1)
|
|
519
|
+
throw new Error('duplicate takes at most one new name: n8n-decanter <ref> duplicate ["<new name>"]');
|
|
520
|
+
await duplicateWorkflow(api, config, id, names[0], log);
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
508
523
|
case "watch": {
|
|
509
524
|
if (ids.length !== 1)
|
|
510
525
|
throw new Error("watch needs exactly one workflow id (pass it, or list a single workflow in decanter.config.json)");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n8n-decanter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Work on n8n like a codebase — built for AI coding agents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"n8n",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.build.json",
|
|
34
|
+
"prepare": "node scripts/setup-hooks.mts",
|
|
34
35
|
"prepack": "npm run build",
|
|
35
36
|
"typecheck": "tsc -p tsconfig.cli.json && node scripts/typecheck.mts",
|
|
36
37
|
"test": "node --test 'test/unit/*.test.mts' && node test/e2e.mts && node test/proxy.mts && node test/interactive.mts",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This is the **source of truth** for working in this repository, and it is written to
|
|
4
4
|
be tool-agnostic. Codex and opencode read this file natively; Claude Code reads it
|
|
5
5
|
through a one-line `@AGENTS.md` import in `CLAUDE.md`; other agents can be pointed at
|
|
6
|
-
|
|
6
|
+
this file directly. Keep project-specific rules here so every agent stays in sync.
|
|
7
7
|
|
|
8
8
|
This repo holds n8n workflows *decanted* into git-friendly form: one folder per
|
|
9
9
|
workflow containing `workflow.json` plus a `code/` subdir with one source file
|
|
@@ -11,6 +11,21 @@ per Code node. The
|
|
|
11
11
|
`n8n-decanter` CLI moves content between here and the live n8n instance
|
|
12
12
|
(`pull` / `push`). This document is the contract for creating and editing these files.
|
|
13
13
|
|
|
14
|
+
## The short version
|
|
15
|
+
|
|
16
|
+
- **Code lives in `code/`, never in `workflow.json`.** A node's `jsCode` is only
|
|
17
|
+
a `//@file:code/<name>` placeholder — edit the file it points at.
|
|
18
|
+
- **Never hand-edit `.decanter.json` or a `*.remote.js`, and never write a
|
|
19
|
+
`// @ts-n8n sha256:` marker.** These are machine-owned; touching them corrupts
|
|
20
|
+
sync state and the CLI blocks it at `push`.
|
|
21
|
+
- **Structural edits have verbs — use them instead of hand-editing JSON:**
|
|
22
|
+
`add` a Code node, `rename` a node/workflow, `create`/`duplicate` a workflow.
|
|
23
|
+
Each does the fiddly multi-file edit atomically and re-validates the folder.
|
|
24
|
+
- **`push` is a deploy to the live instance — only when the user asks.** Edit,
|
|
25
|
+
then verify offline with `n8n-decanter check` and `n8n-decanter run`.
|
|
26
|
+
|
|
27
|
+
The rest of this document is the full contract behind those four points.
|
|
28
|
+
|
|
14
29
|
## How this differs from standard n8n editing — read this first
|
|
15
30
|
|
|
16
31
|
Your general n8n knowledge (docs, n8n MCP tools, training data) is **valid** for:
|
|
@@ -35,12 +50,14 @@ runtime. It is **wrong** about how code is stored here. The deviations:
|
|
|
35
50
|
diff: port the change into the `.ts`, delete the `.remote.js`, then the user
|
|
36
51
|
pushes. Never edit `.remote.js` itself; never push while one exists.
|
|
37
52
|
6. **Renames have no UI magic here.** The n8n editor auto-updates references on
|
|
38
|
-
rename;
|
|
53
|
+
rename; editing files does not. Use `n8n-decanter <ref> rename` — it rewrites
|
|
54
|
+
every reference atomically. See "Renaming a node" below.
|
|
39
55
|
7. **New workflows are born on the server, not from a repo folder.** There is no
|
|
40
56
|
id-less folder that becomes a workflow on push. Use `n8n-decanter create
|
|
41
57
|
"<name>"` — it posts a blank draft to n8n and pulls it back (the server
|
|
42
|
-
assigns the id) —
|
|
43
|
-
|
|
58
|
+
assigns the id) — `n8n-decanter <ref> duplicate` to clone an existing
|
|
59
|
+
workflow into a new one, or create it in the n8n UI and `pull`. Then add its
|
|
60
|
+
id to `decanter.config.json`. A created workflow is **unpublished**; `publish`
|
|
44
61
|
takes it live, `unpublish` returns it to draft-only, and `<ref> delete`
|
|
45
62
|
removes one from the server (the local folder is kept).
|
|
46
63
|
|
|
@@ -72,7 +89,7 @@ A node object:
|
|
|
72
89
|
|
|
73
90
|
```json
|
|
74
91
|
{
|
|
75
|
-
"id": "a1b2c3d4-…", // UUID v4 — `n8n-decanter
|
|
92
|
+
"id": "a1b2c3d4-…", // UUID v4 — `n8n-decanter <ref> add` mints it; NEVER change an existing id
|
|
76
93
|
"name": "Amazon Feed", // unique per workflow — connections + $('…') reference it
|
|
77
94
|
"type": "n8n-nodes-base.code",
|
|
78
95
|
"typeVersion": 2,
|
|
@@ -103,24 +120,36 @@ Rules:
|
|
|
103
120
|
(whitelisted keys), `staticData`. Activation is managed in the n8n UI.
|
|
104
121
|
- **Position:** keep a sensible grid; new nodes roughly +200 x from their input.
|
|
105
122
|
|
|
106
|
-
### Renaming a node
|
|
123
|
+
### Renaming a node
|
|
124
|
+
|
|
125
|
+
Use the verb — it rewrites every reference atomically, offline, and re-validates
|
|
126
|
+
the folder:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
n8n-decanter <ref> rename "<Old Name>" "<New Name>"
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
It touches all four places a rename must hit at once — the same four you'd have
|
|
133
|
+
to keep in sync **by hand** (and atomically) if you ever edit the raw JSON:
|
|
107
134
|
|
|
108
135
|
1. `name` in the node object.
|
|
109
136
|
2. Every reference in `connections` (keys *and* target `"node"` values).
|
|
110
137
|
3. Every `$('Old Name')` in **all** code files of the workflow.
|
|
111
|
-
4. The source file name and its `//@file:` placeholder (
|
|
112
|
-
|
|
113
|
-
on the next pull, but the repo must be self-consistent immediately.
|
|
138
|
+
4. The source file name and its `//@file:` placeholder (kebab-case convention,
|
|
139
|
+
file stays in `code/`).
|
|
114
140
|
|
|
115
|
-
Renaming a **workflow**:
|
|
116
|
-
on the next pull
|
|
117
|
-
`workflow.json` is authoritative
|
|
141
|
+
Renaming a **workflow**: `n8n-decanter <ref> rename --workflow "<New Name>"`.
|
|
142
|
+
The folder rename follows on the next pull — folder names are cosmetic; the id
|
|
143
|
+
inside `workflow.json` is authoritative.
|
|
118
144
|
|
|
119
145
|
### Adding a Code node
|
|
120
146
|
|
|
121
|
-
1.
|
|
122
|
-
|
|
123
|
-
|
|
147
|
+
1. `n8n-decanter <ref> add "<Node name>" [--ts]` scaffolds it in one step: it
|
|
148
|
+
mints the node `id`, writes the `code/` source (`.js`, or `.ts` with
|
|
149
|
+
`--ts`), adds the `//@file:` placeholder, records it in `.decanter.json`,
|
|
150
|
+
and re-checks the folder. The node lands **disconnected**.
|
|
151
|
+
2. Wire it up — add its connections in `workflow.json` (and any `$('…')`
|
|
152
|
+
references from other nodes), then edit the source in `code/`.
|
|
124
153
|
3. Verify: `n8n-decanter run <file> [fixture.json]` to execute it, then
|
|
125
154
|
`n8n-decanter check` (typecheck + layout).
|
|
126
155
|
|
|
@@ -141,12 +170,11 @@ The runtime contract (identical for `.js` and `.ts` — types are erased):
|
|
|
141
170
|
workspace TypeScript: `npm install`, then in VS Code accept
|
|
142
171
|
*Use Workspace Version* (offered via `.vscode/settings.json`'s
|
|
143
172
|
`typescript.tsdk`; JetBrains uses the project TypeScript by default).
|
|
144
|
-
- Available globals
|
|
145
|
-
`$input
|
|
146
|
-
`$json`, `$binary`,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
`$getWorkflowStaticData('global' | 'node')`.
|
|
173
|
+
- **Available globals: `n8n-globals.d.ts` is the authoritative list.** Open it
|
|
174
|
+
to see every global in scope with its exact signature (`$input`, `$('Node
|
|
175
|
+
Name')`, `$json`, `$binary`, the Luxon `DateTime`/`$now`/`$today`, `$workflow`,
|
|
176
|
+
`$execution`, `$getWorkflowStaticData`, …). If node code needs a global that
|
|
177
|
+
file doesn't declare yet, add it there.
|
|
150
178
|
- **Imports**: `.js` nodes — none at all (they run verbatim; assume the
|
|
151
179
|
instance allows no external or built-in modules). `.ts` nodes — imports
|
|
152
180
|
from inside this repo (`shared/`, relative paths) and from npm packages
|
|
@@ -157,7 +185,7 @@ The runtime contract (identical for `.js` and `.ts` — types are erased):
|
|
|
157
185
|
builtins (`node:*`, `fs`, `crypto`, …) are compile errors. Never
|
|
158
186
|
`require()`.
|
|
159
187
|
- `console.log` is available for debugging (visible in the execution view).
|
|
160
|
-
- `$('Node Name')` takes the node's **name** —
|
|
188
|
+
- `$('Node Name')` takes the node's **name** — so `rename` keeps it in sync.
|
|
161
189
|
|
|
162
190
|
### `.js` nodes (default — lossless)
|
|
163
191
|
|
|
@@ -292,18 +320,20 @@ Rules:
|
|
|
292
320
|
## Commands — who runs what
|
|
293
321
|
|
|
294
322
|
- **Offline, safe — run freely:** `n8n-decanter check` (or `npm run typecheck`),
|
|
295
|
-
`n8n-decanter run <file> [fixture]`, `n8n-decanter
|
|
296
|
-
`n8n-decanter executions clean`. These never
|
|
323
|
+
`n8n-decanter run <file> [fixture]`, `n8n-decanter <ref> add "<Node name>"`,
|
|
324
|
+
`n8n-decanter <ref> rename …`, `n8n-decanter executions clean`. These never
|
|
325
|
+
touch the network.
|
|
297
326
|
- **Reads remote, no writes:** `n8n-decanter status` (`--diff` shows the line
|
|
298
327
|
diffs; exits 1 on conflict/remote drift) and `n8n-decanter <ref> executions`
|
|
299
328
|
(fetches execution data, read-only) — safe to run, but they do contact the
|
|
300
329
|
instance.
|
|
301
330
|
- **Live n8n instance — only when the user explicitly asks:** `n8n-decanter
|
|
302
331
|
pull` / `push` / `watch` read/write the real instance and `push` deploys;
|
|
303
|
-
`create "<name>"` adds a new workflow
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
332
|
+
`create "<name>"` adds a new workflow and `<ref> duplicate` clones an existing
|
|
333
|
+
one into a new (unpublished) remote workflow, and `publish` / `unpublish`
|
|
334
|
+
change what is live (`publish` takes the draft live immediately, `unpublish`
|
|
335
|
+
returns it to draft-only). Otherwise finish edits, verify with `check` +
|
|
336
|
+
`run`, and report that the change is ready to push (or publish).
|
|
307
337
|
- **Destructive — never without an explicit instruction:** `n8n-decanter <ref>
|
|
308
338
|
delete` removes a workflow from the server (hard delete, even if published;
|
|
309
339
|
the local folder is kept as the git record). Run it only when the user has
|