n8n-decanter 0.3.0 → 0.3.2

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,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.2] - 2026-07-20
11
+
12
+ ### Fixed
13
+
14
+ - **Globally-installed CLI (`npm i -g n8n-decanter`) could crash on
15
+ `push`/`check`/`watch`'s typecheck gate** — it resolved the `typescript`
16
+ package relative to its own install location instead of the sync dir
17
+ being checked, which only ever worked when the CLI happened to be nested
18
+ inside the sync dir's `node_modules` (e.g. a local `devDependency`
19
+ install). A global install is never nested there, so the gate could fail
20
+ to find `typescript` at all. Now resolved relative to the sync dir first,
21
+ falling back to the CLI's own location.
22
+
23
+ ## [0.3.1] - 2026-07-20
24
+
25
+ ### Added
26
+
27
+ - **`publish` / `unpublish` verbs** close the n8n 2.x workflow lifecycle from
28
+ the CLI: `n8n-decanter <ref> publish` takes a draft live, `unpublish` returns
29
+ it to draft-only. Already-in-that-state is a no-op with a note, not an error.
30
+ A staged rollout is now `unpublish` → `push` → `publish` without leaving the
31
+ terminal.
32
+ - **`create` verb** — `n8n-decanter create "<name>"` creates a blank workflow
33
+ on the server (born unpublished) and immediately pulls it, so the folder and
34
+ the new id are ready to edit → push → `publish`.
35
+ - **`delete` verb** — `n8n-decanter <ref> delete` removes a workflow from the
36
+ server. It asks for a `y/N` confirmation naming the workflow; non-interactive
37
+ runs require `--force`. The **local folder is left untouched** as the
38
+ git-tracked record, and a stale `decanter.config.json` `workflows` entry is
39
+ flagged. Requires a ref (never deletes config workflows by default), one at a
40
+ time.
41
+
42
+ ### Changed
43
+
44
+ - **`status` is version-aware.** On a published workflow whose draft has moved
45
+ ahead of the live version (a UI edit not yet published), `status` now says
46
+ the live version is older than the draft (`push` or `publish` to catch it
47
+ up) instead of the plain `published` note.
48
+ - **`executions` warns on stale fixtures.** When a fetched execution ran a
49
+ published version different from your local draft, the fetch now warns that
50
+ the captured data may not match the code you're editing (still written — a
51
+ warning, not an error).
52
+ - The recommended **scoped API key** now includes `workflow:create`,
53
+ `workflow:delete`, `workflow:activate`, and `workflow:deactivate` so the new
54
+ lifecycle verbs work (`README`, `.env.example`).
55
+
10
56
  ## [0.3.0] - 2026-07-20
11
57
 
12
58
  ### Security
package/README.md CHANGED
@@ -2,14 +2,20 @@
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)
6
7
 
7
8
  **Work on n8n like a codebase — built for AI coding agents.**
8
9
 
10
+ **Pre-1.0 — breaking changes to the data model or CLI may ship in minor
11
+ versions until v1.0.**
12
+
9
13
  n8n-decanter syncs your n8n instance into a git-friendly, folder-per-workflow
10
14
  layout: every Code node's source becomes its own `.js` or `.ts` file,
11
15
  editable in your IDE or by your agent, and pushed back through the n8n API.
12
16
 
17
+ ![Terminal demo — the interactive picker: filter workflows, choose a verb, sync](./docs/terminal-demo.gif)
18
+
13
19
  - **Real version control** — meaningful diffs, PRs, blame; every push and
14
20
  pull is auto-committed.
15
21
  - **TypeScript or typed JS** — write nodes in TS (compiled on push); n8n
@@ -33,7 +39,9 @@ editable in your IDE or by your agent, and pushed back through the n8n API.
33
39
  behavior that touches the API is verified against a real n8n 2.x instance
34
40
  by an automated integration suite.
35
41
 
