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 +46 -0
- package/README.md +24 -4
- package/dist/lib/api.mjs +26 -0
- package/dist/lib/executions.mjs +33 -1
- package/dist/lib/lifecycle.mjs +86 -0
- package/dist/lib/picker.mjs +22 -19
- package/dist/lib/pull.mjs +8 -4
- package/dist/lib/status.mjs +8 -2
- package/dist/lib/util.mjs +15 -0
- package/dist/lib/watch.mjs +10 -3
- package/dist/n8n-decanter.mjs +46 -2
- package/dist/scripts/typecheck.mjs +19 -1
- package/package.json +2 -2
- package/template/.env.example +4 -2
- package/template/AGENTS.md.example +20 -7
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
|
[](https://github.com/buttjer/n8n-decanter/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/n8n-decanter)
|
|
5
|
+
[](https://buttjer.github.io/n8n-decanter/)
|
|
5
6
|
[](LICENSE)
|
|
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
|
+

|
|
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
|
-

|
|
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.
|
|
213
|
-
unpublish
|
|
214
|
-
|
|
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
|
package/dist/lib/executions.mjs
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { findWorkflowDir, listWorkflowDirs } from "./state.mjs";
|
|
4
4
|
export const EXECUTIONS_DIR = "executions";
|
|
5
|
+
/**
|
|
6
|
+
* Warn when fetched executions ran a *published* version that differs from the
|
|
7
|
+
* local draft (`workflow.json`'s `versionId`) — the captured data may not match
|
|
8
|
+
* the code you're now editing (PLAN.md's "convenience data, not ground truth"
|
|
9
|
+
* caveat, enforced). A warning, not an error: the data is still useful. Deduped
|
|
10
|
+
* by version so a page of same-version executions warns once; silent when the
|
|
11
|
+
* draft version or the execution version is unavailable (defensive).
|
|
12
|
+
*/
|
|
13
|
+
function warnStaleFixtures(dir, executions, log) {
|
|
14
|
+
const wfFile = path.join(dir, "workflow.json");
|
|
15
|
+
if (!existsSync(wfFile))
|
|
16
|
+
return;
|
|
17
|
+
let draft;
|
|
18
|
+
try {
|
|
19
|
+
draft = JSON.parse(readFileSync(wfFile, "utf8")).versionId;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return; // unparsable workflow.json — nothing to compare against
|
|
23
|
+
}
|
|
24
|
+
if (typeof draft !== "string")
|
|
25
|
+
return;
|
|
26
|
+
const stale = new Set();
|
|
27
|
+
for (const exec of executions) {
|
|
28
|
+
if (typeof exec.workflowVersionId === "string" && exec.workflowVersionId !== draft)
|
|
29
|
+
stale.add(exec.workflowVersionId);
|
|
30
|
+
}
|
|
31
|
+
for (const ran of stale) {
|
|
32
|
+
log.warn(`captured executions ran published version ${ran}; your draft is ${draft} — the data may not match the code you're editing`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
5
35
|
/**
|
|
6
36
|
* Materialize execution JSON under `<workflow>/executions/`. The dir is made
|
|
7
37
|
* self-ignoring via an `executions/.gitignore` containing `*` — execution
|
|
@@ -30,6 +60,7 @@ export async function fetchExecutions(api, root, workflowId, { status, limit },
|
|
|
30
60
|
return;
|
|
31
61
|
}
|
|
32
62
|
writeExecutionFiles(dir, executions, log);
|
|
63
|
+
warnStaleFixtures(dir, executions, log);
|
|
33
64
|
log.ok(`${executions.length} execution${executions.length === 1 ? "" : "s"} -> ${path.relative(process.cwd(), path.join(dir, EXECUTIONS_DIR))} (gitignored — temp data, "executions clean" removes it)`);
|
|
34
65
|
}
|
|
35
66
|
/**
|
|
@@ -45,6 +76,7 @@ export async function fetchExecutionById(api, root, executionId, log) {
|
|
|
45
76
|
if (!dir)
|
|
46
77
|
throw new Error(`execution ${executionId} belongs to workflow ${workflowId}, which is not pulled under ${root} — pull it first`);
|
|
47
78
|
writeExecutionFiles(dir, [exec], log);
|
|
79
|
+
warnStaleFixtures(dir, [exec], log);
|
|
48
80
|
log.ok(`execution ${executionId} -> ${path.relative(process.cwd(), path.join(dir, EXECUTIONS_DIR))}`);
|
|
49
81
|
}
|
|
50
82
|
/**
|
|
@@ -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
|
+
}
|
package/dist/lib/picker.mjs
CHANGED
|
@@ -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;
|
|
6
|
-
// so
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
output.write(`\x1b[${prevLines}A\r\x1b[J`);
|
|
161
165
|
prevLines = 0;
|
|
162
166
|
};
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const wasRaw = stdin.isRaw === true;
|
|
167
|
+
emitKeypressEvents(input);
|
|
168
|
+
const wasRaw = input.isRaw === true;
|
|
166
169
|
const restore = () => {
|
|
167
170
|
if (!wasRaw)
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
input.setRawMode?.(false);
|
|
172
|
+
output.write("\x1b[?25h");
|
|
170
173
|
};
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
+
input.on("keypress", onKeypress);
|
|
212
215
|
repaint();
|
|
213
216
|
});
|
|
214
217
|
}
|
|
215
218
|
finally {
|
|
216
219
|
finished = true;
|
|
217
220
|
if (onKeypress)
|
|
218
|
-
|
|
221
|
+
input.removeListener("keypress", onKeypress);
|
|
219
222
|
if (onEnd)
|
|
220
|
-
|
|
223
|
+
input.removeListener("end", onEnd);
|
|
221
224
|
erase();
|
|
222
225
|
restore();
|
|
223
226
|
process.removeListener("exit", restore);
|
|
224
|
-
|
|
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)
|
|
128
|
-
// duplicate every node's source
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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);
|
package/dist/lib/status.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/lib/watch.mjs
CHANGED
|
@@ -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` +
|
package/dist/n8n-decanter.mjs
CHANGED
|
@@ -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.
|
|
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": {
|
package/template/.env.example
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
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
|
|
40
|
-
|
|
41
|
-
`
|
|
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
|
-
|
|
300
|
-
|
|
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.)
|