n8n-decanter 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,122 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-07-20
11
+
12
+ ### Changed
13
+
14
+ - **Refreshed the scaffolded agent guide (`AGENTS.md`).** It now steers agents
15
+ to the `rename` and `duplicate` verbs (rename led with the command instead of
16
+ a hand-edit checklist, `duplicate` added to the new-workflow and command
17
+ taxonomies), opens with a compact "short version" of the hard invariants,
18
+ points at `n8n-globals.d.ts` as the authoritative globals list instead of an
19
+ inline copy that could drift, and drops a stale reference to a non-existent
20
+ `SCAFFOLD.md`.
21
+
22
+ ## [0.4.0] - 2026-07-20
23
+
24
+ ### Added
25
+
26
+ - **`add` verb** — `n8n-decanter <ref> add "<Node name>" [--ts]` scaffolds a
27
+ Code node into a pulled workflow in one offline step: it mints the node id,
28
+ writes the `code/` source file (kebab-case, with the `-<id8>` collision
29
+ suffix), adds the node object plus its `//@file:` placeholder, and registers
30
+ it in `.decanter.json`, then re-checks the folder. The node lands
31
+ **disconnected** (wire it in the editor); `--ts` scaffolds a `.ts` source.
32
+ The next `push` propagates it.
33
+ - **`duplicate` verb** — `n8n-decanter <ref> duplicate ["<new name>"]` clones an
34
+ already-pulled workflow into a **new workflow on the server** and pulls the
35
+ copy. The clone carries the repo's current content (placeholders
36
+ reconstituted from `code/`, `.ts` nodes compiled), is born **unpublished**,
37
+ and defaults its name to `"<name> (copy)"`. The source folder and the source
38
+ remote workflow are left untouched.
39
+
40
+ ### Removed
41
+
42
+ - **Breaking: the `uuid` verb is gone.** Its only job was minting a node id for
43
+ hand-adding a Code node — now `add` does the whole scaffold (id included) in
44
+ one guard-checked step, so a bare id generator is redundant. Use
45
+ `n8n-decanter <ref> add "<Node name>"` instead.
46
+
47
+ ## [0.3.4] - 2026-07-20
48
+
49
+ ### Added
50
+
51
+ - **Modification-aware template refresh.** `init` now records a copy-time
52
+ baseline of every template file in a git-tracked `.decanter-template.json`
53
+ manifest. Re-running `init` uses it to refresh files you haven't touched
54
+ (after a confirm), pull in files newly added to the template, and **leave
55
+ your local edits alone** — reporting them as drift instead of silently
56
+ keeping the old version. Files that changed in both the template and your
57
+ copy are flagged as conflicts and left untouched.
58
+
59
+ ### Changed
60
+
61
+ - **Re-running `init` is no longer all-or-nothing.** Previously the default
62
+ refused to overwrite anything and `--force` clobbered every template file.
63
+ Now the default is modification-aware (see above); `--force` is unchanged —
64
+ the escape hatch that overwrites everything, now noting which files "had
65
+ local changes" as it goes.
66
+
67
+ ## [0.3.3] - 2026-07-20
68
+
69
+ ### Changed
70
+
71
+ - **Interactive picker got a visual refresh.** Each workflow row now leads with
72
+ a `●` (pulled) / `○` (not pulled) status glyph and the ids line up in an
73
+ aligned column; each stage carries a short title (`pick a workflow` over the
74
+ list, the workflow name over its verb menu). The state distinction is now
75
+ carried by the glyph *shape*, so the per-row `(not pulled)` words are gone —
76
+ the key is stated once in a footer legend (`● pulled · ○ not pulled`), and
77
+ the output stays legible under `NO_COLOR`. Behavior (filtering, navigation,
78
+ verbs) is unchanged.
79
+
80
+ ## [0.3.2] - 2026-07-20
81
+
82
+ ### Fixed
83
+
84
+ - **Globally-installed CLI (`npm i -g n8n-decanter`) could crash on
85
+ `push`/`check`/`watch`'s typecheck gate** — it resolved the `typescript`
86
+ package relative to its own install location instead of the sync dir
87
+ being checked, which only ever worked when the CLI happened to be nested
88
+ inside the sync dir's `node_modules` (e.g. a local `devDependency`
89
+ install). A global install is never nested there, so the gate could fail
90
+ to find `typescript` at all. Now resolved relative to the sync dir first,
91
+ falling back to the CLI's own location.
92
+
93
+ ## [0.3.1] - 2026-07-20
94
+
95
+ ### Added
96
+
97
+ - **`publish` / `unpublish` verbs** close the n8n 2.x workflow lifecycle from
98
+ the CLI: `n8n-decanter <ref> publish` takes a draft live, `unpublish` returns
99
+ it to draft-only. Already-in-that-state is a no-op with a note, not an error.
100
+ A staged rollout is now `unpublish` → `push` → `publish` without leaving the
101
+ terminal.
102
+ - **`create` verb** — `n8n-decanter create "<name>"` creates a blank workflow
103
+ on the server (born unpublished) and immediately pulls it, so the folder and
104
+ the new id are ready to edit → push → `publish`.
105
+ - **`delete` verb** — `n8n-decanter <ref> delete` removes a workflow from the
106
+ server. It asks for a `y/N` confirmation naming the workflow; non-interactive
107
+ runs require `--force`. The **local folder is left untouched** as the
108
+ git-tracked record, and a stale `decanter.config.json` `workflows` entry is
109
+ flagged. Requires a ref (never deletes config workflows by default), one at a
110
+ time.
111
+
112
+ ### Changed
113
+
114
+ - **`status` is version-aware.** On a published workflow whose draft has moved
115
+ ahead of the live version (a UI edit not yet published), `status` now says
116
+ the live version is older than the draft (`push` or `publish` to catch it
117
+ up) instead of the plain `published` note.
118
+ - **`executions` warns on stale fixtures.** When a fetched execution ran a
119
+ published version different from your local draft, the fetch now warns that
120
+ the captured data may not match the code you're editing (still written — a
121
+ warning, not an error).
122
+ - The recommended **scoped API key** now includes `workflow:create`,
123
+ `workflow:delete`, `workflow:activate`, and `workflow:deactivate` so the new
124
+ lifecycle verbs work (`README`, `.env.example`).
125
+
10
126
  ## [0.3.0] - 2026-07-20