36
- ![VS Code Screenshot](./docs/screenshot.webp)
42
+ ![Agent demo — a coding agent edits a Code node, tests it offline, then pushes live](./docs/agent-demo.gif)
43
+
44
+ 📖 **Full documentation: [buttjer.github.io/n8n-decanter](https://buttjer.github.io/n8n-decanter/)**
37
45
 
38
46
  ## Setup
39
47
 
@@ -72,6 +80,8 @@ limited to the permissions the CLI actually uses rather than a full-access one,
72
80
  so a leaked `.env` has a small blast radius:
73
81
 
74
82
  - `workflow:read`, `workflow:list`, `workflow:update` — `pull` / `status` / `push`
83
+ - `workflow:create`, `workflow:delete` — `create` / `delete`
84
+ - `workflow:activate`, `workflow:deactivate` — `publish` / `unpublish`
75
85
  - `execution:read`, `execution:list` — the `executions` verb
76
86
 
77
87
  A full-access key also works if your n8n version predates scoped keys.
@@ -110,6 +120,12 @@ n8n-decanter <ref> rename "<old node>" "<new node>" # rename a node everywhere
110
120
  n8n-decanter <ref> rename --workflow "<new name>" # rename the workflow
111
121
  n8n-decanter [ref] watch # push a workflow's nodes on save
112
122
  # (+ browser live-reload, opt-in)
123
+ n8n-decanter [ref...] publish # take the draft(s) live
124
+ n8n-decanter [ref...] unpublish # (unpublish returns to draft-only)
125
+ n8n-decanter create "<name>" # create a blank workflow, then pull it
126
+ n8n-decanter <ref> delete [--force] # delete a workflow from the server
127
+ # (y/N confirm; --force skips it; the
128
+ # local folder is left untouched)
113
129
  n8n-decanter [ref...] executions [--status=success|error|waiting] [--limit=N]
114
130
  # fetch recent execution data (run JSON)
115
131
  # into workflows/<Name>/executions/
@@ -209,9 +225,13 @@ already run, so Claude Code on a Claude subscription needs no extra API tokens.
209
225
  update through the API** (pushing to an *unpublished* workflow only updates
210
226
  its draft). This is an n8n API limitation, not decanter's; decanter surfaces
211
227
  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.
228
+ draft only`, and `status`/`watch` show the state. The **`publish`** and
229
+ **`unpublish`** verbs take a draft live / return it to draft-only from the
230
+ CLI, so a staged rollout is `unpublish` → `push` → `publish` (triggers are
231
+ down in between) without leaving the terminal, or push to a staging-copy
232
+ workflow. On a published workflow whose draft has moved ahead in the UI,
233
+ `status` says the live version is older than the draft (`push` or `publish`
234
+ to catch it up).
215
235
  - **n8n's optimistic locking isn't reachable through the API.** That same `PUT`
216
236
  also forces `forceSave: true` and exposes no version checksum, so the server
217
237
  won't reject a stale write. Decanter's **drift guard** is the only thing
package/dist/lib/api.mjs CHANGED
@@ -48,6 +48,32 @@ 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 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.
57
+ */
58
+ async createWorkflow(name) {
59
+ return this.#request("POST", "/api/v1/workflows", { name, nodes: [], connections: {}, settings: {} });
60
+ }
61
+ /** Take the draft live (n8n 2.x publish). Idempotent server-side; returns the workflow. */
62
+ async activateWorkflow(id) {
63
+ return this.#request("POST", `/api/v1/workflows/${encodeURIComponent(id)}/activate`);
64
+ }
65
+ /** Return a published workflow to draft-only. Idempotent server-side; returns the workflow. */
66
+ async deactivateWorkflow(id) {
67
+ return this.#request("POST", `/api/v1/workflows/${encodeURIComponent(id)}/deactivate`);
68
+ }
69
+ /**
70
+ * Hard-delete a workflow (n8n 2.x `DELETE /workflows/:id`). Verified to delete
71
+ * even a *published* workflow outright (no refusal, no archive), so the CLI's
72
+ * confirmation gate is the only guard. Returns the deleted workflow.
73
+ */
74
+ async deleteWorkflow(id) {
75
+ return this.#request("DELETE", `/api/v1/workflows/${encodeURIComponent(id)}`);
76
+ }
51
77
  async #request(method, pathname, body) {
52
78
  try {
53
79
  // 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
  /**
@@ -0,0 +1,86 @@
1
+ import path from "node:path";
2
+ import { createPrompt } from "./prompt.mjs";
3
+ import { pullWorkflow } from "./pull.mjs";
4
+ import { findWorkflowDir } from "./state.mjs";
5
+ import { publicationState } from "./util.mjs";
6
+ /**
7
+ * `publish` — take an unpublished draft live (n8n 2.x activate). A workflow
8
+ * that is already published is a no-op-with-a-note, not an error: a push to a
9
+ * published workflow auto-publishes anyway. Needs credentials (not offline).
10
+ */
11
+ export async function publishWorkflow(api, id, log) {
12
+ const before = await api.getWorkflow(id);
13
+ if (publicationState(before) === "published") {
14
+ log.info(`"${before.name}" (${id}) is already published — code is live (pushes auto-publish)`);
15
+ return;
16
+ }
17
+ await api.activateWorkflow(id);
18
+ log.ok(`published "${before.name}" (${id}) — code is live now`);
19
+ }
20
+ /**
21
+ * `unpublish` — return a published workflow to draft-only (n8n 2.x deactivate).
22
+ * Already-unpublished is a no-op-with-a-note. Needs credentials (not offline).
23
+ */
24
+ export async function unpublishWorkflow(api, id, log) {
25
+ const before = await api.getWorkflow(id);
26
+ if (publicationState(before) === "unpublished") {
27
+ log.info(`"${before.name}" (${id}) is already unpublished — draft only`);
28
+ return;
29
+ }
30
+ await api.deactivateWorkflow(id);
31
+ log.ok(`unpublished "${before.name}" (${id}) — draft only`);
32
+ }
33
+ /**
34
+ * `create "<name>"` — a blank draft born on the server, then pulled so the
35
+ * folder + `.decanter.json` land and the id is printed. Born **unpublished**
36
+ * (`publish` takes it live), so create → edit → push → publish is a full CLI
37
+ * loop. The server still assigns the id and owns the birth (PLAN.md's "born in
38
+ * n8n" rule holds); the CLI only triggers it. Shares `api.createWorkflow` with
39
+ * Plan 21's `duplicate`.
40
+ */
41
+ export async function createWorkflow(api, config, name, log) {
42
+ const created = await api.createWorkflow(name);
43
+ const id = created.id;
44
+ log.ok(`created "${name}" (${id}) on the server — unpublished draft`);
45
+ const { dir } = await pullWorkflow(api, config.root, id, { commitOnPull: config.commitOnPull }, log);
46
+ log.info(`edit code/, push, then "publish" to go live`);
47
+ return { dir, id };
48
+ }
49
+ /**
50
+ * `<ref> delete` — hard-delete a workflow from the server, deliberately.
51
+ * Destructive and outward-facing, so consent is explicit: on a TTY a `y/N`
52
+ * prompt naming the workflow; non-interactive runs require `--force` and abort
53
+ * without it. The **local folder is never touched** — it stays as the
54
+ * git-tracked record — and a stale `decanter.config.json` `workflows` entry is
55
+ * flagged. One workflow per call (the dispatcher enforces exactly one ref).
56
+ */
57
+ export async function deleteWorkflow(api, config, id, { force = false } = {}, log) {
58
+ const remote = await api.getWorkflow(id); // authoritative name + proof it exists
59
+ const name = remote.name;
60
+ if (!force) {
61
+ if (!process.stdin.isTTY) {
62
+ throw new Error(`refusing to delete "${name}" (${id}) without confirmation — re-run with --force to delete in a non-interactive session`);
63
+ }
64
+ const rl = createPrompt();
65
+ let answer;
66
+ try {
67
+ answer = (await rl.question(`Delete workflow "${name}" (${id}) from the n8n server? This cannot be undone. [y/N] `)).trim().toLowerCase();
68
+ }
69
+ finally {
70
+ rl.close();
71
+ }
72
+ if (answer !== "y" && answer !== "yes") {
73
+ log.info("aborted — nothing deleted");
74
+ return;
75
+ }
76
+ }
77
+ await api.deleteWorkflow(id);
78
+ log.ok(`deleted "${name}" (${id}) from the server`);
79
+ const dir = findWorkflowDir(config.root, id, log);
80
+ if (dir) {
81
+ log.info(`local folder ${path.relative(process.cwd(), dir) || "."} left untouched — it's your git-tracked record`);
82
+ }
83
+ if (config.workflows.includes(id)) {
84
+ log.warn(`${id} is still listed in decanter.config.json "workflows" — remove it so pull/push/status stop targeting it`);
85
+ }
86
+ }
@@ -2,8 +2,9 @@
2
2
  // inited project. Two stages — type-to-filter workflow list (pulled green,
