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 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
  [![npm](https://img.shields.io/npm/v/n8n-decanter)](https://www.npmjs.com/package/n8n-decanter)
5
5
  [![Docs](https://img.shields.io/badge/docs-website-blue.svg)](https://buttjer.github.io/n8n-decanter/)
6
6
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![vibe coded](https://img.shields.io/badge/vibe%20coded-with%20Claude%20Code-8A2BE2)](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). It never overwrites existing files (safe to re-run) unless you pass
68
- `--force`, which re-copies template files over existing ones (`.env` is never
69
- touched by it). When `.env` already holds both values, init skips the
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 `uuid` are fully
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
@@ -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 blank draft on the server (n8n 2.x `POST /workflows`). The minimal
53
- * accepted body is name + empty nodes/connections/settings (verified against
54
- * 2.30.7 — name-only is rejected). Born **unpublished** (`active:false`); the
55
- * caller pulls the returned id so the folder + state land. `createWorkflow` is
56
- * the shared entry point Plan 21's `duplicate` reuses.
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(name) {
59
- return this.#request("POST", "/api/v1/workflows", { name, nodes: [], connections: {}, settings: {} });
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
- function copyTemplate(srcDir, destDir, { force = false, protect = new Set() } = {}) {
56
- const overwritten = [];
57
- const walk = (src, dest, rel) => {
58
- mkdirSync(dest, { recursive: true });
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(dest, entry.name), path.join(rel, entry.name));
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 destPath = path.join(dest, name);
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
- if (exists && (!force || protect.has(destPath)))
71
- continue;
72
- copyFileSync(srcPath, destPath);
73
- if (exists)
74
- overwritten.push(path.join(rel, name));
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, destDir, "");
78
- return overwritten;
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 completely (whatever it contains, recursively).
111
- // Default: never overwrite files that already exist in the target.
112
- // --force re-copies template files over existing ones (.env excepted —
113
- // it was just written with real credentials).
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
- const overwritten = copyTemplate(TEMPLATE_DIR, dir, { force, protect: new Set([envFile]) });
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");
@@ -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`
@@ -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
- function renderLines(state) {
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(`${style.bold("?")} ${state.query === "" ? d("type to filter") : state.query}`);
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 name = e.pulled ? style.green(truncate(e.name, 48)) : style.yellow(truncate(e.name, 48));
125
- // "(not pulled)" in words, not only color — NO_COLOR/monochrome safety
126
- lines.push(`${pointer} ${name} ${d(e.id)}${e.pulled ? "" : ` ${d("(not pulled)")}`}`);
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 [nameWidth, idWidth] of SKELETON_WIDTHS) {
134
- lines.push(` ${d("░".repeat(nameWidth))} ${d("░".repeat(idWidth))}`);
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
+ }
@@ -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("(--force re-copies template files over existing ones)")}
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", "watch", "run", "uuid", "list", "executions", "publish", "unpublish", "create", "delete", "completion", "__complete", "help"]);
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.2",
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",
@@ -6,7 +6,7 @@
6
6
  "Bash(n8n-decanter check)",
7
7
  "Bash(n8n-decanter check:*)",
8
8
  "Bash(n8n-decanter run:*)",
9
- "Bash(n8n-decanter uuid:*)",
9
+ "Bash(n8n-decanter add:*)",
10
10
  "Bash(n8n-decanter status:*)",
11
11
  "Bash(npm run typecheck)",
12
12
  "Bash(npm run check)"
@@ -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
- it (see `SCAFFOLD.md`). Keep project-specific rules here so every agent stays in sync.
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; raw JSON editing does not. See the rename checklist below.
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) — or create it in the n8n UI and `pull`. Then add its id to
43
- `decanter.config.json`. A created workflow is **unpublished**; `publish`
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 uuid` to generate; NEVER change an existing id
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 — checklist (all four, atomically)
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 (keep the kebab-case
112
- name-file convention, file stays in `code/`). The tool re-maps by node id
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**: change `name` in `workflow.json`; folder rename happens
116
- on the next pull (or do it yourself, folder names are cosmetic — the id inside
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. Add the node object — generate its `id` with `n8n-decanter uuid` (a lowercase
122
- v4 UUID), placeholder `jsCode`, position, connections.
123
- 2. Create the source file it points at in `code/` (`.js` or `.ts`, see below).
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 (extend `n8n-globals.d.ts` when you use one it lacks):
145
- `$input.all()/.first()/.last()/.item` · `$('Node Name').all()/.first()/.item/.itemMatching(i)` ·
146
- `$json`, `$binary`, `$itemIndex` (each-item mode) ·
147
- `DateTime`, `Duration`, `Interval` (Luxon), `$now`, `$today` ·
148
- `$jmespath()` · `$workflow`, `$execution`, `$prevNode`, `$runIndex` ·
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** — see the rename checklist.
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 uuid [n]`,
296
- `n8n-decanter executions clean`. These never touch the network.
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, and `publish` / `unpublish` change what
304
- is live (`publish` takes the draft live immediately, `unpublish` returns it to
305
- draft-only). Otherwise finish edits, verify with `check` + `run`, and report
306
- that the change is ready to push (or publish).
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