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 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]` | Interactive bootstrap — OAuth, template, config, agent tooling |
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** |
@@ -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
- * `--version`/`--at`, or a TTY chooser), re-inline the Code-node source,
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, version, at, interactive = false }, log) {
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, { version, at, interactive }, log);
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
- /** Pick which backup to restore: explicit flags, else a TTY chooser, else latest. */
191
- async function selectBackup(backups, { version, at, interactive }, log) {
192
- if (version !== undefined) {
193
- // match against the full inner versionId or the short one in the name
194
- const hit = backups.find((b) => {
195
- if (b.versionId === version || version.startsWith(b.versionId))
196
- return true;
197
- try {
198
- return JSON.parse(readFileSync(b.file, "utf8")).versionId === version;
199
- }
200
- catch {
201
- return false;
202
- }
203
- });
204
- if (!hit)
205
- throw new Error(`no backup with versionId "${version}" — see \`backup list\``);
206
- return hit;
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
- if (at !== undefined) {
209
- const hit = backups.find((b) => b.timestamp === at || b.name.startsWith(at));
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 at "${at}" — see \`backup list\``);
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
- const materializedRel = path.join(rel, name);
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
- const entries = scanTemplate(srcDir, destDir, manifest.files, protect);
121
- const nextFiles = {};
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
- * Interactive bootstrap (Plan 32: OAuth-first): prompt for the host, run the
217
- * browser OAuth consent for MCP (the sync backend) with a paste-a-bearer
218
- * fallback, offer the OPTIONAL public API key (executions / data-tables /
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
- let host = existing.N8N_HOST ?? "";
228
- let apiKey = existing.N8N_API_KEY ?? "";
229
- let mcpToken = existing.N8N_MCP_TOKEN ?? "";
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
- if (!/^https?:\/\//.test(host))
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
- mcpToken = await ask("paste an n8n MCP token (n8n → Settings → MCP → API key) [Enter to skip]: ");
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
- await refreshTemplate(TEMPLATE_DIR, dir, { force, protect: new Set([envFile]), version: cliVersion() }, log);
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
- const tsFile = nodeState.file ?? `${CODE_DIR}/${base}.ts`;
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 };