3
3
  // unpulled remote yellow), then a verb menu; unpulled entries pull directly.
4
4
  // The pure state machine (filter, reducer, window) is exported for unit
5
- // tests; only runPicker touches the terminal. Exists only behind a TTY gate,
6
- // so piped output never sees any of this.
5
+ // tests; runPicker's terminal IO takes injectable input/output streams
6
+ // (Plan 22) so test/interactive.mts can drive it without a real pty. Exists
7
+ // only behind a TTY gate at the CLI, so piped output never sees any of this.
7
8
  import { emitKeypressEvents } from "node:readline";
8
9
  import { style } from "./style.mjs";
9
10
  /** Verb menu for a pulled workflow, in display order. */
@@ -96,7 +97,7 @@ function reduceVerbKey(state, key) {
96
97
  }
97
98
  return { done: false, state };
98
99
  }
99
- // ---------- terminal IO (TTY only, untested by CI — keep this part thin) ----------
100
+ // ---------- terminal IO (covered by test/interactive.mts via injected streams) ----------
100
101
  const LIST_HEIGHT = 10;
101
102
  // Placeholder rows shown where remote workflows will appear while the list
102
103
  // loads: light-gray blocks shaped like "name id", varied widths on purpose.
@@ -144,33 +145,35 @@ function renderLines(state) {
144
145
  * already in flight; its workflows are appended as they arrive. `resume`
145
146
  * re-opens a workflow's verb menu (the picker loop passes the last run).
146
147
  * Resolves with the chosen verb+id+name, or "quit" (Esc) / "interrupted"
147
- * (Ctrl-C).
148
+ * (Ctrl-C). `input`/`output` default to the real TTY streams — tests inject
149
+ * a `PassThrough` pair instead of needing a real pty (no new dependency).
148
150
  */
149
151
  export async function runPicker(local, remote, opts = {}) {
152
+ const input = opts.input ?? process.stdin;
153
+ const output = opts.output ?? process.stdout;
150
154
  let state = initialState(local, remote !== undefined, opts);
151
155
  let prevLines = 0;
152
156
  const repaint = () => {
153
157
  const lines = renderLines(state);
154
158
  const up = prevLines > 0 ? `\x1b[${prevLines}A` : "";
155
- process.stdout.write(`${up}\r\x1b[J${lines.join("\n")}\n`);
159
+ output.write(`${up}\r\x1b[J${lines.join("\n")}\n`);
156
160
  prevLines = lines.length;
157
161
  };
158
162
  const erase = () => {
159
163
  if (prevLines > 0)
160
- process.stdout.write(`\x1b[${prevLines}A\r\x1b[J`);
164
+ output.write(`\x1b[${prevLines}A\r\x1b[J`);
161
165
  prevLines = 0;
162
166
  };
163
- const stdin = process.stdin;
164
- emitKeypressEvents(stdin);
165
- const wasRaw = stdin.isRaw === true;
167
+ emitKeypressEvents(input);
168
+ const wasRaw = input.isRaw === true;
166
169
  const restore = () => {
167
170
  if (!wasRaw)
168
- stdin.setRawMode(false);
169
- process.stdout.write("\x1b[?25h");
171
+ input.setRawMode?.(false);
172
+ output.write("\x1b[?25h");
170
173
  };
171
- stdin.setRawMode(true);
172
- stdin.resume();
173
- process.stdout.write("\x1b[?25l");
174
+ input.setRawMode?.(true);
175
+ input.resume();
176
+ output.write("\x1b[?25l");
174
177
  process.once("exit", restore); // belt and braces: never leave the terminal raw
175
178
  let finished = false;
176
179
  let onKeypress;
@@ -183,7 +186,7 @@ export async function runPicker(local, remote, opts = {}) {
183
186
  finished = true;
184
187
  resolve("quit");
185
188
  };
186
- stdin.once("end", onEnd);
189
+ input.once("end", onEnd);
187
190
  onKeypress = (_str, key) => {
188
191
  const step = reduceKey(state, key ?? {});
189
192
  if (step.done) {
@@ -208,19 +211,19 @@ export async function runPicker(local, remote, opts = {}) {
208
211
  state = { ...state, loadingRemote: false, notice: `remote list unavailable (${err.message.split("\n")[0]})` };
209
212
  repaint();
210
213
  });
211
- stdin.on("keypress", onKeypress);
214
+ input.on("keypress", onKeypress);
212
215
  repaint();
213
216
  });
214
217
  }
