n8n-decanter 0.10.1 → 0.10.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 +140 -0
- package/README.md +21 -1
- package/dist/lib/config.mjs +131 -3
- package/dist/lib/init.mjs +180 -10
- package/dist/lib/simulate.mjs +6 -1
- package/dist/n8n-decanter.mjs +44 -12
- package/package.json +1 -1
- package/template/.claude/hooks/mcp-route-check.mjs.example +85 -11
- package/template/.claude/hooks/rename-refs.mjs.example +20 -4
- package/template/.claude/hooks/verify.mjs.example +21 -3
- package/template/AGENTS.md.example +47 -1
- package/template/CLAUDE.md.example +1 -1
- package/template/opencode.json.example +2 -3
- package/dist/lib/add.mjs +0 -101
- package/dist/lib/proxy.mjs +0 -204
- package/dist/lib/rename.mjs +0 -96
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.2] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **"Missing `n8n-instance` tools? Restart" is no longer the only answer we
|
|
15
|
+
give — in a nested sync dir it was a dead end.** Agent wiring loads at
|
|
16
|
+
startup *from the dir the agent was started in*, so tools declared in
|
|
17
|
+
`.mcp.json` can be absent for two reasons: the wiring is new (a restart fixes
|
|
18
|
+
it), or the wiring sits **below** the launch dir, where no restart will ever
|
|
19
|
+
load it. Every surface that taught only the first now teaches both, with the
|
|
20
|
+
discriminator you can apply yourself — *is that `.mcp.json` below the
|
|
21
|
+
directory you started the agent in?* — and the two working routes (start the
|
|
22
|
+
agent in the sync dir, or wire the root with `N8N_DECANTER_DIR` plus a
|
|
23
|
+
root-resolvable command): the scaffolded `AGENTS.md`, `init`'s own output
|
|
24
|
+
(which now prints the nested guidance **instead of** the restart line when it
|
|
25
|
+
scaffolds into a nested dir), [Working with coding
|
|
26
|
+
agents](/docs/agents/overview/), [init](/docs/cli/init/) and a new
|
|
27
|
+
[troubleshooting](/docs/faq/troubleshooting/) entry. The CLI never needed
|
|
28
|
+
that wiring, so `pull`/`push`/`preflight` keep working either way — the docs
|
|
29
|
+
now say so too.
|
|
30
|
+
|
|
31
|
+
- **The oversized-scenario warning now names a remedy you can actually take.**
|
|
32
|
+
Above 1 MB `scenario create` said "Trim it before that" — but there is no
|
|
33
|
+
trim flag, so the advice pointed nowhere. It now says what is true and what
|
|
34
|
+
to do: nothing is committed yet (`scenario create` never commits — only the
|
|
35
|
+
next `pull`/`push` sweeps the folder into history), so you can still cut
|
|
36
|
+
items out of `data.resultData.runData` by hand, or re-create the scenario
|
|
37
|
+
with `--scaffold` instead of `--execution` and author the pins yourself.
|
|
38
|
+
`docs/cli/scenario.md` spells out both, including which parts of a capture
|
|
39
|
+
are unsafe to delete.
|
|
40
|
+
|
|
41
|
+
- **The agent contract now says to orient *before* the first edit, not only
|
|
42
|
+
before the push.** `preflight` was always the read-only report of the
|
|
43
|
+
instance's side (`drift`, `CONFLICT`, a pending `parity`), but every surface
|
|
44
|
+
framed it as the pre-push gate — so an agent edited first and learned about a
|
|
45
|
+
colleague's UI edit afterwards. The scaffolded `AGENTS.md` and both agent doc
|
|
46
|
+
pages now open the loop with it: on drift, `pull` and carry on; on a
|
|
47
|
+
`CONFLICT`, show `diff` and ask before either side is overwritten.
|
|
48
|
+
- **The scaffold stopped advertising a deny rule it no longer has.** The
|
|
49
|
+
Claude Code `settings.json` denies `.decanter.json`, `.env` and
|
|
50
|
+
`push --force` — but the scaffolded `CLAUDE.md` and `opencode.json` still
|
|
51
|
+
claimed `*.remote.js` was blocked too. Those conflict artifacts were removed
|
|
52
|
+
in the MCP pivot; both files now describe the policy that actually ships.
|
|
53
|
+
- **Docs: `mcp connect` / `mcp serve` no longer read as if the guard obtained
|
|
54
|
+
credentials itself.** "decanter's own credentials", "the agent never holds an
|
|
55
|
+
n8n credential" and "no secret to manage" led readers (and agents) to believe
|
|
56
|
+
the guard handles the n8n login. It does not: it only *reads* what `init`
|
|
57
|
+
wrote to `.env` / `.decanter-auth.json`, and can at most refresh an OAuth
|
|
58
|
+
token. The mcp-connect / mcp-serve pages and the template `AGENTS.md` now say
|
|
59
|
+
so outright — obtaining credentials is exclusively `init`'s job, and a
|
|
60
|
+
"no MCP credentials" answer means run `init`, not retry.
|
|
61
|
+
|
|
62
|
+
- **"decanter.config.json not found" now points at `init` — with its flags.**
|
|
63
|
+
The classic half-setup is a hand-written `.env`: an agent that cannot run the
|
|
64
|
+
browser OAuth flow asks its human to paste `N8N_MCP_TOKEN` into a file and
|
|
65
|
+
stops there, leaving no config, template, `.gitignore` or agent wiring behind.
|
|
66
|
+
The error now says the dir is not a sync dir yet, that `.env` alone is not
|
|
67
|
+
enough, and prints the prompt-free command that fixes it
|
|
68
|
+
(`n8n-decanter init . --host <host-url> --token <mcp-token>`). The docs
|
|
69
|
+
(init, configuration, troubleshooting, README) say the same thing: headless is
|
|
70
|
+
not a reason to skip `init` — it takes the same token as a flag.
|
|
71
|
+
|
|
72
|
+
- **`init`'s restart reminder now covers everything it wires, not just
|
|
73
|
+
permission rules.** MCP servers (`.mcp.json` / `opencode.json` — including the
|
|
74
|
+
guarded `n8n-instance` server), permission rules and hooks are all read at
|
|
75
|
+
agent **startup**, and `init` normally runs inside the session it configures.
|
|
76
|
+
The reminder now fires when any of those files is newly scaffolded and says
|
|
77
|
+
what it means: this session is still unconfigured, restart the agent (or
|
|
78
|
+
`/reload`). README, init and the agents docs say the same, and the scaffolded
|
|
79
|
+
`AGENTS.md` tells the agent to **ask for a restart** when the `n8n-instance`
|
|
80
|
+
tools are missing instead of connecting to the instance directly.
|
|
81
|
+
|
|
82
|
+
### Fixed
|
|
83
|
+
|
|
84
|
+
- **The scaffolded hooks now work when your sync dir is not where the agent was
|
|
85
|
+
started.** All three found the sync dir by assuming it was the current
|
|
86
|
+
directory, which only holds for an agent launched inside it. With the sync dir
|
|
87
|
+
nested in a bigger repo — a layout the docs explicitly allow — the agent runs
|
|
88
|
+
at the repo root and every one of them misbehaved: the rename-reference guard
|
|
89
|
+
became a **silent no-op**, so `$('Old Name')` references left behind by a
|
|
90
|
+
`renameNode` went unreported until a later `push` refused them; the verify hook
|
|
91
|
+
spawned the CLI without a directory, so it **blocked every node-file edit** with
|
|
92
|
+
a "not a sync dir" error; and the MCP routing check scanned the wrong tree.
|
|
93
|
+
Each hook now locates the sync dir from its own installed path, so it behaves
|
|
94
|
+
the same wherever the agent starts.
|
|
95
|
+
- **The routing check no longer misses direct-route servers in your user
|
|
96
|
+
config.** Its lookup for this project's entry in `~/.claude.json` matched the
|
|
97
|
+
current directory, but that file is keyed by the repository root — so in any
|
|
98
|
+
sync dir inside a git repo the check silently found nothing. It now matches the
|
|
99
|
+
project entry for the sync dir or any parent of it.
|
|
100
|
+
- **The verify hook finds a locally installed CLI.** It only ever looked for
|
|
101
|
+
`n8n-decanter` on `PATH`, so with a local (non-global) install it stayed quiet
|
|
102
|
+
and no verification ran at all. It now prefers the sync dir's
|
|
103
|
+
`node_modules/.bin` and falls back to `PATH`.
|
|
104
|
+
- **"decanter.config.json not found" no longer sends you to `init` when the sync
|
|
105
|
+
dir is simply somewhere else.** Run from *above* a perfectly good sync dir —
|
|
106
|
+
what happens whenever an agent starts at the repo root — the error read as if
|
|
107
|
+
nothing had ever been set up, and advised scaffolding a second sync dir on top
|
|
108
|
+
of the working one. It now looks below the directory it searched from, names
|
|
109
|
+
the sync dir it finds there, and prints the `--dir` / `N8N_DECANTER_DIR` form
|
|
110
|
+
that reaches it. When there really is no sync dir, the `init` advice is
|
|
111
|
+
unchanged.
|
|
112
|
+
|
|
113
|
+
### Added
|
|
114
|
+
|
|
115
|
+
- **The routing check also looks at parent directories, up to your repository
|
|
116
|
+
root.** Agents merge `.mcp.json` from every directory above the one they start
|
|
117
|
+
in, so a server pointing straight at your n8n instance can sit in the repo
|
|
118
|
+
root's config and still route this session. The scan stops at the repository
|
|
119
|
+
boundary, so it never reaches into unrelated parent directories, and an
|
|
120
|
+
offender found above the sync dir is named by its relative path
|
|
121
|
+
(`../.mcp.json`) so you can tell which file it means.
|
|
122
|
+
|
|
123
|
+
- **`--dir <path>` (or `N8N_DECANTER_DIR`) points any verb at a sync dir that is
|
|
124
|
+
not the current directory.** The layout the docs allow but nothing supported:
|
|
125
|
+
the sync dir nested inside a bigger repo, with the agent started at the repo
|
|
126
|
+
root. The MCP entry `init` writes into the sync dir is invisible from up
|
|
127
|
+
there, and hoisting it to the repo root spawned the guard where no
|
|
128
|
+
`decanter.config.json` could be found — so the guard did not work at all. The
|
|
129
|
+
search still only walks *up*; this says where it starts. In an agent's server
|
|
130
|
+
entry the environment variable is the form to reach for
|
|
131
|
+
(`"env": { "N8N_DECANTER_DIR": "flows" }`), and relative values resolve
|
|
132
|
+
against the working directory, so a repo-relative one keeps working for
|
|
133
|
+
everyone who clones. `init` does not take `--dir` — it still takes the
|
|
134
|
+
directory to scaffold as an argument.
|
|
135
|
+
|
|
136
|
+
- **`init` now tells you how to wire an agent when your sync dir is nested in a
|
|
137
|
+
bigger project.** Agents look for `.mcp.json`, `opencode.json` and
|
|
138
|
+
`.claude/settings.json` from the directory they were *started* in and never in
|
|
139
|
+
one below it, so everything `init` scaffolds is inert for an agent started at
|
|
140
|
+
the repo root above the sync dir. When init sees a project around it (a `.git` or
|
|
141
|
+
`package.json` in a parent) it prints both shapes that work: starting the
|
|
142
|
+
agent inside the sync dir — recommended, nothing further to configure — or the
|
|
143
|
+
paste-ready MCP, opencode and hooks/permissions blocks for the project root,
|
|
144
|
+
with every path and glob already prefixed. That prefixing is the point: copied
|
|
145
|
+
up verbatim, `Read(.env)` / `Edit(.env)` guard the *root's* `.env` and quietly
|
|
146
|
+
stop protecting your credentials. `init` prints this; it never writes into a
|
|
147
|
+
parent directory. Only on the run that first scaffolds the agent files, and
|
|
148
|
+
never for a standalone sync dir.
|
|
149
|
+
|
|
10
150
|
## [0.10.1] - 2026-08-16
|
|
11
151
|
|
|
12
152
|
### Added
|
package/README.md
CHANGED
|
@@ -73,6 +73,13 @@ n8n-decanter init [dir] # OAuth in your browser (or a pasted MCP token)
|
|
|
73
73
|
n8n-decanter pull # pick a workflow from the list → it pulls
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
**Restart your coding agent after `init`** — the MCP servers, permission rules
|
|
77
|
+
and hooks it scaffolds are read at agent startup, so the session that ran `init`
|
|
78
|
+
is still unconfigured. If the sync dir is nested in a bigger repo and you start
|
|
79
|
+
the agent at the repo root, a restart is *not* enough — that wiring loads only
|
|
80
|
+
from the dir the agent starts in
|
|
81
|
+
([which file loads from where](docs/agents/overview.md#where-the-agent-wiring-loads-from)).
|
|
82
|
+
|
|
76
83
|
On a terminal, `pull` with no argument lists your n8n workflows — pick one and
|
|
77
84
|
it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
|
|
78
85
|
<id-or-name>` pulls it directly (scriptable, no TTY needed).
|
|
@@ -80,7 +87,9 @@ it lands in `workflows/<slug>/`. Know the id already? `n8n-decanter pull
|
|
|
80
87
|
set a bare `pull`/`push`/`diff` acts on.)
|
|
81
88
|
|
|
82
89
|
**Credentials:** OAuth by default (via `init`); `N8N_MCP_TOKEN` for
|
|
83
|
-
headless/CI
|
|
90
|
+
headless/CI — pass it to `init --host <url> --token <mcp-token>` rather than
|
|
91
|
+
hand-writing `.env`, which leaves the config, template, `.gitignore` and agent
|
|
92
|
+
configs unscaffolded; `N8N_API_KEY` is optional, needed only for `executions`,
|
|
84
93
|
`data-tables`, and `backup`. Details: [Installation](docs/getting-started/installation.md),
|
|
85
94
|
[init](docs/cli/init.md), [Configuration](docs/concepts/configuration.md).
|
|
86
95
|
|
|
@@ -132,6 +141,17 @@ renaming, and archiving workflows — and adding or renaming nodes — are
|
|
|
132
141
|
**n8n's acts**: do them in the editor or let your agent do it over n8n's MCP
|
|
133
142
|
tools (through the guard above); the next `pull` reconciles the local mirror.
|
|
134
143
|
|
|
144
|
+
**Sync dir nested in a bigger repo?** A global `--dir <path>` (or
|
|
145
|
+
`N8N_DECANTER_DIR`) starts the upward `decanter.config.json` search there
|
|
146
|
+
instead of in the working directory — every verb honours it except `init`,
|
|
147
|
+
which takes the directory to scaffold as its argument. A coding agent launched
|
|
148
|
+
at the repo root needs it too — agents look for `.mcp.json` and
|
|
149
|
+
`.claude/settings.json` from where they were *started*, never in a directory
|
|
150
|
+
below it, so either start the agent in the sync dir or wire the root
|
|
151
|
+
explicitly:
|
|
152
|
+
[Working with coding agents](docs/agents/overview.md#where-the-agent-wiring-loads-from),
|
|
153
|
+
[mcp connect](docs/cli/mcp-connect.md#when-your-sync-dir-is-not-your-project-root).
|
|
154
|
+
|
|
135
155
|
## How it compares
|
|
136
156
|
|
|
137
157
|
n8n-decanter is **Code-node-first**: it optimizes writing, typing, verifying and
|
package/dist/lib/config.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
/**
|
|
4
4
|
* The one message every cold start hits — a fresh clone has no `.env`, so this
|
|
@@ -44,12 +44,114 @@ export function loadEnv(dir) {
|
|
|
44
44
|
process.env[key] = value;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Where `loadConfig`'s upward search STARTS: `--dir` > `N8N_DECANTER_DIR` > cwd.
|
|
49
|
+
* The search itself is untouched (Plan 81) — this only moves its origin, so a
|
|
50
|
+
* sync dir nested in a bigger repo stays reachable from a process started at the
|
|
51
|
+
* repo root. That is the shape agent wiring falls into: an MCP entry hoisted to
|
|
52
|
+
* the repo root spawns `mcp connect` with cwd = the root, and an upward-only
|
|
53
|
+
* search can never walk *down* into `flows/`.
|
|
54
|
+
*
|
|
55
|
+
* **The env var is the load-bearing half, not the flag** — every agent's MCP
|
|
56
|
+
* server entry has an `env` block, while a `cwd` key is not guaranteed across
|
|
57
|
+
* agents and versions.
|
|
58
|
+
*
|
|
59
|
+
* **Both forms resolve against cwd on purpose:** a committed root `.mcp.json`
|
|
60
|
+
* carrying `N8N_DECANTER_DIR=flows` survives a clone on a teammate's machine,
|
|
61
|
+
* where an absolute `/home/me/repo/flows` would not. (`loadEnv` never overrides
|
|
62
|
+
* an already-set variable, so the sync dir's own `.env` can't fight the value
|
|
63
|
+
* that pointed decanter at it — and couldn't anyway: it is read only *after*
|
|
64
|
+
* the dir has been found.)
|
|
65
|
+
*
|
|
66
|
+
* A path that is not a directory fails here, loudly. Left alone it would walk
|
|
67
|
+
* to the filesystem root and surface as "not found (searched from … upward)" —
|
|
68
|
+
* a message about the wrong problem.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveSearchStart(dirFlag, cwd = process.cwd(), env = process.env) {
|
|
71
|
+
const envDir = env.N8N_DECANTER_DIR;
|
|
72
|
+
// Empty counts as unset: an agent config that interpolates a missing value
|
|
73
|
+
// ships `"N8N_DECANTER_DIR": ""`, and inheriting cwd beats failing on it.
|
|
74
|
+
const override = dirFlag !== undefined && dirFlag !== ""
|
|
75
|
+
? { value: dirFlag, source: "--dir" }
|
|
76
|
+
: envDir !== undefined && envDir !== ""
|
|
77
|
+
? { value: envDir, source: "N8N_DECANTER_DIR" }
|
|
78
|
+
: undefined;
|
|
79
|
+
if (override === undefined)
|
|
80
|
+
return path.resolve(cwd);
|
|
81
|
+
const resolved = path.resolve(cwd, override.value);
|
|
82
|
+
const stat = statSync(resolved, { throwIfNoEntry: false });
|
|
83
|
+
if (stat === undefined || !stat.isDirectory()) {
|
|
84
|
+
throw new Error(`${override.source} ${override.value} is not a directory (resolved to ${resolved})\n` +
|
|
85
|
+
" it names the sync dir — the folder holding decanter.config.json — and relative paths\n" +
|
|
86
|
+
" resolve against the working directory, so a repo-relative value stays portable");
|
|
87
|
+
}
|
|
88
|
+
return resolved;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Bounded hunt for a sync dir *below* `start` — the evidence that separates
|
|
92
|
+
* "not a sync dir yet" from "the search started too high" (Plan 81). Breadth-
|
|
93
|
+
* first so the shallow hit wins, skipping `node_modules` and every dot-dir
|
|
94
|
+
* (`.git` included — nobody's sync dir lives there, and they are where the file
|
|
95
|
+
* count explodes). Symlinked dirs report `isDirectory()` false from a `Dirent`,
|
|
96
|
+
* so a link cycle is never entered.
|
|
97
|
+
*
|
|
98
|
+
* **Three caps, and the WALL-CLOCK one is the load-bearing cap.** Counting
|
|
99
|
+
* directories is not enough: measured here, a single `existsSync` inside an
|
|
100
|
+
* rclone/NFS-style mount costs ~500 ms, so a scan of a home directory holding
|
|
101
|
+
* one ran **16 seconds** — on the failure path of every verb, the guard's
|
|
102
|
+
* `mcp connect` startup included. This whole scan only sharpens an error
|
|
103
|
+
* message, so it is never worth waiting for: past the deadline it gives up and
|
|
104
|
+
* the caller falls back to the cold-start advice.
|
|
105
|
+
*
|
|
106
|
+
* One `readdirSync` per visited dir, and the hit is read out of those entries —
|
|
107
|
+
* the earlier shape (readdir the parent, then stat `<child>/decanter.config.json`
|
|
108
|
+
* for every child) paid two round trips per directory to learn the same thing.
|
|
109
|
+
*/
|
|
110
|
+
function findConfigBelow(start, maxDepth = 3, maxDirs = 400, maxMs = 250) {
|
|
111
|
+
const deadline = Date.now() + maxMs;
|
|
112
|
+
const queue = [{ dir: start, depth: 0 }];
|
|
113
|
+
let visited = 0;
|
|
114
|
+
while (queue.length > 0) {
|
|
115
|
+
if (visited >= maxDirs || Date.now() > deadline)
|
|
116
|
+
return undefined;
|
|
117
|
+
const { dir, depth } = queue.shift();
|
|
118
|
+
visited++;
|
|
119
|
+
let entries;
|
|
120
|
+
try {
|
|
121
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// unreadable directory (permissions, a race) — skip it; this scan is
|
|
125
|
+
// never authoritative
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// `start` itself cannot hold one — loadConfig checked it before walking up.
|
|
129
|
+
// `!isDirectory()` rather than `isFile()`: a `Dirent` reports a SYMLINK as
|
|
130
|
+
// neither, and loadConfig's own `existsSync` would happily follow one.
|
|
131
|
+
if (dir !== start && entries.some((e) => e.name === "decanter.config.json" && !e.isDirectory()))
|
|
132
|
+
return dir;
|
|
133
|
+
if (depth >= maxDepth)
|
|
134
|
+
continue;
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
// Cap the QUEUE too, or one directory with 100k children buys itself
|
|
137
|
+
// 100k queue entries before the visit counter ever gets a say.
|
|
138
|
+
if (queue.length >= maxDirs)
|
|
139
|
+
break;
|
|
140
|
+
if (!entry.isDirectory() || entry.name === "node_modules" || entry.name.startsWith("."))
|
|
141
|
+
continue;
|
|
142
|
+
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
47
147
|
/**
|
|
48
148
|
* Load decanter.config.json from cwd (or nearest ancestor) and resolve paths.
|
|
49
149
|
* `requireHost` gates only N8N_HOST (online verbs need it): the API key is
|
|
50
150
|
* optional since Plan 32 (MCP is the sync backend; `requireApiKey` guards the
|
|
51
151
|
* REST-API-only verbs at use time) and MCP credentials are resolved separately
|
|
52
152
|
* (lib/mcp.mts `resolveMcpAuth` — env token or .decanter-auth.json).
|
|
153
|
+
* `cwd` is where the search *starts*; the CLI passes `resolveSearchStart(…)` so
|
|
154
|
+
* `--dir`/`N8N_DECANTER_DIR` can move that origin without touching the walk.
|
|
53
155
|
*/
|
|
54
156
|
export function loadConfig(cwd = process.cwd(), { requireHost = true } = {}) {
|
|
55
157
|
let dir = path.resolve(cwd);
|
|
@@ -86,8 +188,34 @@ export function loadConfig(cwd = process.cwd(), { requireHost = true } = {}) {
|
|
|
86
188
|
};
|
|
87
189
|
}
|
|
88
190
|
const parent = path.dirname(dir);
|
|
89
|
-
if (parent === dir)
|
|
90
|
-
|
|
191
|
+
if (parent === dir) {
|
|
192
|
+
// Two different situations end up here, and the advice inverts between
|
|
193
|
+
// them — so say which one the user is in. A sync dir sitting *below* the
|
|
194
|
+
// starting point means the setup is fine and the search merely began too
|
|
195
|
+
// high (an agent launched at the repo root; Plan 81). Sending that user to
|
|
196
|
+
// `init` would scaffold a second sync dir on top of a working one.
|
|
197
|
+
const nested = findConfigBelow(path.resolve(cwd));
|
|
198
|
+
if (nested !== undefined) {
|
|
199
|
+
const rel = path.relative(path.resolve(cwd), nested) || ".";
|
|
200
|
+
throw new Error("decanter.config.json not found (searched from " + cwd + " upward)\n" +
|
|
201
|
+
" it is not missing — the sync dir sits BELOW the working directory: " + nested + "\n" +
|
|
202
|
+
" the search only walks up, so name the sync dir explicitly:\n" +
|
|
203
|
+
// The `=` form, not `--dir <rel>`: the space form declines to eat a
|
|
204
|
+
// value that is also a verb, so a sync dir called `test/` or `list/`
|
|
205
|
+
// would come back as "--dir needs a value" from a copy-paste.
|
|
206
|
+
" n8n-decanter <verb> --dir=" + rel + "\n" +
|
|
207
|
+
" or set N8N_DECANTER_DIR=" + rel + " (agents: the `env` block of the decanter MCP server entry)");
|
|
208
|
+
}
|
|
209
|
+
// A hand-written `.env` is the classic half-setup: an agent that cannot
|
|
210
|
+
// run the browser OAuth flow asks its human to paste `N8N_MCP_TOKEN`
|
|
211
|
+
// into a file and stops there, leaving no config, template, .gitignore
|
|
212
|
+
// or agent wiring. Name the non-interactive `init` (it takes the very
|
|
213
|
+
// same token as a flag) so the fix is one command, not a research task.
|
|
214
|
+
throw new Error("decanter.config.json not found (searched from " + cwd + " upward)\n" +
|
|
215
|
+
" this is not a decanter sync dir yet — writing .env by hand is not enough; init also\n" +
|
|
216
|
+
" scaffolds the config, template, .gitignore and agent configs:\n" +
|
|
217
|
+
" n8n-decanter init . --host <host-url> --token <mcp-token>");
|
|
218
|
+
}
|
|
91
219
|
dir = parent;
|
|
92
220
|
}
|
|
93
221
|
}
|
package/dist/lib/init.mjs
CHANGED
|
@@ -212,6 +212,156 @@ function migrateRenamedTemplateFiles(srcDir, destDir, manifest, force, log) {
|
|
|
212
212
|
}
|
|
213
213
|
return { skip, keep };
|
|
214
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Nearest STRICT ancestor of `dir` that looks like the **agent's** project root
|
|
217
|
+
* — one holding `.git` (a *file* in worktrees and submodules, so `existsSync`,
|
|
218
|
+
* never `statSync().isDirectory()`) or a `package.json`. Null when there is
|
|
219
|
+
* none, which is the standalone sync dir every earlier version assumed.
|
|
220
|
+
*
|
|
221
|
+
* Only strict ancestors count, and that is what makes the test cheap: the sync
|
|
222
|
+
* dir's own scaffolded `package.json` — and a `git init` run inside it, the
|
|
223
|
+
* shape the docs teach — are never mistaken for a parent project.
|
|
224
|
+
*
|
|
225
|
+
* `.git` beats an intermediate `package.json` on the way up: in a monorepo the
|
|
226
|
+
* repo root is the dir people actually open, while a sub-package manifest is
|
|
227
|
+
* just a stop along the walk.
|
|
228
|
+
*
|
|
229
|
+
* Consulting git here answers "where would the agent be started?", a different
|
|
230
|
+
* question from "where is the sync dir?" — so it is **not** a reversal of
|
|
231
|
+
* `docs/concepts/sync-layout.md`'s "decanter never uses git to find the sync
|
|
232
|
+
* dir" (which remains true: that is still the upward `decanter.config.json`
|
|
233
|
+
* search, git-blind).
|
|
234
|
+
*/
|
|
235
|
+
export function projectRootAbove(dir) {
|
|
236
|
+
let current = path.resolve(dir);
|
|
237
|
+
let pkgRoot = null;
|
|
238
|
+
for (;;) {
|
|
239
|
+
const parent = path.dirname(current);
|
|
240
|
+
if (parent === current)
|
|
241
|
+
return pkgRoot; // fs root: only a package.json ever matched
|
|
242
|
+
if (existsSync(path.join(parent, ".git")))
|
|
243
|
+
return parent;
|
|
244
|
+
if (pkgRoot === null && existsSync(path.join(parent, "package.json")))
|
|
245
|
+
pkgRoot = parent;
|
|
246
|
+
current = parent;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The nested-sync-dir note (Plan 81), printed verbatim rather than pointed at:
|
|
251
|
+
* the wiring `init` just wrote is loaded **only from the dir the agent starts
|
|
252
|
+
* in** — `.mcp.json` is merged from every ancestor of that dir (so a nested one
|
|
253
|
+
* is invisible from above) and `.claude/settings.json` is read from that dir
|
|
254
|
+
* alone, no walk in either direction. Verified against Claude Code 2.1.234.
|
|
255
|
+
*
|
|
256
|
+
* Option A leads because it is the only shape with zero further config *and*
|
|
257
|
+
* the only one in which the scaffolded permission globs stay anchored where
|
|
258
|
+
* they were written. Option B is deliberately the long half: a root-hoisted MCP
|
|
259
|
+
* entry needs BOTH the `N8N_DECANTER_DIR` pin (decanter's config search only
|
|
260
|
+
* ever walks *up* from its cwd) and a command that resolves from the root —
|
|
261
|
+
* which `npx --no-install n8n-decanter` does not: it looks in the *launch*
|
|
262
|
+
* dir's `node_modules/.bin`, which under a local install is the sync dir
|
|
263
|
+
* itself. And a hoisted permission list is worse than none: relative globs
|
|
264
|
+
* re-anchor at the root, so a verbatim `Read(.env)`/`Edit(.env)` stops covering
|
|
265
|
+
* the credentials file — hence every glob below carries the sync-dir prefix.
|
|
266
|
+
*/
|
|
267
|
+
export function nestedWiringNote(syncDir, projectRoot) {
|
|
268
|
+
const rel = path.relative(projectRoot, syncDir).split(path.sep).join("/");
|
|
269
|
+
// Local install → the binary is not on the root's PATH; point at it
|
|
270
|
+
// repo-relative (child_process resolves a command containing a separator
|
|
271
|
+
// against the spawn cwd = the agent's root), because an ABSOLUTE path in a
|
|
272
|
+
// committed root config is wrong on every other machine.
|
|
273
|
+
const hasLocalBin = existsSync(path.join(syncDir, "node_modules", ".bin", "n8n-decanter"));
|
|
274
|
+
const command = hasLocalBin ? `${rel}/node_modules/.bin/n8n-decanter` : "n8n-decanter";
|
|
275
|
+
const commandNote = hasLocalBin
|
|
276
|
+
? ` The command is that path because decanter is installed inside the sync
|
|
277
|
+
dir, not globally. Keep it repo-relative — an absolute path in a committed
|
|
278
|
+
root config breaks for everyone else who clones.`
|
|
279
|
+
: ` The bare command name assumes a global install (npm i -g n8n-decanter).
|
|
280
|
+
If you instead run npm install inside the sync dir, nothing is on the
|
|
281
|
+
root's PATH — use ${rel}/node_modules/.bin/n8n-decanter (repo-relative;
|
|
282
|
+
an absolute path in a committed root config breaks for everyone else).`;
|
|
283
|
+
return `
|
|
284
|
+
This sync dir is nested inside ${projectRoot}. Agents load the wiring just
|
|
285
|
+
scaffolded here only when they are STARTED here: .mcp.json is merged from
|
|
286
|
+
every ancestor of the launch dir (so a nested one is invisible from above),
|
|
287
|
+
and .claude/settings.json is read from the launch dir alone.
|
|
288
|
+
|
|
289
|
+
${style.bold("A. Recommended — start the agent in this dir:")}
|
|
290
|
+
|
|
291
|
+
cd "${syncDir}" && claude # or opencode, codex, …
|
|
292
|
+
|
|
293
|
+
Everything just scaffolded then applies unchanged, with no further config.
|
|
294
|
+
Its one cost: that project's own .claude/settings.json does not load then
|
|
295
|
+
(its .mcp.json still does, via the ancestor walk).
|
|
296
|
+
|
|
297
|
+
${style.bold(`B. Or wire up ${projectRoot} by hand — both halves, or it still fails:`)}
|
|
298
|
+
|
|
299
|
+
${projectRoot}/.mcp.json — merge into the servers already there (the
|
|
300
|
+
"n8n-docs" entry needs no dir, copy it over unchanged):
|
|
301
|
+
|
|
302
|
+
{
|
|
303
|
+
"mcpServers": {
|
|
304
|
+
"n8n-instance": {
|
|
305
|
+
"command": "${command}",
|
|
306
|
+
"args": ["mcp", "connect"],
|
|
307
|
+
"env": { "N8N_DECANTER_DIR": "${rel}" }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
N8N_DECANTER_DIR is what makes decanter find this dir at all — its config
|
|
313
|
+
search only walks UP from where it was started. It resolves against the
|
|
314
|
+
agent's working dir, so keep it repo-relative.
|
|
315
|
+
|
|
316
|
+
${commandNote}
|
|
317
|
+
|
|
318
|
+
${projectRoot}/opencode.json — the same two halves:
|
|
319
|
+
|
|
320
|
+
{
|
|
321
|
+
"mcp": {
|
|
322
|
+
"n8n-instance": {
|
|
323
|
+
"type": "local",
|
|
324
|
+
"command": ["${command}", "mcp", "connect"],
|
|
325
|
+
"environment": { "N8N_DECANTER_DIR": "${rel}" }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
${projectRoot}/.claude/settings.json — hooks and permissions, every path
|
|
331
|
+
and every relative glob prefixed with ${rel}:
|
|
332
|
+
|
|
333
|
+
{
|
|
334
|
+
"hooks": {
|
|
335
|
+
"PostToolUse": [
|
|
336
|
+
{ "matcher": "Edit|Write|MultiEdit",
|
|
337
|
+
"hooks": [{ "type": "command", "command": "node ${rel}/.claude/hooks/verify.mjs" }] },
|
|
338
|
+
{ "matcher": "mcp__n8n-instance__update_workflow",
|
|
339
|
+
"hooks": [{ "type": "command", "command": "node ${rel}/.claude/hooks/rename-refs.mjs" }] }
|
|
340
|
+
],
|
|
341
|
+
"SessionStart": [
|
|
342
|
+
{ "hooks": [{ "type": "command", "command": "node ${rel}/.claude/hooks/mcp-route-check.mjs" }] }
|
|
343
|
+
]
|
|
344
|
+
},
|
|
345
|
+
"permissions": {
|
|
346
|
+
"deny": ["Read(${rel}/.env)", "Edit(${rel}/.env)", "Edit(**/.decanter.json)"]
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
Those two .env rules are the sharp end: a relative glob anchors at the
|
|
351
|
+
settings file that declares it, so copying Read(.env)/Edit(.env) up
|
|
352
|
+
verbatim would guard the ROOT's .env and silently stop protecting
|
|
353
|
+
${rel}/.env — the credentials file. The allow list moves the same way:
|
|
354
|
+
Edit(workflows/**) becomes Edit(${rel}/workflows/**), Edit(shared/**)
|
|
355
|
+
becomes Edit(${rel}/shared/**). Everything else in
|
|
356
|
+
${rel}/.claude/settings.json names no path and moves across verbatim —
|
|
357
|
+
copy it over too, the "push --force" Bash denies above all; the block
|
|
358
|
+
above is short because only the PATHS need rewriting, not because the
|
|
359
|
+
rest is optional.
|
|
360
|
+
|
|
361
|
+
Do not reach for \${CLAUDE_PROJECT_DIR}: it expands to the agent's project
|
|
362
|
+
root — the parent — so it reads as if it pointed here and never does.
|
|
363
|
+
`;
|
|
364
|
+
}
|
|
215
365
|
/**
|
|
216
366
|
* Modification-aware template refresh (dpkg conffile-style). First init copies
|
|
217
367
|
* everything and records a baseline manifest. Re-init copies files new to the
|
|
@@ -321,16 +471,36 @@ async function refreshTemplate(srcDir, destDir, { force, protect, version, extra
|
|
|
321
471
|
log.info(`copied template -> ${destDir}`);
|
|
322
472
|
else
|
|
323
473
|
log.info(`template up to date (${uptodate} unchanged${added.length ? `, ${added.length} added` : ""})`);
|
|
324
|
-
//
|
|
325
|
-
// session
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
474
|
+
// None of the agent wiring hot-loads, and `init` is normally run from INSIDE
|
|
475
|
+
// the session it is meant to configure: permission rules (no edits to
|
|
476
|
+
// `.decanter.json`, no reads of `.env`, no `push --force`), the guarded
|
|
477
|
+
// `n8n-instance` MCP server, and the session hooks are all read at agent
|
|
478
|
+
// startup, so everything just written is inert for it. The docs said
|
|
479
|
+
// "restart" only for the skills plugin, so the wiring that actually gates the
|
|
480
|
+
// agent went unmentioned: the tool knew something the user needed and never
|
|
481
|
+
// said it. Only when a file is new — re-running init in a set-up dir must not
|
|
482
|
+
// nag. What "make it live" MEANS then splits on the layout (see below).
|
|
483
|
+
const agentFiles = [path.join(".claude", "settings.json"), ".mcp.json", "opencode.json"];
|
|
484
|
+
if (agentFiles.some((f) => added.includes(f))) {
|
|
485
|
+
// Plan 81: those same three files are the ones a nested sync dir never gets
|
|
486
|
+
// to use — an agent started at the surrounding project root loads none of
|
|
487
|
+
// them. So the note rides the same gate — it answers "here is what it takes
|
|
488
|
+
// to make what I just wrote live" — and a standalone dir (the shape this all
|
|
489
|
+
// assumed until now) stays completely silent.
|
|
490
|
+
const projectRoot = projectRootAbove(destDir);
|
|
491
|
+
// Plan 83: which line is TRUE depends on that same detection, so branch on
|
|
492
|
+
// it rather than printing "restart" unconditionally and appending the
|
|
493
|
+
// nested note. In a nested dir a restart is not merely insufficient, it is
|
|
494
|
+
// a dead end — startup discovery walks UP from the launch dir, so a session
|
|
495
|
+
// started at the root re-misses this file every time. A blind agent read
|
|
496
|
+
// the old unconditional line, concluded "restart", and had no next idea.
|
|
497
|
+
if (projectRoot === null) {
|
|
498
|
+
log.info(style.dim(" restart your agent (or /reload) before working here — the MCP servers, permission rules and hooks just scaffolded load at agent STARTUP, so this session is still unconfigured"));
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
log.info(style.dim(" the MCP servers, permission rules and hooks just scaffolded load at agent STARTUP, from the dir the agent was STARTED in — so a restart makes them live only for an agent started in this dir. Started higher up, no restart ever loads them; use A or B below."));
|
|
502
|
+
log.info(nestedWiringNote(destDir, projectRoot));
|
|
503
|
+
}
|
|
334
504
|
}
|
|
335
505
|
}
|
|
336
506
|
/**
|
package/dist/lib/simulate.mjs
CHANGED
|
@@ -758,8 +758,13 @@ export async function writeScenario(dir, opts, log) {
|
|
|
758
758
|
else {
|
|
759
759
|
log.ok(`scenario "${name}" written from ${seededFrom} -> ${rel} (${size}) — no gaps; replay with \`preflight --simulate --scenario ${name}\``);
|
|
760
760
|
}
|
|
761
|
+
// Both remedies are named because "trim it" alone left the reader hunting for
|
|
762
|
+
// a flag that does not exist (the `--trim` idea was retired — the file is
|
|
763
|
+
// hand-editable by design, and `--scaffold` is the no-capture path). The
|
|
764
|
+
// window is real and worth stating: `scenario create` does NOT commit, so
|
|
765
|
+
// nothing is in history until the next pull/push sweeps the folder.
|
|
761
766
|
if (bytes > 1024 * 1024) {
|
|
762
|
-
log.warn(`that scenario is ${size} and \`scenarios/\` is TRACKED — the next pull or push auto-commits the whole workflow folder
|
|
767
|
+
log.warn(`that scenario is ${size} and \`scenarios/\` is TRACKED — nothing is committed yet, but the next pull or push auto-commits the whole workflow folder and it lands in git history permanently. Before that: cut items out of "data.resultData.runData" by hand (the file is indented for exactly that), or re-create it with \`--scaffold\` instead of \`--execution\` — that carries no capture data at all, and you author the pins.`);
|
|
763
768
|
}
|
|
764
769
|
if (notExercised.length > 0) {
|
|
765
770
|
log.info(`pinned ${notExercised.length} node${notExercised.length === 1 ? "" : "s"} to an EMPTY run — the capture never reached ${notExercised.length === 1 ? "it" : "them"}: ${notExercised.join(", ")}. That is what makes this scenario usable with \`test\` too; review the claim, and give one real output if a branch should have run.`);
|