n8n-decanter 0.6.0 → 0.7.0
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 +139 -0
- package/README.md +4 -1
- package/dist/lib/backup.mjs +34 -24
- package/dist/lib/init.mjs +180 -41
- package/dist/lib/pull.mjs +12 -3
- package/dist/lib/push.mjs +5 -16
- package/dist/lib/run.mjs +79 -12
- package/dist/lib/skills.mjs +126 -0
- package/dist/lib/state.mjs +29 -1
- package/dist/lib/validate.mjs +35 -0
- package/dist/n8n-decanter.mjs +47 -13
- package/{template/n8n-globals.d.ts.example → n8n-globals.d.ts} +16 -3
- package/package.json +11 -2
- package/template/.claude/{settings.local.json.example → settings.json.example} +2 -1
- package/template/AGENTS.md.example +34 -7
- package/template/CLAUDE.md.example +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,145 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-07-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`check` now warns when local work has not been registered with n8n** — a
|
|
15
|
+
node whose `//@file:` placeholder has moved off what `.decanter.json` records
|
|
16
|
+
(the shape of a `.js`→`.ts` conversion), or whose recorded file is gone from
|
|
17
|
+
disk. It stays a *warning*, not an error: `push` reconciles the file map, and
|
|
18
|
+
the compliance guard runs before that reconcile, so failing here would refuse
|
|
19
|
+
the one command that fixes it.
|
|
20
|
+
|
|
21
|
+
- **`n8n-decanter --version` prints the installed version** (`-v` too), the way
|
|
22
|
+
every CLI is expected to. It answers before any config load or verb dispatch,
|
|
23
|
+
so it works from anywhere — including outside a sync dir. Passed *alongside* a
|
|
24
|
+
verb it is a hard error naming the flag you meant, so a stray `--version`
|
|
25
|
+
can't quietly swallow a command.
|
|
26
|
+
|
|
27
|
+
- **A first `init` points at n8n's official skills pack.** Setup now closes by
|
|
28
|
+
naming [n8n-io/skills](https://github.com/n8n-io/skills) — the knowledge layer
|
|
29
|
+
that makes agentic workflow building work — and printing the install commands
|
|
30
|
+
for **Claude Code**, **Codex**, and **skills.sh**, with the agent it detects
|
|
31
|
+
from your environment listed first and the activation step each one still
|
|
32
|
+
needs. It prints; it does not install: that would mean spawning a third-party
|
|
33
|
+
CLI to mutate agent state outside the sync dir, and a plugin installed
|
|
34
|
+
mid-session isn't active until the agent reloads. Said once per sync dir (no
|
|
35
|
+
re-init repeats it), on every path including piped and `--host`-driven runs,
|
|
36
|
+
and it consumes no input — no existing script's stdin changes. *(Plan 55.)*
|
|
37
|
+
|
|
38
|
+
- **`init` can run non-interactively via `--host` / `--token` / `--api-key`.**
|
|
39
|
+
Passing any of them drives setup purely from the flags plus any existing
|
|
40
|
+
`.env` and issues **no prompt** — so a script or coding agent can bootstrap a
|
|
41
|
+
sync dir without the interactive stdin dance (the field-test agents needed
|
|
42
|
+
20+ tries to drive the old prompt path). `--host` is required in this mode
|
|
43
|
+
(a scheme-less local host is normalized to `http://`, like a typed one) and
|
|
44
|
+
wins over an existing `.env` value; `--token` sets `N8N_MCP_TOKEN` (headless
|
|
45
|
+
OAuth is still terminal-only); `--api-key` sets the optional `N8N_API_KEY`.
|
|
46
|
+
The flag-less path (interactive, or piped answers) is unchanged. *(Plan 35
|
|
47
|
+
field-test finding.)*
|
|
48
|
+
|
|
49
|
+
- **`node run` now emulates `$jmespath`.** A Code node that calls
|
|
50
|
+
`$jmespath(data, expr)` (or the `$jmesPath` alias) runs offline, matching
|
|
51
|
+
n8n's result (backed by `jmespath@0.16.0`, the version n8n pins). It also
|
|
52
|
+
fills in `$items()`/`$node` (views over the fixture's `nodes`), `$vars`/
|
|
53
|
+
`$secrets` (new fixture fields), and `$nodeId`/`$nodeVersion`/`$webhookId`.
|
|
54
|
+
|
|
55
|
+
- **`node run` fixtures gained `vars` and `secrets`** to pin the instance-scoped
|
|
56
|
+
`$vars`/`$secrets` when a node reads them.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- **Breaking:** the scaffolded Claude Code settings moved from
|
|
61
|
+
**`.claude/settings.local.json` to `.claude/settings.json`**, and `init` now
|
|
62
|
+
migrates existing sync dirs. The file holds *project policy* — decanter's verb
|
|
63
|
+
permissions plus the `verify.mjs` and `mcp-route-check.mjs` hooks — with
|
|
64
|
+
nothing machine-specific in it; it was already being committed and tracked in
|
|
65
|
+
the shared `.decanter-template.json`, so `local` was the wrong scope, and it
|
|
66
|
+
squatted the one file Claude Code reserves for **your** own overrides. The
|
|
67
|
+
local slot is now yours: permission lists merge across the two files and a
|
|
68
|
+
`deny` beats an `allow`, so a local file can add to the policy but cannot
|
|
69
|
+
unblock what the project denies. On re-init, an untouched copy is moved for
|
|
70
|
+
you; a copy you edited is left exactly where it is and the new file is **not**
|
|
71
|
+
written (both would register their hooks) — `init` says what to move, and
|
|
72
|
+
`--force` resolves it by removing the old file. A `settings.local.json` that
|
|
73
|
+
`init` never wrote is never touched. *(Plan 56.)*
|
|
74
|
+
|
|
75
|
+
- **Breaking: `backup restore` takes the backup as an argument, not a flag —
|
|
76
|
+
`backup restore <workflow> [<backup>]`.** `--version <id>` and `--at <ts>`
|
|
77
|
+
are gone. The argument is a **backup ref** resolved by shape, exactly like a
|
|
78
|
+
`<workflow>` ref: paste a timestamp (or a prefix — a bare date is enough) or a
|
|
79
|
+
`versionId` (short or full), whichever column of `backup list` you have to
|
|
80
|
+
hand. `backup restore order-sync 2026-07-24` and `backup restore order-sync
|
|
81
|
+
a1b2c3d4` both just work; a ref that matches nothing is an error, never a
|
|
82
|
+
silent fall back to the latest. The retired flags fail loudly with the
|
|
83
|
+
replacement. This also un-squats `--version`, which no CLI can spend on a
|
|
84
|
+
verb-scoped meaning (see Added).
|
|
85
|
+
|
|
86
|
+
- **`check`'s success line now states its scope** —
|
|
87
|
+
`OK (local layout — status compares with n8n)` instead of a bare `OK`. `check`
|
|
88
|
+
is offline by definition, so green means "well-formed", never "live in n8n".
|
|
89
|
+
The agent guide gained the matching rule, and the `.js`→`.ts` recipe now ends
|
|
90
|
+
at `push` rather than `check` — it previously told you to stop one step short
|
|
91
|
+
of the conversion actually reaching the instance. *(Surfaced by the Plan 35
|
|
92
|
+
blind field test: three separate sessions authored code, read a green `check`
|
|
93
|
+
as "done", and never pushed.)*
|
|
94
|
+
|
|
95
|
+
- **`scenario create` / `scenario check` with no workflow now open the picker on
|
|
96
|
+
a terminal**, like every other ref-taking verb (`pull`, `push`, `backup …`, …).
|
|
97
|
+
They previously hard-errored with a usage line even on a TTY, which made them
|
|
98
|
+
the odd ones out. Piped / non-TTY runs are unchanged — still the usage error —
|
|
99
|
+
so scripts and agent harnesses never block on a prompt. *(Surfaced by the Plan
|
|
100
|
+
35 blind field test, where an agent tripped the inconsistency twice.)*
|
|
101
|
+
|
|
102
|
+
- **`node run` signposts instead of crashing on instance-scoped globals.** A
|
|
103
|
+
global whose value lives on the running instance (`$vars`/`$secrets` when
|
|
104
|
+
unpinned, `$evaluateExpression`) now throws a friendly message that names the
|
|
105
|
+
global and points to `test` (or the fixture field) — never a bare
|
|
106
|
+
`ReferenceError`. `docs/cli/node-run.md` documents the covered / partial /
|
|
107
|
+
unsupported boundary.
|
|
108
|
+
|
|
109
|
+
### Fixed
|
|
110
|
+
|
|
111
|
+
- **The documented Claude Code skills-install commands are no longer
|
|
112
|
+
copy-paste-broken.** `/plugin marketplace add` / `/plugin install` are
|
|
113
|
+
in-session slash commands, but the docs and the scaffolded `AGENTS.md`
|
|
114
|
+
presented them as shell commands. Both now show the in-session form and the
|
|
115
|
+
real shell equivalents (`claude plugin marketplace add …` /
|
|
116
|
+
`claude plugin install …`) separately, plus the post-install activation step
|
|
117
|
+
each agent needs.
|
|
118
|
+
|
|
119
|
+
- **A `.js`→`.ts` conversion is no longer reverted by a pull that fires before
|
|
120
|
+
the first TS push.** Re-pointing a node's `//@file:` placeholder to a `.ts`
|
|
121
|
+
file and swapping the source is the sanctioned way to convert a node, but a
|
|
122
|
+
`pull` landing in the window before the first TS `push` — notably the
|
|
123
|
+
on-by-default live-mirror background refresh after a structure edit — rewrote
|
|
124
|
+
the placeholder back to `.js` and left `.decanter.json` pointing at the
|
|
125
|
+
deleted `.js` file, so the next push failed with `referenced node file
|
|
126
|
+
missing`. Pull now honors the re-pointed placeholder exactly as push does
|
|
127
|
+
(they share one reconcile step). *(Plan 35 field-test finding.)*
|
|
128
|
+
|
|
129
|
+
- **`init` no longer breaks local `http` instances.** A scheme-less host typed
|
|
130
|
+
at the `n8n host:` prompt now defaults to `http://` for local addresses
|
|
131
|
+
(`localhost`, loopback, private LAN ranges, `*.local`) and `https://`
|
|
132
|
+
otherwise. Previously every scheme-less host got `https://`, so a local n8n
|
|
133
|
+
(plain http) was written to `.env` as a TLS URL and every sync/guard call
|
|
134
|
+
failed with `fetch failed`. A scheme you type is still kept as-is.
|
|
135
|
+
|
|
136
|
+
- **`n8n-globals.d.ts` no longer over-declares `$if`/`$min`/`$max`.** Those are
|
|
137
|
+
n8n *expression-language* helpers (`{{ }}` only), not Code-node globals — they
|
|
138
|
+
throw in a real Code node too — so declaring them wrongly type-checked broken
|
|
139
|
+
code. The declared surface now matches what a Code node actually sees, and is
|
|
140
|
+
single-sourced (init copies the one root file — no duplicate template copy).
|
|
141
|
+
|
|
142
|
+
- The scaffolded agent permission allowlist (`.claude/settings.json` — see the
|
|
143
|
+
move under Changed) now
|
|
144
|
+
pre-approves the read-only **`preflight`** gate, so an agent following the
|
|
145
|
+
template's recommended `edit → check → preflight → push` loop no longer stalls
|
|
146
|
+
on a permission prompt at the gate itself. Also dropped the obsolete
|
|
147
|
+
`*.remote.js` deny rule — those artifacts were removed in the Plan 32 MCP pivot.
|
|
148
|
+
|
|
10
149
|
## [0.6.0] - 2026-07-23
|
|
11
150
|
|
|
12
151
|
### Added
|
package/README.md
CHANGED
|
@@ -105,6 +105,9 @@ through [`mcp connect`](docs/cli/mcp-connect.md), a guard that forwards
|
|
|
105
105
|
everything except writes to a Code node's `jsCode` — those redirect back to
|
|
106
106
|
the repo (edit the file, `push`). No secret to manage.
|
|
107
107
|
|
|
108
|
+
**A first [`init`](docs/cli/init.md) prints the install commands** for Claude
|
|
109
|
+
Code, Codex, or any skills.sh-supported agent, listing the one it detects first.
|
|
110
|
+
|
|
108
111
|
**Full guide: [Using n8n's official skills](docs/agents/n8n-skills.md).**
|
|
109
112
|
|
|
110
113
|
## Commands
|
|
@@ -115,7 +118,7 @@ Full flag reference: `n8n-decanter --help`, or the
|
|
|
115
118
|
|
|
116
119
|
| Verb | What it does |
|
|
117
120
|
|---|---|
|
|
118
|
-
| `init [dir]` |
|
|
121
|
+
| `init [dir]` | Bootstrap a sync dir — OAuth/token, template, config, agent tooling (`--host`/`--token`/`--api-key` drive it non-interactively) |
|
|
119
122
|
| `completion zsh\|bash` | Print a shell completion script |
|
|
120
123
|
| `pull [workflow…]` | Code + structure snapshot → `workflows/<kebab>/` |
|
|
121
124
|
| `push [workflow…]` | Push Code-node source to the workflow's **draft** |
|
package/dist/lib/backup.mjs
CHANGED
|
@@ -156,17 +156,17 @@ async function assembleForRestore(dir, backup, log) {
|
|
|
156
156
|
return { body, nodes };
|
|
157
157
|
}
|
|
158
158
|
/**
|
|
159
|
-
* `backup restore` — git → instance. Select a backup (latest by default;
|
|
160
|
-
*
|
|
159
|
+
* `backup restore` — git → instance. Select a backup (latest by default; a
|
|
160
|
+
* positional backup ref, or a TTY chooser), re-inline the Code-node source,
|
|
161
161
|
* compliance-guard the assembly, and REST-POST a NEW, unpublished workflow
|
|
162
162
|
* (new workflow id; node ids preserved). Prints credential-rebind hints + the
|
|
163
163
|
* editor URL; publish is the operator's next step.
|
|
164
164
|
*/
|
|
165
|
-
export async function backupRestore(api, dir, { host,
|
|
165
|
+
export async function backupRestore(api, dir, { host, ref, interactive = false }, log) {
|
|
166
166
|
const backups = listBackups(dir);
|
|
167
167
|
if (backups.length === 0)
|
|
168
168
|
throw new Error(`no backups in ${path.basename(dir)}/${BACKUPS_DIR}/ — run \`backup create\` first`);
|
|
169
|
-
const selected = await selectBackup(backups, {
|
|
169
|
+
const selected = await selectBackup(backups, { ref, interactive }, log);
|
|
170
170
|
if (selected === null)
|
|
171
171
|
return null; // user quit the chooser
|
|
172
172
|
const backup = JSON.parse(readFileSync(selected.file, "utf8"));
|
|
@@ -187,28 +187,38 @@ export async function backupRestore(api, dir, { host, version, at, interactive =
|
|
|
187
187
|
log.info(style.dim("it landed unpublished — review, rebind credentials, then publish to go live"));
|
|
188
188
|
return { id: created.id };
|
|
189
189
|
}
|
|
190
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return
|
|
190
|
+
/**
|
|
191
|
+
* A **backup ref** resolves like a workflow ref does — untyped, by shape, over
|
|
192
|
+
* the two keys `backup list` prints. Both are in the filename
|
|
193
|
+
* (`<timestamp>.<shortVersionId>.json`), so one positional covers both without
|
|
194
|
+
* the caller having to say which it meant:
|
|
195
|
+
*
|
|
196
|
+
* - **timestamp** — exact, or any prefix of the filename (a bare date works)
|
|
197
|
+
* - **versionId** — the short one in the filename, the full one the user pastes
|
|
198
|
+
* from n8n (a prefix of which the short id is the head), or the full one
|
|
199
|
+
* stored *inside* the backup
|
|
200
|
+
*
|
|
201
|
+
* The two key spaces don't collide in practice (timestamps lead with a date,
|
|
202
|
+
* versionIds are uuids), so first-match-wins needs no tie-break.
|
|
203
|
+
*/
|
|
204
|
+
function matchesBackupRef(b, ref) {
|
|
205
|
+
if (b.timestamp === ref || b.name.startsWith(ref))
|
|
206
|
+
return true;
|
|
207
|
+
if (b.versionId === ref || ref.startsWith(b.versionId))
|
|
208
|
+
return true;
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(readFileSync(b.file, "utf8")).versionId === ref;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return false;
|
|
207
214
|
}
|
|
208
|
-
|
|
209
|
-
|
|
215
|
+
}
|
|
216
|
+
/** Pick which backup to restore: an explicit ref, else a TTY chooser, else latest. */
|
|
217
|
+
async function selectBackup(backups, { ref, interactive }, log) {
|
|
218
|
+
if (ref !== undefined) {
|
|
219
|
+
const hit = backups.find((b) => matchesBackupRef(b, ref));
|
|
210
220
|
if (!hit)
|
|
211
|
-
throw new Error(`no backup
|
|
221
|
+
throw new Error(`no backup matching "${ref}" in ${BACKUPS_DIR}/ — pass a timestamp or versionId from \`backup list\``);
|
|
212
222
|
return hit;
|
|
213
223
|
}
|
|
214
224
|
const latest = backups.at(-1);
|
package/dist/lib/init.mjs
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { parseEnvFile } from "./config.mjs";
|
|
5
5
|
import { AUTH_FILE, McpClient, openBrowserCommand, readAuthFile, runOAuthConsent, searchWorkflows, writeAuthFile, } from "./mcp.mjs";
|
|
6
6
|
import { PROXY_STATE_FILE } from "./mcpserve.mjs";
|
|
7
7
|
import { createPrompt } from "./prompt.mjs";
|
|
8
|
+
import { detectAgent, printSkillsRecommendation } from "./skills.mjs";
|
|
8
9
|
import { style } from "./style.mjs";
|
|
9
10
|
import { classifyTemplateFile, MANIFEST_FILE, readManifest, writeManifest } from "./template.mjs";
|
|
10
11
|
import { sha256 } from "./util.mjs";
|
|
12
|
+
/**
|
|
13
|
+
* Normalize a user-entered n8n host into a full origin. A scheme the user typed
|
|
14
|
+
* is kept as-is; a scheme-less host gets `http://` when it is a LOCAL address
|
|
15
|
+
* (localhost, loopback, private LAN ranges, `*.local`) and `https://` otherwise.
|
|
16
|
+
* A local n8n almost always serves plain http, so blindly defaulting to https
|
|
17
|
+
* left `.env` pointing at a TLS endpoint that doesn't exist — every sync/guard
|
|
18
|
+
* fetch then failed with `fetch failed` (Plan 35 field-test finding). Trailing
|
|
19
|
+
* slashes are stripped.
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeHostInput(raw) {
|
|
22
|
+
const host = raw.trim();
|
|
23
|
+
if (/^https?:\/\//i.test(host))
|
|
24
|
+
return host.replace(/\/+$/, "");
|
|
25
|
+
const isLocal = /^(localhost|0\.0\.0\.0|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|\[::1\]|::1|[a-z0-9-]+\.local)(:\d+)?$/i.test(host);
|
|
26
|
+
return ((isLocal ? "http://" : "https://") + host).replace(/\/+$/, "");
|
|
27
|
+
}
|
|
11
28
|
/** Like readAuthFile, but a corrupt file only warns — init re-mints it. */
|
|
12
29
|
function readAuthFileTolerant(dir, log) {
|
|
13
30
|
try {
|
|
@@ -38,8 +55,8 @@ export function packageRootFrom(startDir) {
|
|
|
38
55
|
}
|
|
39
56
|
const PACKAGE_ROOT = packageRootFrom(path.dirname(fileURLToPath(import.meta.url)));
|
|
40
57
|
const TEMPLATE_DIR = path.join(PACKAGE_ROOT, "template");
|
|
41
|
-
/** Own package version (banner); tolerant of an unreadable package.json. */
|
|
42
|
-
function cliVersion() {
|
|
58
|
+
/** Own package version (banner, `--version`); tolerant of an unreadable package.json. */
|
|
59
|
+
export function cliVersion() {
|
|
43
60
|
try {
|
|
44
61
|
return JSON.parse(readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")).version ?? "0.0.0";
|
|
45
62
|
}
|
|
@@ -68,10 +85,31 @@ export function printBanner(log) {
|
|
|
68
85
|
}
|
|
69
86
|
/**
|
|
70
87
|
* Classify every template file against the target dir and the copy-time
|
|
71
|
-
* baseline manifest. Pure scan — no files are written.
|
|
88
|
+
* baseline manifest. Pure scan — no files are written. `extraSources` folds in
|
|
89
|
+
* files authored *outside* `template/` that init still materializes into the
|
|
90
|
+
* sync dir (Plan 43: `n8n-globals.d.ts` is sourced from the single root file,
|
|
91
|
+
* not a byte-identical `template/*.example` duplicate) — they flow through the
|
|
92
|
+
* identical pristine/drift/manifest logic, keyed by their materialized rel path.
|
|
72
93
|
*/
|
|
73
|
-
function scanTemplate(srcDir, destDir, manifest, protect) {
|
|
94
|
+
function scanTemplate(srcDir, destDir, manifest, protect, extraSources = []) {
|
|
74
95
|
const entries = [];
|
|
96
|
+
const classify = (srcPath, materializedRel) => {
|
|
97
|
+
const destPath = path.join(destDir, materializedRel);
|
|
98
|
+
if (protect.has(destPath))
|
|
99
|
+
return; // .env: written separately, never manifest-tracked
|
|
100
|
+
const templateHash = sha256(readFileSync(srcPath, "utf8"));
|
|
101
|
+
const exists = existsSync(destPath);
|
|
102
|
+
const targetHash = exists ? sha256(readFileSync(destPath, "utf8")) : undefined;
|
|
103
|
+
const manifestHash = manifest[materializedRel];
|
|
104
|
+
entries.push({
|
|
105
|
+
rel: materializedRel,
|
|
106
|
+
srcPath,
|
|
107
|
+
destPath,
|
|
108
|
+
templateHash,
|
|
109
|
+
targetHash,
|
|
110
|
+
outcome: classifyTemplateFile({ exists, targetHash, templateHash, manifestHash }),
|
|
111
|
+
});
|
|
112
|
+
};
|
|
75
113
|
const walk = (src, rel) => {
|
|
76
114
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
77
115
|
const srcPath = path.join(src, entry.name);
|
|
@@ -82,31 +120,97 @@ function scanTemplate(srcDir, destDir, manifest, protect) {
|
|
|
82
120
|
const name = entry.name.endsWith(".example") && entry.name !== ".example"
|
|
83
121
|
? entry.name.slice(0, -".example".length)
|
|
84
122
|
: entry.name;
|
|
85
|
-
|
|
86
|
-
const destPath = path.join(destDir, materializedRel);
|
|
87
|
-
if (protect.has(destPath))
|
|
88
|
-
continue; // .env: written separately, never manifest-tracked
|
|
89
|
-
const templateHash = sha256(readFileSync(srcPath, "utf8"));
|
|
90
|
-
const exists = existsSync(destPath);
|
|
91
|
-
const targetHash = exists ? sha256(readFileSync(destPath, "utf8")) : undefined;
|
|
92
|
-
const manifestHash = manifest[materializedRel];
|
|
93
|
-
entries.push({
|
|
94
|
-
rel: materializedRel,
|
|
95
|
-
srcPath,
|
|
96
|
-
destPath,
|
|
97
|
-
templateHash,
|
|
98
|
-
targetHash,
|
|
99
|
-
outcome: classifyTemplateFile({ exists, targetHash, templateHash, manifestHash }),
|
|
100
|
-
});
|
|
123
|
+
classify(srcPath, path.join(rel, name));
|
|
101
124
|
}
|
|
102
125
|
};
|
|
103
126
|
walk(srcDir, "");
|
|
127
|
+
for (const s of extraSources)
|
|
128
|
+
classify(s.srcPath, s.rel);
|
|
104
129
|
return entries;
|
|
105
130
|
}
|
|
106
131
|
function copyEntry(entry) {
|
|
107
132
|
mkdirSync(path.dirname(entry.destPath), { recursive: true });
|
|
108
133
|
copyFileSync(entry.srcPath, entry.destPath);
|
|
109
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Template files that changed NAME between CLI versions, as materialized rel
|
|
137
|
+
* paths. The manifest is keyed by path, so without this a rename reads as
|
|
138
|
+
* "delete one file, add another" and the two copies coexist — which for a
|
|
139
|
+
* settings file means the stale one silently keeps applying.
|
|
140
|
+
*
|
|
141
|
+
* Plan 56: `.claude/settings.local.json` → `.claude/settings.json`. The file
|
|
142
|
+
* holds *project policy* (decanter's verb permissions, the `verify.mjs` and
|
|
143
|
+
* `mcp-route-check.mjs` hooks) — nothing machine-specific — and it was already
|
|
144
|
+
* being committed (init's `.gitignore` never covered it) and tracked in the
|
|
145
|
+
* shared `.decanter-template.json`. Scaffolding it into the `local` slot both
|
|
146
|
+
* mislabeled it and squatted the one file Claude Code reserves for the *user's*
|
|
147
|
+
* own machine-specific overrides.
|
|
148
|
+
*/
|
|
149
|
+
const TEMPLATE_RENAMES = [
|
|
150
|
+
{ from: path.join(".claude", "settings.local.json"), to: path.join(".claude", "settings.json") },
|
|
151
|
+
];
|
|
152
|
+
/**
|
|
153
|
+
* Resolve each rename against a target dir, before the template scan runs.
|
|
154
|
+
* Returns the destination paths the scan must NOT write this run, plus manifest
|
|
155
|
+
* entries to carry over — a deferred migration has to stay tracked, or the next
|
|
156
|
+
* re-init (whose manifest is rebuilt from scanned template files, and so has
|
|
157
|
+
* forgotten the old name) can no longer tell decanter's leftover file from one
|
|
158
|
+
* the user wrote, and gives up on it forever.
|
|
159
|
+
*
|
|
160
|
+
* Deliberately file-driven rather than manifest-driven: a rename does not
|
|
161
|
+
* change the file's *contents*, so "is this decanter's copy, untouched?" is
|
|
162
|
+
* answerable by hashing — which also works for dirs that pre-date manifests.
|
|
163
|
+
*
|
|
164
|
+
* - The old file is not ours (no manifest entry, contents ≠ the template) →
|
|
165
|
+
* leave it completely alone. It is the user's own personal settings file, and
|
|
166
|
+
* the new project-scoped one is scaffolded alongside it as normal.
|
|
167
|
+
* - Ours and pristine → delete it; the scan then lands the new name.
|
|
168
|
+
* - Ours but locally edited → keep it and **skip scaffolding the new name**.
|
|
169
|
+
* Writing both would double-register the PostToolUse/SessionStart hooks, so
|
|
170
|
+
* the user is told to move their file instead. The next init picks up where
|
|
171
|
+
* they left off, because this check reads the filesystem, not a flag.
|
|
172
|
+
* - Both names already present → touch nothing, just report the shadowing.
|
|
173
|
+
*
|
|
174
|
+
* `--force` keeps its documented meaning (reset every template file to its
|
|
175
|
+
* template version): it removes the old name even when edited, rather than
|
|
176
|
+
* leaving a stale file to shadow the reset copy.
|
|
177
|
+
*/
|
|
178
|
+
function migrateRenamedTemplateFiles(srcDir, destDir, manifest, force, log) {
|
|
179
|
+
const skip = new Set();
|
|
180
|
+
const keep = {};
|
|
181
|
+
for (const { from, to } of TEMPLATE_RENAMES) {
|
|
182
|
+
const oldPath = path.join(destDir, from);
|
|
183
|
+
if (!existsSync(oldPath))
|
|
184
|
+
continue;
|
|
185
|
+
const newPath = path.join(destDir, to);
|
|
186
|
+
const oldHash = sha256(readFileSync(oldPath, "utf8"));
|
|
187
|
+
const templateSrc = path.join(srcDir, `${to}.example`);
|
|
188
|
+
const templateHash = existsSync(templateSrc) ? sha256(readFileSync(templateSrc, "utf8")) : "";
|
|
189
|
+
// Never touch a file decanter did not put there.
|
|
190
|
+
if (manifest[from] === undefined && oldHash !== templateHash)
|
|
191
|
+
continue;
|
|
192
|
+
if (existsSync(newPath) && !force) {
|
|
193
|
+
keep[from] = manifest[from] ?? oldHash;
|
|
194
|
+
log.warn(`${from} and ${to} both exist — the old file still applies (and its hooks fire twice); merge it into ${to} and delete it`);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (force && oldHash !== templateHash) {
|
|
198
|
+
rmSync(oldPath);
|
|
199
|
+
log.warn(`--force: removed ${from} (had local changes) — its content now lives at ${to}`);
|
|
200
|
+
}
|
|
201
|
+
else if (oldHash === templateHash || oldHash === manifest[from]) {
|
|
202
|
+
rmSync(oldPath);
|
|
203
|
+
log.info(`renamed ${from} -> ${to} — it holds shared project policy, so the local slot is yours again`);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
skip.add(newPath);
|
|
207
|
+
// stay tracked: this is still decanter's file until the user resolves it
|
|
208
|
+
keep[from] = manifest[from] ?? oldHash;
|
|
209
|
+
log.warn(`${from} has local edits, so ${to} was NOT scaffolded (both files' hooks would fire) — move your copy to ${to}, or delete it, then re-run init`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { skip, keep };
|
|
213
|
+
}
|
|
110
214
|
/**
|
|
111
215
|
* Modification-aware template refresh (dpkg conffile-style). First init copies
|
|
112
216
|
* everything and records a baseline manifest. Re-init copies files new to the
|
|
@@ -114,11 +218,16 @@ function copyEntry(entry) {
|
|
|
114
218
|
* leaves locally-modified files alone while reporting the drift. `--force` is
|
|
115
219
|
* the escape hatch: it overwrites every template file regardless.
|
|
116
220
|
*/
|
|
117
|
-
async function refreshTemplate(srcDir, destDir, { force, protect, version }, log) {
|
|
221
|
+
async function refreshTemplate(srcDir, destDir, { force, protect, version, extraSources }, log) {
|
|
118
222
|
const manifest = readManifest(destDir);
|
|
119
223
|
const firstInit = !existsSync(path.join(destDir, MANIFEST_FILE));
|
|
120
|
-
|
|
121
|
-
|
|
224
|
+
// Renames first: a file the migration deletes must look absent to the scan
|
|
225
|
+
// (so the new name is simply "added"), and one it defers must look protected.
|
|
226
|
+
const { skip: renameSkips, keep: renameKeep } = migrateRenamedTemplateFiles(srcDir, destDir, manifest.files, force, log);
|
|
227
|
+
const entries = scanTemplate(srcDir, destDir, manifest.files, new Set([...protect, ...renameSkips]), extraSources);
|
|
228
|
+
// A pending rename stays in the baseline; everything else is rebuilt from the
|
|
229
|
+
// scan, which is what retires the old key once the migration completes.
|
|
230
|
+
const nextFiles = { ...renameKeep };
|
|
122
231
|
if (force) {
|
|
123
232
|
let anyExisting = false;
|
|
124
233
|
for (const e of entries) {
|
|
@@ -213,21 +322,39 @@ async function refreshTemplate(srcDir, destDir, { force, protect, version }, log
|
|
|
213
322
|
log.info(`template up to date (${uptodate} unchanged${added.length ? `, ${added.length} added` : ""})`);
|
|
214
323
|
}
|
|
215
324
|
/**
|
|
216
|
-
*
|
|
217
|
-
* browser OAuth consent for MCP (the sync backend) with a
|
|
218
|
-
* fallback, offer the OPTIONAL public API key (executions /
|
|
219
|
-
* backup only), write .env + .decanter-auth.json, copy template/.
|
|
325
|
+
* Bootstrap a sync dir (Plan 32: OAuth-first). Interactive by default: prompt
|
|
326
|
+
* for the host, run the browser OAuth consent for MCP (the sync backend) with a
|
|
327
|
+
* paste-a-bearer fallback, offer the OPTIONAL public API key (executions /
|
|
328
|
+
* data-tables / backup only), write .env + .decanter-auth.json, copy template/.
|
|
329
|
+
*
|
|
330
|
+
* Passing any of `--host`/`--token`/`--api-key` (Plan 35 field-test finding —
|
|
331
|
+
* init was undrivable headless) switches to a fully **non-interactive** mode:
|
|
332
|
+
* values come from the flags + the existing .env, and NOT ONE prompt is issued
|
|
333
|
+
* (a missing MCP token just warns, a missing API key is skipped). The flag-less
|
|
334
|
+
* invocation is unchanged (interactive, or answers piped over stdin).
|
|
335
|
+
*
|
|
336
|
+
* Plan 55: a first init closes by pointing at n8n's official skills pack — a
|
|
337
|
+
* printed recommendation, never a prompt, so no run's stdin changes.
|
|
220
338
|
*/
|
|
221
|
-
export async function init(targetDir, { force = false } = {}, log) {
|
|
339
|
+
export async function init(targetDir, { force = false, host: hostFlag, token: tokenFlag, apiKey: apiKeyFlag } = {}, log) {
|
|
222
340
|
printBanner(log);
|
|
223
341
|
const dir = path.resolve(targetDir ?? ".");
|
|
224
342
|
mkdirSync(dir, { recursive: true });
|
|
225
343
|
const envFile = path.join(dir, ".env");
|
|
226
344
|
const existing = parseEnvFile(envFile);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
let
|
|
345
|
+
// Precedence: an explicit flag wins over the existing .env, which wins over a
|
|
346
|
+
// prompt. A flag host is normalized the same way a typed one is.
|
|
347
|
+
let host = hostFlag !== undefined ? normalizeHostInput(hostFlag) : (existing.N8N_HOST ?? "");
|
|
348
|
+
let apiKey = apiKeyFlag ?? existing.N8N_API_KEY ?? "";
|
|
349
|
+
let mcpToken = tokenFlag ?? existing.N8N_MCP_TOKEN ?? "";
|
|
230
350
|
const interactive = process.stdin.isTTY === true;
|
|
351
|
+
// Any setup flag → non-interactive: drive init purely from flags + existing
|
|
352
|
+
// .env, issuing no prompts (and no OAuth-fallback token prompt either).
|
|
353
|
+
const flagDriven = hostFlag !== undefined || tokenFlag !== undefined || apiKeyFlag !== undefined;
|
|
354
|
+
// Plan 55: point at the official skills pack once, on a FIRST init (= no
|
|
355
|
+
// baseline manifest yet). Printed, never asked — every run's stdin stays
|
|
356
|
+
// exactly as it was.
|
|
357
|
+
const firstInit = !existsSync(path.join(dir, MANIFEST_FILE));
|
|
231
358
|
// ONE shared prompt session for every question: a second createPrompt()
|
|
232
359
|
// would lose piped answers the first one already buffered, so the session
|
|
233
360
|
// opens lazily on the first question and closes once at the end.
|
|
@@ -239,22 +366,24 @@ export async function init(targetDir, { force = false } = {}, log) {
|
|
|
239
366
|
try {
|
|
240
367
|
// --- host (prompted over stdin even when piped — init stays scriptable)
|
|
241
368
|
if (host !== "") {
|
|
242
|
-
log.info(`using existing .env host (${host})`);
|
|
369
|
+
log.info(hostFlag !== undefined ? `using --host ${host}` : `using existing .env host (${host})`);
|
|
370
|
+
}
|
|
371
|
+
else if (flagDriven) {
|
|
372
|
+
throw new Error("host is required — pass --host <url> (e.g. --host http://localhost:5678)");
|
|
243
373
|
}
|
|
244
374
|
else {
|
|
245
375
|
host = await ask("n8n host: ");
|
|
246
376
|
if (!host)
|
|
247
377
|
throw new Error("host is required");
|
|
248
|
-
|
|
249
|
-
host = "https://" + host;
|
|
250
|
-
host = host.replace(/\/+$/, "");
|
|
378
|
+
host = normalizeHostInput(host);
|
|
251
379
|
}
|
|
252
380
|
// --- MCP credentials (the sync backend): existing → OAuth consent (TTY) →
|
|
253
381
|
// paste-a-token fallback. Only the browser consent itself is TTY-gated;
|
|
254
382
|
// piped runs go straight to the token prompt so init stays scriptable.
|
|
383
|
+
// --token / any setup flag suppresses every prompt (non-interactive mode).
|
|
255
384
|
const auth = readAuthFileTolerant(dir, log);
|
|
256
385
|
if (mcpToken !== "") {
|
|
257
|
-
log.info("using existing MCP token from .env (N8N_MCP_TOKEN)");
|
|
386
|
+
log.info(tokenFlag !== undefined ? "using MCP token from --token" : "using existing MCP token from .env (N8N_MCP_TOKEN)");
|
|
258
387
|
}
|
|
259
388
|
else if (auth !== null && auth.host === host) {
|
|
260
389
|
log.info(`using existing MCP OAuth credentials (${AUTH_FILE})`);
|
|
@@ -267,17 +396,18 @@ export async function init(targetDir, { force = false } = {}, log) {
|
|
|
267
396
|
}
|
|
268
397
|
catch (err) {
|
|
269
398
|
log.warn(`OAuth consent did not complete (${err.message})`);
|
|
270
|
-
|
|
399
|
+
if (!flagDriven)
|
|
400
|
+
mcpToken = await ask("paste an n8n MCP token (n8n → Settings → MCP → API key) [Enter to skip]: ");
|
|
271
401
|
}
|
|
272
402
|
}
|
|
273
|
-
else {
|
|
403
|
+
else if (!flagDriven) {
|
|
274
404
|
mcpToken = await ask("n8n MCP token (n8n → Settings → MCP → API key) [Enter to skip]: ");
|
|
275
405
|
}
|
|
276
406
|
if (mcpToken === "" && !(auth !== null && auth.host === host)) {
|
|
277
407
|
log.warn("no MCP credentials yet — sync verbs (pull/push/watch/…) will not work until you re-run init or set N8N_MCP_TOKEN");
|
|
278
408
|
}
|
|
279
409
|
// --- optional public API key (the REST-only surfaces)
|
|
280
|
-
if (apiKey === "") {
|
|
410
|
+
if (apiKey === "" && !flagDriven) {
|
|
281
411
|
apiKey = await ask("n8n public API key (optional — executions/data-tables/backup) [Enter to skip]: ");
|
|
282
412
|
}
|
|
283
413
|
}
|
|
@@ -302,7 +432,11 @@ export async function init(targetDir, { force = false } = {}, log) {
|
|
|
302
432
|
// is protected (just written with real credentials) and never tracked.
|
|
303
433
|
// Files named `X.example` are inert in this repo (so agent tooling ignores
|
|
304
434
|
// them while working on the CLI itself) and materialize as `X` in the target.
|
|
305
|
-
|
|
435
|
+
// `n8n-globals.d.ts` is the exception: it's sourced from the single root file
|
|
436
|
+
// (Plan 43 — no `template/*.example` duplicate to drift) but materialized and
|
|
437
|
+
// tracked exactly like a template file.
|
|
438
|
+
const extraSources = [{ rel: "n8n-globals.d.ts", srcPath: path.join(PACKAGE_ROOT, "n8n-globals.d.ts") }];
|
|
439
|
+
await refreshTemplate(TEMPLATE_DIR, dir, { force, protect: new Set([envFile]), version: cliVersion(), extraSources }, log);
|
|
306
440
|
const configFile = path.join(dir, "decanter.config.json");
|
|
307
441
|
if (!existsSync(configFile)) {
|
|
308
442
|
writeFileSync(configFile, JSON.stringify({ root: "./workflows", workflows: [] }, null, 2) + "\n");
|
|
@@ -371,4 +505,9 @@ export async function init(targetDir, { force = false } = {}, log) {
|
|
|
371
505
|
log.warn(`could not reach ${host} (${reason}) — .env written anyway`);
|
|
372
506
|
}
|
|
373
507
|
}
|
|
508
|
+
// --- the official n8n skills pack (Plan 55). Dead last, and output-only:
|
|
509
|
+
// decanter names the pack and prints the commands for the detected agent,
|
|
510
|
+
// but installs nothing (see lib/skills.mts for why).
|
|
511
|
+
if (firstInit)
|
|
512
|
+
printSkillsRecommendation(detectAgent(), log);
|
|
374
513
|
}
|
package/dist/lib/pull.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { compileTs } from "./compile.mjs";
|
|
4
4
|
import { commitWorkflowDir } from "./git.mjs";
|
|
5
5
|
import { getWorkflowDetails } from "./mcp.mjs";
|
|
6
|
-
import { findWorkflowDir, readState, renameNodeFilePair, writeState } from "./state.mjs";
|
|
6
|
+
import { findWorkflowDir, readState, reconcileFileMapFromSnapshot, renameNodeFilePair, writeState } from "./state.mjs";
|
|
7
7
|
import { CODE_DIR, FILE_PLACEHOLDER_PREFIX, isJsCodeNode, kebabCase, sha256, splitMarker, stableWorkflowJson, } from "./util.mjs";
|
|
8
8
|
function writeIfChanged(file, content) {
|
|
9
9
|
if (existsSync(file) && readFileSync(file, "utf8") === content)
|
|
@@ -72,6 +72,12 @@ export async function pullWorkflow(mcp, root, id, { commitOnPull = false } = {},
|
|
|
72
72
|
state.nodes ??= {};
|
|
73
73
|
// structural hashing died with Plan 32 — scrub the legacy field on rewrite
|
|
74
74
|
delete state.lastPulledWorkflowHash;
|
|
75
|
+
// Honor a re-pointed //@file: placeholder (e.g. an `.js`→`.ts` conversion the
|
|
76
|
+
// agent just made) before deriving files from node names — otherwise this
|
|
77
|
+
// pull would treat the stale `.js` map entry as authoritative and rewrite the
|
|
78
|
+
// placeholder back to `.js` (Plan 35 field-test finding). Same reconcile push
|
|
79
|
+
// runs, so the two stay symmetric.
|
|
80
|
+
reconcileFileMapFromSnapshot(dir, state);
|
|
75
81
|
const usedNames = new Set();
|
|
76
82
|
const placeholders = new Map(); // node id -> file name
|
|
77
83
|
for (const node of wf.nodes) {
|
|
@@ -107,8 +113,11 @@ export async function pullWorkflow(mcp, root, id, { commitOnPull = false } = {},
|
|
|
107
113
|
}
|
|
108
114
|
else if (nodeState.file?.endsWith(".ts") || existsSync(path.join(dir, CODE_DIR, base + ".ts"))) {
|
|
109
115
|
// Local .ts exists but remote carries no marker: never clobber TS source
|
|
110
|
-
// and don't drop a competing .js next to it.
|
|
111
|
-
|
|
116
|
+
// and don't drop a competing .js next to it. Pick the actual `.ts` path —
|
|
117
|
+
// a `nodeState.file` still ending in `.js` is a not-yet-reconciled
|
|
118
|
+
// pre-conversion entry, so fall to the detected `<base>.ts` instead of
|
|
119
|
+
// parroting the deleted `.js` (Plan 35 field-test finding).
|
|
120
|
+
const tsFile = nodeState.file?.endsWith(".ts") ? nodeState.file : `${CODE_DIR}/${base}.ts`;
|
|
112
121
|
log.warn(`${wf.name} / ${node.name}: local ${tsFile} exists but remote code has no @ts-n8n marker (not pushed from TS yet?) — keeping your .ts; the next push overwrites the remote code`);
|
|
113
122
|
placeholders.set(node.id, tsFile);
|
|
114
123
|
state.nodes[node.id] = { ...nodeState, file: tsFile, lastPushedHash: remoteHash, name: node.name };
|