11
127
 
12
128
  ### Security
package/README.md CHANGED
@@ -2,14 +2,25 @@
2
2
 
3
3
  [![CI](https://github.com/buttjer/n8n-decanter/actions/workflows/ci.yml/badge.svg)](https://github.com/buttjer/n8n-decanter/actions/workflows/ci.yml)
4
4
  [![npm](https://img.shields.io/npm/v/n8n-decanter)](https://www.npmjs.com/package/n8n-decanter)
5
+ [![Docs](https://img.shields.io/badge/docs-website-blue.svg)](https://buttjer.github.io/n8n-decanter/)
5
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)
6
8
 
7
9
  **Work on n8n like a codebase — built for AI coding agents.**
8
10
 
11
+ **Pre-1.0 — breaking changes to the data model or CLI may ship in minor
12
+ versions until v1.0.**
13
+
14
+ > **Built with AI agents.** Much of this codebase was written by Claude Code
15
+ > under human review. It's tested (CI + a real-n8n integration suite) and used
16
+ > in earnest, but treat pre-1.0 the way the version implies.
17
+
9
18
  n8n-decanter syncs your n8n instance into a git-friendly, folder-per-workflow
10
19
  layout: every Code node's source becomes its own `.js` or `.ts` file,
11
20
  editable in your IDE or by your agent, and pushed back through the n8n API.
12
21
 
22
+ ![Terminal demo — the interactive picker: filter workflows, choose a verb, sync](./docs/terminal-demo.gif)
23
+
13
24
  - **Real version control** — meaningful diffs, PRs, blame; every push and
14
25
  pull is auto-committed.
15
26
  - **TypeScript or typed JS** — write nodes in TS (compiled on push); n8n
@@ -33,7 +44,9 @@ editable in your IDE or by your agent, and pushed back through the n8n API.
33
44
  behavior that touches the API is verified against a real n8n 2.x instance
34
45
  by an automated integration suite.
35
46
 
36
- ![VS Code Screenshot](./docs/screenshot.webp)
47
+ ![Agent demo — a coding agent edits a Code node, tests it offline, then pushes live](./docs/agent-demo.gif)
48
+
49
+ 📖 **Full documentation: [buttjer.github.io/n8n-decanter](https://buttjer.github.io/n8n-decanter/)**
37
50
 
38
51
  ## Setup
39
52
 
@@ -56,9 +69,11 @@ directly, no build needed.
56
69
 
57
70
  `init` copies everything in [template/](template/); files named `X.example`
58
71
  land as `X` (the suffix keeps agent configs inert in this repo, live in the
59
- target). It never overwrites existing files (safe to re-run) unless you pass
60
- `--force`, which re-copies template files over existing ones (`.env` is never
61
- 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
62
77
  prompts and reuses them — edit or delete `.env` to change credentials. It
63
78
  also does a best-effort credential check. Alternatively set up manually: `cp .env.example .env` and
64
79
  fill it in. Then add workflow ids to `decanter.config.json`:
@@ -72,6 +87,8 @@ limited to the permissions the CLI actually uses rather than a full-access one,
72
87
  so a leaked `.env` has a small blast radius:
73
88
 
74
89
  - `workflow:read`, `workflow:list`, `workflow:update` — `pull` / `status` / `push`
90
+ - `workflow:create`, `workflow:delete` — `create` / `delete`
91
+ - `workflow:activate`, `workflow:deactivate` — `publish` / `unpublish`
75
92
  - `execution:read`, `execution:list` — the `executions` verb
76
93
 
77
94
  A full-access key also works if your n8n version predates scoped keys.
@@ -88,7 +105,7 @@ PostToolUse hook that runs `check` after node edits. Verification routes through
88
105
  the CLI, so `n8n-decanter` must be on the sync dir's PATH: install it globally
89
106
  (`npm i -g n8n-decanter`), add it to the sync dir's `devDependencies`, or
90
107
  `npm link` a git checkout (build it first — Node won't type-strip `.mts`
91
- under `node_modules`). The verbs `check`, `run`, and `uuid` are fully
108
+ under `node_modules`). The verbs `check`, `run`, `rename`, and `add` are fully
92
109
  offline (no credentials, no network).
93
110
 
94
111
  ## Commands
@@ -108,8 +125,16 @@ n8n-decanter [ref...] status [--diff] # drift report (--diff: line diffs);
108
125
  n8n-decanter [ref...] check # offline layout-compliance + typecheck
109
126
  n8n-decanter <ref> rename "<old node>" "<new node>" # rename a node everywhere
110
127
  n8n-decanter <ref> rename --workflow "<new name>" # rename the workflow
128
+ n8n-decanter <ref> add "<Node name>" [--ts] # scaffold a Code node (offline)
111
129
  n8n-decanter [ref] watch # push a workflow's nodes on save
112
130
  # (+ browser live-reload, opt-in)
131
+ n8n-decanter [ref...] publish # take the draft(s) live
132
+ n8n-decanter [ref...] unpublish # (unpublish returns to draft-only)
133
+ n8n-decanter create "<name>" # create a blank workflow, then pull it
134
+ n8n-decanter <ref> duplicate ["<new name>"] # clone a workflow, then pull it
135
+ n8n-decanter <ref> delete [--force] # delete a workflow from the server
136
+ # (y/N confirm; --force skips it; the
137
+ # local folder is left untouched)
113
138
  n8n-decanter [ref...] executions [--status=success|error|waiting] [--limit=N]
114
139
  # fetch recent execution data (run JSON)
115
140
  # into workflows/<Name>/executions/
@@ -120,7 +145,6 @@ n8n-decanter list [--remote] # pulled workflows: name, id, folder
120
145
  # (--remote adds unpulled ones)
121
146
  n8n-decanter completion zsh|bash # print a shell completion script
122
147
  n8n-decanter <node-file> run [fixture.json] [--allow-env] # run a node offline, print items
123
- n8n-decanter uuid [count] # lowercase v4 UUID(s) for new node ids
124
148
  ```
125
149
 
126
150
  A workflow `<ref>` is its **id, its workflow/folder name, or a unique name
@@ -209,9 +233,13 @@ already run, so Claude Code on a Claude subscription needs no extra API tokens.
209
233
  update through the API** (pushing to an *unpublished* workflow only updates
210
234
  its draft). This is an n8n API limitation, not decanter's; decanter surfaces
211
235
  the outcome — push prints `published: code is live now` vs `unpublished:
212
- draft only`, and `status`/`watch` show the state. For a staged rollout,
213
- unpublish → push → publish (triggers are down in between), or push to a
214
- staging-copy workflow.
236
+ draft only`, and `status`/`watch` show the state. The **`publish`** and
237
+ **`unpublish`** verbs take a draft live / return it to draft-only from the
238
+ CLI, so a staged rollout is `unpublish` → `push` → `publish` (triggers are
239
+ down in between) without leaving the terminal, or push to a staging-copy
240
+ workflow. On a published workflow whose draft has moved ahead in the UI,
241
+ `status` says the live version is older than the draft (`push` or `publish`
242
+ to catch it up).
215
243
  - **n8n's optimistic locking isn't reachable through the API.** That same `PUT`
216
244
  also forces `forceSave: true` and exposes no version checksum, so the server
217
245
  won't reject a stale write. Decanter's **drift guard** is the only thing
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { findWorkflowDir, readState, writeState } from "./state.mjs";
5
+ import { CODE_DIR, CODE_NODE_TYPE, FILE_PLACEHOLDER_PREFIX, kebabCase, stableWorkflowJson } from "./util.mjs";
6
+ import { validateWorkflowDir } from "./validate.mjs";
7
+ /**
8
+ * Starter source for a freshly scaffolded Code node: valid, self-contained
9
+ * (no `$('…')` references, so it lands compliant), and it typechecks under the
10
+ * n8n ambient globals — the same body for `.js` and `.ts`.
11
+ */
12
+ const DEFAULT_SOURCE = `// New Code node — runs once for all items. Edit me, then push.
13
+ // See https://docs.n8n.io/code/ for the Code node API.
14
+ for (const item of $input.all()) {
15
+ item.json.myNewField = 1;
16
+ }
17
+
18
+ return $input.all();
19
+ `;
20
+ /** Rightmost node's x + a step, so a scaffolded node doesn't stack on another. */
21
+ function nextPosition(nodes) {
22
+ let maxX = 0;
23
+ for (const node of nodes) {
24
+ const pos = node.position;
25
+ if (Array.isArray(pos) && typeof pos[0] === "number")
26
+ maxX = Math.max(maxX, pos[0]);
27
+ }
28
+ return [maxX + 220, 0];
29
+ }
30
+ /**
31
+ * Scaffold a `n8n-nodes-base.code` node into a pulled workflow in one atomic,
32
+ * guard-checked step (offline; `push` propagates). Mints a v4 uuid, writes the
33
+ * kebab-case source file under `code/` (sharing pull/rename's `-<id8>` collision
34
+ * suffix), appends the node object (default parameters, `mode:
35
+ * runOnceForAllItems`, a `//@file:` placeholder) to `workflow.json`, and
36
+ * registers it in `.decanter.json`. **No connections are wired** — the node
37
+ * lands disconnected but compliant; wiring stays in the editor. Re-validates
38
+ * afterwards and fails loudly on any violation, exactly like `rename`.
39
+ */
40
+ export function addCodeNode(root, id, nodeName, { ts = false } = {}, log) {
41
+ nodeName = nodeName.trim();
42
+ if (!nodeName)
43
+ throw new Error("node name must not be empty");
44
+ const dir = findWorkflowDir(root, id, log);
45
+ if (!dir)
46
+ throw new Error(`workflow ${id} not found under ${root} — pull it first`);
47
+ const wfFile = path.join(dir, "workflow.json");
48
+ if (!existsSync(wfFile))
49
+ throw new Error(`missing workflow.json in ${dir} — pull first`);
50
+ let wf;
51
+ try {
52
+ wf = JSON.parse(readFileSync(wfFile, "utf8"));
53
+ }
54
+ catch (err) {
55
+ throw new Error(`${wfFile}: invalid JSON (${err.message})`);
56
+ }
57
+ if (wf.nodes.some((n) => n.name === nodeName))
58
+ throw new Error(`a node named "${nodeName}" already exists in "${wf.name}"`);
59
+ const nodeId = randomUUID();
60
+ const ext = ts ? ".ts" : ".js";
61
+ mkdirSync(path.join(dir, CODE_DIR), { recursive: true });
62
+ let base = kebabCase(nodeName);
63
+ if (existsSync(path.join(dir, CODE_DIR, `${base}${ext}`))) {
64
+ base = `${base}-${nodeId.slice(0, 8)}`;
65
+ if (existsSync(path.join(dir, CODE_DIR, `${base}${ext}`))) {
66
+ throw new Error(`cannot add "${nodeName}": both kebab-case targets exist (${CODE_DIR}/${base}${ext})`);
67
+ }
68
+ }
69
+ const file = `${CODE_DIR}/${base}${ext}`;
70
+ writeFileSync(path.join(dir, file), DEFAULT_SOURCE);
71
+ const node = {
72
+ id: nodeId,
73
+ name: nodeName,
74
+ type: CODE_NODE_TYPE,
75
+ typeVersion: 2,
76
+ position: nextPosition(wf.nodes),
77
+ parameters: { mode: "runOnceForAllItems", jsCode: FILE_PLACEHOLDER_PREFIX + file },
78
+ };
79
+ wf.nodes.push(node);
80
+ writeFileSync(wfFile, stableWorkflowJson(wf));
81
+ const state = readState(dir);
82
+ if (state) {
83
+ state.nodes[nodeId] = { file };
84
+ writeState(dir, state);
85
+ }
86
+ log.info(`added Code node "${nodeName}" (${nodeId}) -> ${file} — disconnected; wire it in the editor, then push to propagate`);
87
+ const { errors } = validateWorkflowDir(dir);
88
+ if (errors.length > 0) {
89
+ throw new Error(`add left the workflow non-compliant — files were written, inspect (git diff) and fix:\n${errors.map((e) => ` ${e}`).join("\n")}`);
90
+ }
91
+ }
package/dist/lib/api.mjs CHANGED
@@ -48,6 +48,33 @@ export class N8nApi {
48
48
  async updateWorkflow(id, body) {
49
49
  return this.#request("PUT", `/api/v1/workflows/${encodeURIComponent(id)}`, body);
50
50
  }
51
+ /**
52
+ * Create a workflow on the server (n8n 2.x `POST /workflows`). The required
53
+ * fields are name + nodes/connections/settings (verified against 2.30.7 —
54
+ * name-only is rejected), so `create` posts empty collections while
55
+ * `duplicate` posts the cloned nodes/connections. Born **unpublished**
56
+ * (`active:false`); the caller pulls the returned id so the folder + state
57
+ * land. Shared by `create` (blank) and `duplicate` (Plan 21).
58
+ */
59
+ async createWorkflow(body) {
60
+ return this.#request("POST", "/api/v1/workflows", body);
61
+ }
62
+ /** Take the draft live (n8n 2.x publish). Idempotent server-side; returns the workflow. */
63
+ async activateWorkflow(id) {
64
+ return this.#request("POST", `/api/v1/workflows/${encodeURIComponent(id)}/activate`);
65
+ }
66
+ /** Return a published workflow to draft-only. Idempotent server-side; returns the workflow. */
67
+ async deactivateWorkflow(id) {
68
+ return this.#request("POST", `/api/v1/workflows/${encodeURIComponent(id)}/deactivate`);
69
+ }
70
+ /**
71
+ * Hard-delete a workflow (n8n 2.x `DELETE /workflows/:id`). Verified to delete
72
+ * even a *published* workflow outright (no refusal, no archive), so the CLI's
73
+ * confirmation gate is the only guard. Returns the deleted workflow.
74
+ */
75
+ async deleteWorkflow(id) {
76
+ return this.#request("DELETE", `/api/v1/workflows/${encodeURIComponent(id)}`);
77
+ }
51
78
  async #request(method, pathname, body) {
52
79
  try {
53
80
  // The signal also covers body consumption, so a stalled response
@@ -1,7 +1,37 @@
1
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { findWorkflowDir, listWorkflowDirs } from "./state.mjs";
4
4
  export const EXECUTIONS_DIR = "executions";
5
+ /**
6
+ * Warn when fetched executions ran a *published* version that differs from the
7
+ * local draft (`workflow.json`'s `versionId`) — the captured data may not match
8
+ * the code you're now editing (PLAN.md's "convenience data, not ground truth"
9
+ * caveat, enforced). A warning, not an error: the data is still useful. Deduped
10
+ * by version so a page of same-version executions warns once; silent when the
11
+ * draft version or the execution version is unavailable (defensive).
12
+ */
13
+ function warnStaleFixtures(dir, executions, log) {
14
+ const wfFile = path.join(dir, "workflow.json");
15
+ if (!existsSync(wfFile))
16
+ return;
17
+ let draft;
18
+ try {
19
+ draft = JSON.parse(readFileSync(wfFile, "utf8")).versionId;
20
+ }
21
+ catch {
22
+ return; // unparsable workflow.json — nothing to compare against
23
+ }
24
+ if (typeof draft !== "string")
25
+ return;
26
+ const stale = new Set();
27
+ for (const exec of executions) {
28
+ if (typeof exec.workflowVersionId === "string" && exec.workflowVersionId !== draft)
29
+ stale.add(exec.workflowVersionId);
30
+ }
31
+ for (const ran of stale) {
32
+ log.warn(`captured executions ran published version ${ran}; your draft is ${draft} — the data may not match the code you're editing`);
33
+ }
34
+ }
5
35
  /**
6
36
  * Materialize execution JSON under `<workflow>/executions/`. The dir is made
7
37
  * self-ignoring via an `executions/.gitignore` containing `*` — execution
@@ -30,6 +60,7 @@ export async function fetchExecutions(api, root, workflowId, { status, limit },
30
60
  return;
31
61
  }
32
62
  writeExecutionFiles(dir, executions, log);
63
+ warnStaleFixtures(dir, executions, log);
33
64
  log.ok(`${executions.length} execution${executions.length === 1 ? "" : "s"} -> ${path.relative(process.cwd(), path.join(dir, EXECUTIONS_DIR))} (gitignored — temp data, "executions clean" removes it)`);
34
65
  }
35
66
  /**
@@ -45,6 +76,7 @@ export async function fetchExecutionById(api, root, executionId, log) {
45
76
  if (!dir)
46
77
  throw new Error(`execution ${executionId} belongs to workflow ${workflowId}, which is not pulled under ${root} — pull it first`);
47
78
  writeExecutionFiles(dir, [exec], log);
79
+ warnStaleFixtures(dir, [exec], log);
48
80
  log.ok(`execution ${executionId} -> ${path.relative(process.cwd(), path.join(dir, EXECUTIONS_DIR))}`);
49
81
  }
50
82
  /**
package/dist/lib/init.mjs CHANGED
@@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url";
4
4
  import { parseEnvFile } from "./config.mjs";
5
5
  import { createPrompt } from "./prompt.mjs";
6
6
  import { style } from "./style.mjs";
7
+ import { classifyTemplateFile, MANIFEST_FILE, readManifest, writeManifest } from "./template.mjs";
8
+ import { sha256 } from "./util.mjs";
7
9
  /**
8
10
  * Nearest ancestor of `startDir` holding a package.json — the package root.
9
11
  * Works from the checkout (lib/ → repo root) *and* from the published build
@@ -52,30 +54,151 @@ export function printBanner(log) {
52
54
  console.log(style.red(row.slice(0, 6)) + style.bold(row.slice(6)));
53
55
  console.log(style.dim(`n8n workflows ⇄ agentic code · v${version}`));
54
56
  }
55
- 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");