215
218
  finally {
216
219
  finished = true;
217
220
  if (onKeypress)
218
- stdin.removeListener("keypress", onKeypress);
221
+ input.removeListener("keypress", onKeypress);
219
222
  if (onEnd)
220
- stdin.removeListener("end", onEnd);
223
+ input.removeListener("end", onEnd);
221
224
  erase();
222
225
  restore();
223
226
  process.removeListener("exit", restore);
224
- stdin.pause();
227
+ input.pause();
225
228
  }
226
229
  }
package/dist/lib/pull.mjs CHANGED
@@ -124,11 +124,15 @@ export async function pullWorkflow(api, root, id, { commitOnPull = false } = {},
124
124
  const wfOut = structuredClone(wf);
125
125
  // Keep workflow.json to the workflow itself: n8n 2.x GET responses embed a
126
126
  // full server-side copy of the *published* version (`activeVersion`, code
127
- // included) and sharing metadata (`shared`) — derived data that would
128
- // duplicate every node's source in git and churn on each publish. Neither
129
- // field is pushable (sanitizeForPut whitelists), so dropping them loses
130
- // nothing.
127
+ // included), sharing metadata (`shared`), and the published-version pointer
128
+ // (`activeVersionId`) — derived data that would duplicate every node's source
129
+ // in git and/or churn on each publish. None is pushable (sanitizeForPut
130
+ // whitelists) and no local command reads them from workflow.json (the
131
+ // version-aware `status` reads `activeVersionId` straight off the live GET),
132
+ // so dropping them loses nothing. The draft `versionId` is kept — the
133
+ // executions stale-fixture warning compares against it.
131
134
  delete wfOut.activeVersion;
135
+ delete wfOut.activeVersionId;
132
136
  delete wfOut.shared;
133
137
  for (const node of wfOut.nodes) {
134
138
  const file = placeholders.get(node.id);
@@ -4,7 +4,7 @@ import { compileTs } from "./compile.mjs";
4
4
  import { unifiedDiff } from "./diff.mjs";
5
5
  import { findWorkflowDir, readState } from "./state.mjs";
6
6
  import { style } from "./style.mjs";
7
- import { isJsCodeNode, publicationState, sha256, splitMarker, workflowStructureHash } from "./util.mjs";
7
+ import { isJsCodeNode, publicationState, publishedVersionLagsDraft, sha256, splitMarker, workflowStructureHash } from "./util.mjs";
8
8
  /** The comparable local body: file content for .js, the compiled JS for .ts. */
9
9
  async function localBody(dir, file, log) {
10
10
  const filePath = path.join(dir, file);
@@ -27,7 +27,13 @@ export async function statusWorkflow(api, root, id, log, { diff = false } = {})
27
27
  };
28
28
  const state = readState(dir);
29
29
  const pub = publicationState(remote);
30
- log.info(`${remote.name} (${id}) [${path.relative(process.cwd(), dir)}]${pub ? ` ${pub}` : ""}`);
30
+ // Version-aware note: on a published workflow whose live version lags the
31
+ // draft (a UI edit not yet published), say so; otherwise the plain state word,
32
+ // or nothing when the server omits `active` (defensive, mirrors publicationState).
33
+ const pubNote = publishedVersionLagsDraft(remote)
34
+ ? ` published — live version is older than the draft (push or "publish" to go live)`
35
+ : pub ? ` ${pub}` : "";
36
+ log.info(`${remote.name} (${id}) [${path.relative(process.cwd(), dir)}]${pubNote}`);
31
37
  const remoteStruct = workflowStructureHash(remote);
32
38
  const wfFile = path.join(dir, "workflow.json");
33
39
  const localStruct = existsSync(wfFile)
package/dist/lib/util.mjs CHANGED
@@ -187,6 +187,21 @@ export function publicationState(wf) {
187
187
  return undefined;
188
188
  return wf.active ? "published" : "unpublished";
189
189
  }
190
+ /**
191
+ * True when a **published** workflow's live version lags its draft — i.e. the
192
+ * draft was edited (in the n8n UI) without being published, so the live code is
193
+ * older than what `pull` would bring down. Compares the n8n 2.x GET fields
194
+ * `versionId` (draft) and `activeVersionId` (published). Undefined when the
195
+ * workflow is unpublished or the server omits either field (mocks, exotic
196
+ * versions) — the same defensive stance as `publicationState`.
197
+ */
198
+ export function publishedVersionLagsDraft(wf) {
199
+ if (publicationState(wf) !== "published")
200
+ return undefined;
201
+ if (typeof wf?.versionId !== "string" || typeof wf?.activeVersionId !== "string")
202
+ return undefined;
203
+ return wf.activeVersionId !== wf.versionId;
204
+ }
190
205
  /**
191
206
  * Hash of the sanitized, code-stripped workflow — detects structural edits
192
207
  * (nodes added/moved/reconnected, settings changed) independent of code edits.
@@ -46,8 +46,15 @@ export function structureAction(localHash, remoteHash, baseline) {
46
46
  * With `browserReload: "proxy"` a transparent dev proxy boots first (Plan 5);
47
47
  * each successful push signals the browser tab to refresh (notifyPushed in
48
48
  * lib/proxy).
49
+ *
50
+ * `promptFactory` (default `createPrompt`) supplies the conflict-resolution
51
+ * prompt — injectable so tests can drive `[m]`/`[l]`/`[r]`/Enter with canned
52
+ * answers (Plan 22) without a real TTY. Passing one explicitly also bypasses
53
+ * the non-interactive (`!process.stdin.isTTY`) skip below, since a test
54
+ * deliberately supplying answers isn't the "no one is there to answer" case
55
+ * that skip exists for.
49
56
  */
50
- export async function watchWorkflow(api, config, id, { force = false } = {}, log) {
57
+ export async function watchWorkflow(api, config, id, { force = false, promptFactory } = {}, log) {
51
58
  const found = findWorkflowDir(config.root, id, log);
52
59
  if (!found)
53
60
  throw new Error(`workflow ${id} not found under ${config.root} — pull it first`);
@@ -151,11 +158,11 @@ export async function watchWorkflow(api, config, id, { force = false } = {}, log
151
158
  /** Interactive conflict resolution; returns true when the workflow got fully synced. */
152
159
  const resolveConflict = async (remote) => {
153
160
  log.error("structural conflict: workflow.json and the remote workflow both changed since last sync");
154
- if (!process.stdin.isTTY) {
161
+ if (!promptFactory && !process.stdin.isTTY) {
155
162
  log.error("non-interactive session — skipped the structural push; pull (or push --force) to resolve");
156
163
  return false;
157
164
  }
158
- const rl = createPrompt();
165
+ const rl = (promptFactory ?? createPrompt)();
159
166
  let choice;
160
167
  try {
161
168
  choice = (await rl.question(` [m]erge — write ${remoteJsonRel} for manual reconciliation\n` +
@@ -5,6 +5,7 @@ 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
9
  import { mergeRemote, runPicker } from "./lib/picker.mjs";
9
10
  import { pullWorkflow } from "./lib/pull.mjs";
10
11
  import { pushWorkflow } from "./lib/push.mjs";
@@ -51,6 +52,11 @@ const usage = () => {
51
52
  ${b("n8n-decanter")} [ref...] ${b("check")} [--no-typecheck] ${d("offline layout-compliance check")}
52
53
  ${b("n8n-decanter")} <ref> ${b("rename")} "<old node>" "<new node>" ${d("rename a node everywhere (offline)")}
53
54
  ${b("n8n-decanter")} <ref> ${b("rename")} --workflow "<new name>" ${d("rename the workflow itself")}
55
+ ${b("n8n-decanter")} [ref...] ${b("publish")} ${d("take the draft(s) live (unpublish returns to draft-only)")}
56
+ ${b("n8n-decanter")} [ref...] ${b("unpublish")}
57
+ ${b("n8n-decanter create")} "<name>" ${d("create a blank workflow on the server, then pull it")}
58
+ ${b("n8n-decanter")} <ref> ${b("delete")} [--force] ${d("delete a workflow from the server (y/N confirm;")}
59
+ ${d("--force skips it; the local folder is left untouched)")}
54
60
  ${b("n8n-decanter")} [ref] ${b("watch")} [--force] ${d("watch code/ + workflow.json, push on save")}
55
61
  ${d("(starts with a safety commit + pull; structural")}
56
62
  ${d("conflicts prompt; browser live-reload optional)")}
@@ -75,9 +81,9 @@ and flags may appear in any position too.
75
81
  Config: decanter.config.json (searched upward from cwd), credentials from .env
76
82
  next to it or the environment (N8N_HOST, N8N_API_KEY).`;
77
83
  };
78
- const VERBS = new Set(["init", "pull", "push", "status", "check", "rename", "watch", "run", "uuid", "list", "executions", "completion", "__complete", "help"]);
84
+ const VERBS = new Set(["init", "pull", "push", "status", "check", "rename", "watch", "run", "uuid", "list", "executions", "publish", "unpublish", "create", "delete", "completion", "__complete", "help"]);
79
85
  /** Verbs whose workflow arguments go through name resolution. */
80
- const REF_VERBS = new Set(["pull", "push", "status", "check", "watch"]);
86
+ const REF_VERBS = new Set(["pull", "push", "status", "check", "watch", "publish", "unpublish", "delete"]);
81
87
  // Both scripts delegate to the hidden `__complete` verb at completion time,
82
88
  // so candidates stay current without regenerating the script.
83
89
  const COMPLETION_SCRIPTS = {
@@ -445,6 +451,44 @@ async function dispatch(command, rest, flags) {
445
451
  process.exitCode = 1;
446
452
  break;
447
453
  }
454
+ case "publish":
455
+ case "unpublish": {
456
+ if (ids.length === 0) {
457
+ throw new Error('no workflow ids: pass them as arguments or list them in decanter.config.json "workflows"');
458
+ }
459
+ let failed = false;
460
+ for (const id of ids) {
461
+ try {
462
+ if (command === "publish")
463
+ await publishWorkflow(api, id, log);
464
+ else
465
+ await unpublishWorkflow(api, id, log);
466
+ }
467
+ catch (err) {
468
+ failed = true;
469
+ log.error(`${id}: ${err.message}`);
470
+ }
471
+ }
472
+ if (failed)
473
+ process.exitCode = 1;
474
+ break;
475
+ }
476
+ case "create": {
477
+ if (rest.length !== 1)
478
+ throw new Error('create needs exactly one name: n8n-decanter create "<name>"');
479
+ await createWorkflow(api, config, rest[0], log);
480
+ break;
481
+ }
482
+ case "delete": {
483
+ // Deliberately never falls back to config.workflows — a ref is required,
484
+ // one workflow per call (no cascade, too much blast radius for a default).
485
+ if (refs.length === 0)
486
+ throw new Error("delete needs a workflow ref: n8n-decanter <ref> delete");
487
+ if (refs.length > 1)
488
+ throw new Error("delete takes exactly one workflow — delete them one at a time");
489
+ await deleteWorkflow(api, config, refs[0], { force }, log);
490
+ break;
491
+ }
448
492
  case "rename": {
449
493
  const [id, ...names] = refs;
450
494
  if (!id)
@@ -5,10 +5,28 @@
5
5
  // in memory only — files on disk stay verbatim — and maps diagnostic line
6
6
  // numbers back. Node files are recognized by a .decanter.json sibling, or —
7
7
  // code/ layout — one in the parent of their code/ dir.
8
+ import { createRequire } from "node:module";
8
9
  import path from "node:path";
9
- import ts from "typescript";
10
10
  import { scanNodeImports } from "../lib/compile.mjs";
11
11
  import { nodeFileContextDir } from "../lib/state.mjs";
12
+ // `typescript` is a devDependency of *this* package, never a runtime one —
13
+ // a plain `import ts from "typescript"` resolves relative to this script's
14
+ // own location, which for a globally-installed CLI is nowhere near the sync
15
+ // dir being typechecked, so it can never find the sync dir's scaffolded
16
+ // `typescript` devDependency (plans/13). Resolve from cwd (the sync dir —
17
+ // lib/validate.mts spawns this script with cwd set to it) first, falling
18
+ // back to this script's own location for setups where the sync dir has no
19
+ // `typescript` of its own (e.g. this repo's own dev/test tree, where the
20
+ // CLI's own node_modules carries it).
21
+ function resolveTypescript() {
22
+ try {
23
+ return createRequire(path.join(process.cwd(), "package.json"))("typescript");
24
+ }
25
+ catch {
26
+ return createRequire(import.meta.url)("typescript");
27
+ }
28
+ }
29
+ const ts = resolveTypescript();
12
30
  const PREFIX = "async function __n8nNode() {\n";
13
31
  const SUFFIX = "\n}\nvoid __n8nNode;\n";
14
32
  // Optional dir arguments scope the *output*: the whole project is still
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-decanter",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Work on n8n like a codebase — built for AI coding agents",
5
5
  "keywords": [
6
6
  "n8n",
@@ -33,7 +33,7 @@
33
33
  "build": "tsc -p tsconfig.build.json",
34
34
  "prepack": "npm run build",
35
35
  "typecheck": "tsc -p tsconfig.cli.json && node scripts/typecheck.mts",
36
- "test": "node --test 'test/unit/*.test.mts' && node test/e2e.mts && node test/proxy.mts",
36
+ "test": "node --test 'test/unit/*.test.mts' && node test/e2e.mts && node test/proxy.mts && node test/interactive.mts",
37
37
  "test:smoke": "node test/smoke-n8n.mts"
38
38
  },
39
39
  "engines": {
@@ -4,7 +4,9 @@ N8N_HOST=https://your-n8n.example.com
4
4
  # Prefer a SCOPED API key (n8n → Settings → n8n API → Create) limited to the
5
5
  # permissions this tool actually uses, not a full-access key — smaller blast
6
6
  # radius if .env leaks:
7
- # workflow:read workflow:list workflow:update (pull / status / push)
8
- # execution:read execution:list (executions)
7
+ # workflow:read workflow:list workflow:update (pull / status / push)
8
+ # workflow:create workflow:delete (create / delete)
9
+ # workflow:activate workflow:deactivate (publish / unpublish)
10
+ # execution:read execution:list (executions)
9
11
  # A full-access key also works.
10
12
  N8N_API_KEY=n8n_api_...
@@ -36,9 +36,13 @@ runtime. It is **wrong** about how code is stored here. The deviations:
36
36
  pushes. Never edit `.remote.js` itself; never push while one exists.
37
37
  6. **Renames have no UI magic here.** The n8n editor auto-updates references on
38
38
  rename; raw JSON editing does not. See the rename checklist below.
39
- 7. **New workflows are born in n8n, not here.** The tool has no create. To add a
40
- workflow: create it (even empty) in the n8n UI, add its id to
41
- `decanter.config.json`, then the user runs `pull`.
39
+ 7. **New workflows are born on the server, not from a repo folder.** There is no
40
+ id-less folder that becomes a workflow on push. Use `n8n-decanter create
41
+ "<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`
44
+ takes it live, `unpublish` returns it to draft-only, and `<ref> delete`
45
+ removes one from the server (the local folder is kept).
42
46
 
43
47
  > **Enforcement.** Rules 1, 3, 4, 5 are *hard invariants* — violating them corrupts
44
48
  > sync state. They are enforced by the CLI at `push` time regardless of who made the
@@ -295,8 +299,17 @@ Rules:
295
299
  (fetches execution data, read-only) — safe to run, but they do contact the
296
300
  instance.
297
301
  - **Live n8n instance — only when the user explicitly asks:** `n8n-decanter
298
- pull` / `push` / `watch` read/write the real instance and `push` deploys.
299
- Otherwise finish edits, verify with `check` + `run`, and report that the
300
- change is ready to push.
302
+ 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).
307
+ - **Destructive — never without an explicit instruction:** `n8n-decanter <ref>
308
+ delete` removes a workflow from the server (hard delete, even if published;
309
+ the local folder is kept as the git record). Run it only when the user has
310
+ clearly asked to delete that specific workflow; it prompts for `y/N` and needs
311
+ `--force` to run non-interactively.
301
312
  - Never use `--force` on push without the user's explicit instruction — it
302
- overrides the drift guard that protects edits made in the n8n UI.
313
+ overrides the drift guard that protects edits made in the n8n UI. (On
314
+ `delete`, `--force` instead means "skip the confirmation" — same rule: only
315
+ on an explicit instruction.)