n8n-decanter 0.10.2 → 0.11.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 +152 -0
- package/README.md +8 -2
- package/dist/lib/api.mjs +33 -5
- package/dist/lib/config.mjs +55 -3
- package/dist/lib/git.mjs +114 -0
- package/dist/lib/init.mjs +231 -53
- package/dist/lib/mcp.mjs +219 -39
- package/dist/lib/mcpconnect.mjs +11 -4
- package/dist/lib/mcpserve.mjs +10 -4
- package/dist/lib/preflight.mjs +14 -9
- package/dist/lib/push.mjs +42 -4
- package/dist/lib/testrun.mjs +2 -2
- package/dist/lib/util.mjs +65 -9
- package/dist/lib/validate.mjs +9 -3
- package/dist/n8n-decanter.mjs +197 -68
- package/package.json +2 -2
- package/template/.cursor/rules/n8n-decanter.mdc.example +3 -1
- package/template/.env.example +14 -0
- package/template/AGENTS.md.example +20 -7
- package/template/CLAUDE.md.example +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,158 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.2] - 2026-10-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`test --scenario` and `test --execution` work again on n8n 2.34 and
|
|
15
|
+
newer** ([#327](https://github.com/buttjer/n8n-decanter/issues/327)). The run
|
|
16
|
+
succeeded on the instance, but decanter then stopped with `Tool get_execution
|
|
17
|
+
not found` and compared nothing. n8n 2.34 renamed that MCP tool to
|
|
18
|
+
`get_workflow_execution`, and renamed two more that decanter uses:
|
|
19
|
+
`search_executions` (behind `preflight`'s `history` check, which quietly
|
|
20
|
+
fell back to the REST API or skipped) and `prepare_test_pin_data` (behind
|
|
21
|
+
`scenario create --scaffold`, which quietly produced fills without schemas).
|
|
22
|
+
decanter now asks for the new name and falls back to the old one, so older n8n
|
|
23
|
+
keeps working too.
|
|
24
|
+
|
|
25
|
+
## [0.11.1] - 2026-09-10
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`N8N_DECANTER_AUTH=upstream` — an auth mode for instances behind a proxy
|
|
30
|
+
that attaches the n8n credentials itself.** In this mode decanter sends *no*
|
|
31
|
+
credential header at all: no `Authorization` on MCP, no `X-N8N-API-KEY` on
|
|
32
|
+
REST. That is the only thing that works — a proxy which adds its own key
|
|
33
|
+
**appends** it to whatever the client sent, and n8n answers `401` to the pair,
|
|
34
|
+
so the previous workaround (a placeholder value in `N8N_API_KEY`, to satisfy
|
|
35
|
+
the REST-verb guard) fails just like an empty one. `N8N_API_KEY` stops being
|
|
36
|
+
required, so `executions`, `data-tables` and `backup` work with no key of your
|
|
37
|
+
own. `mcp serve` drops the agent's session secret instead of swapping it for a
|
|
38
|
+
credential, so that local secret is never forwarded upstream. Set it in `.env`
|
|
39
|
+
or with the new **`init --auth upstream`**, which needs no token, no browser
|
|
40
|
+
and no TTY, and verifies both backends through the proxy. Existing credentials
|
|
41
|
+
are kept, ignored, and reported as unused. Any other value of the variable is
|
|
42
|
+
an error on every verb — a typo must not silently restore the header.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **A REST 401 now says what is wrong.** It used to fall through to a bare
|
|
47
|
+
`401 Unauthorized` status line; it now names `N8N_API_KEY` as invalid for the
|
|
48
|
+
host (or, in upstream mode, points at the proxy).
|
|
49
|
+
|
|
50
|
+
## [0.11.0] - 2026-09-03
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- **Breaking: a compiled `.ts` node now identifies itself on line 1, not on
|
|
55
|
+
its last line.** Open a bundled node in n8n and you used to see
|
|
56
|
+
`var __n8n_node = {}` followed by a few hundred lines of inlined helpers,
|
|
57
|
+
with the only trace of where it came from — `// @ts-n8n sha256:…` — at the
|
|
58
|
+
bottom, where nobody looks, naming *n8n* rather than the tool that put it
|
|
59
|
+
there. Push now writes one self-describing line where the editor opens:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
// n8n-decanter · workflows/orders/code/normalize-lines.ts · do not edit here · @ts-n8n sha256:39af5ea6… · v0.10.1 ca3c201 2026-08-20T09:14Z
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Nothing you already pushed has to change.** The old trailing form is read
|
|
66
|
+
forever and counts as fully in sync — `push`, `diff` and `preflight` say
|
|
67
|
+
nothing about it, and no write is queued just to relocate a marker. Nodes
|
|
68
|
+
adopt line 1 on their next real code push; there is no migration and no mass
|
|
69
|
+
re-push. The break runs the other way: **an older n8n-decanter will not
|
|
70
|
+
recognise a node pushed by this version** (its reader only looks at the last
|
|
71
|
+
line) and would pull such a node down as a plain `.js` file. Pre-1.0, that is
|
|
72
|
+
accepted rather than shimmed — upgrade the CLI everywhere that pulls the same
|
|
73
|
+
instance. The `@ts-n8n` token itself is unchanged, so one `grep -r @ts-n8n`
|
|
74
|
+
still finds both forms.
|
|
75
|
+
|
|
76
|
+
- **The layout guard now rejects a marker line in a node source file in either
|
|
77
|
+
position, and in `.ts` files too** (it was trailing-only and `.js`-only).
|
|
78
|
+
That line is written by `push`; it is never source. Surfaces as before via
|
|
79
|
+
`preflight`'s `layout` check.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **An auth failure no longer sends you in a circle, and throttling is no
|
|
84
|
+
longer called an expiry.** Every failed OAuth token refresh used to print
|
|
85
|
+
the same sentence — `MCP session expired … re-run: n8n-decanter init` — and
|
|
86
|
+
both halves of it could be wrong. `init` reuses `.decanter-auth.json`
|
|
87
|
+
whenever the host matches and never re-mints, so following the advice
|
|
88
|
+
re-probed with the same dead credentials and finished with "credentials
|
|
89
|
+
written anyway"; the only apparent way out was deleting a credential file
|
|
90
|
+
that was often perfectly fine. The three cases are now told apart:
|
|
91
|
+
- **the refresh token really is spent** (`invalid_grant`) — named as such,
|
|
92
|
+
and pointed at `init --reauth`, a command that actually re-mints;
|
|
93
|
+
- **n8n is rate-limiting** (429) — now **retried** with the same backoff the
|
|
94
|
+
MCP endpoint has always had (honouring `Retry-After`, five attempts), and
|
|
95
|
+
if it still fails it says the credentials are fine and never mentions
|
|
96
|
+
`init`. OAuth discovery got the same retry; both sat on bare `fetch` calls;
|
|
97
|
+
- **anything else** — the reason is named without a diagnosis, and nothing
|
|
98
|
+
is suggested for discarding.
|
|
99
|
+
|
|
100
|
+
- **`init`'s closing connection check acts on a spent token instead of
|
|
101
|
+
shrugging.** It had the failure in hand and printed "credentials written
|
|
102
|
+
anyway". On a terminal it now offers to re-authorize on the spot; off one it
|
|
103
|
+
names `init --reauth`. Deliberately narrow — a network error, a 401, or a
|
|
104
|
+
403 "MCP access is disabled" still report as before, because none of them
|
|
105
|
+
says anything about your credentials.
|
|
106
|
+
|
|
107
|
+
- **A successful first OAuth consent no longer warns "no MCP credentials
|
|
108
|
+
yet".** The check looked for a *pre-existing* auth file, so a browser
|
|
109
|
+
authorization that had just succeeded still ended with advice to re-run
|
|
110
|
+
`init` with a token.
|
|
111
|
+
|
|
112
|
+
- **`<verb> --help` prints help instead of running the verb.** `--help` was
|
|
113
|
+
only recognised in argument slot 0, and every `--flag` is stripped before
|
|
114
|
+
dispatch, so `n8n-decanter init --help` was indistinguishable from a bare
|
|
115
|
+
`init` — a request for help **scaffolded a sync dir** into whatever directory
|
|
116
|
+
it was asked from. `--help` (and `-h`) now wins from any position, before any
|
|
117
|
+
verb, any namespace and the picker. It prints **that verb's own block** rather
|
|
118
|
+
than the whole listing, with only the notes that apply to it; `help <verb>` is
|
|
119
|
+
the same question, and a bare `help`/`--help` still prints everything.
|
|
120
|
+
|
|
121
|
+
### Added
|
|
122
|
+
|
|
123
|
+
- **`init` refuses to scaffold on top of a sync dir that already exists below
|
|
124
|
+
the target.** Run from the root of a bigger repo whose sync dir lives in
|
|
125
|
+
`n8n/`, `init` used to drop a second `decanter.config.json`, the template,
|
|
126
|
+
`workflows/`, `shared/`, `tsconfig.json` and the agent configs into the root.
|
|
127
|
+
It now looks a few levels down first and stops, naming the sync dir it found
|
|
128
|
+
and the `--dir=`/`N8N_DECANTER_DIR` form that addresses it from where you are
|
|
129
|
+
— the same advice every read verb already gives. A terminal gets it as a
|
|
130
|
+
question (`y` scaffolds anyway); a piped or flag-driven run exits 1 having
|
|
131
|
+
written nothing, not even the target directory. Re-running `init` **inside**
|
|
132
|
+
an existing sync dir is unchanged.
|
|
133
|
+
|
|
134
|
+
- **The provenance line carries the build stamp, not just the hash**: the
|
|
135
|
+
node's source path relative to the sync dir, "do not edit here", the CLI
|
|
136
|
+
version, the git commit and the push time. The commit is HEAD at build time
|
|
137
|
+
and renders as `ca3c201+dirty` when the sync dir has uncommitted changes —
|
|
138
|
+
which, with `commitOnPush` on, is the normal case and reads correctly as
|
|
139
|
+
"built from working-tree state on top of `ca3c201`". Fields that cannot be
|
|
140
|
+
known (no git repo, unreadable `package.json`) are simply left out. **None of
|
|
141
|
+
it is hashed**, so a rename, a new commit or a CLI upgrade never makes a node
|
|
142
|
+
look changed — only the code below line 1 does.
|
|
143
|
+
|
|
144
|
+
- **Credentials now resolve in a git worktree.** Both `.env` and
|
|
145
|
+
`.decanter-auth.json` are gitignored, so a fresh linked worktree had neither
|
|
146
|
+
and every credentialed verb died on `N8N_HOST must be set` — including the
|
|
147
|
+
`mcp connect` guard, which left agents in a worktree with no `n8n-instance`
|
|
148
|
+
tools at all. A worktree without its own credentials now reads the main
|
|
149
|
+
checkout's copies (same path, resolved from git's own worktree pointer, no
|
|
150
|
+
`git` subprocess). **A local file still wins**, so a worktree deliberately
|
|
151
|
+
aimed at another instance keeps its own, and nothing else is redirected —
|
|
152
|
+
`workflows/`, `.decanter.json` and `decanter.config.json` stay worktree-local.
|
|
153
|
+
For `.decanter-auth.json` the shared file is the only correct shape: the
|
|
154
|
+
refresh token is single-use and rotates, so **copying** it into a worktree
|
|
155
|
+
(what Claude Code's `.worktreeinclude` would do) forks it into two token
|
|
156
|
+
chains and kills the loser. New [Git
|
|
157
|
+
worktrees](/docs/concepts/configuration/#git-worktrees) section covers the two
|
|
158
|
+
remaining worktree gaps decanter cannot fix — a missing `node_modules` and the
|
|
159
|
+
per-path MCP approval — and the troubleshooting entry for missing
|
|
160
|
+
`n8n-instance` tools gained the worktree case.
|
|
161
|
+
|
|
10
162
|
## [0.10.2] - 2026-08-18
|
|
11
163
|
|
|
12
164
|
### Changed
|
package/README.md
CHANGED
|
@@ -37,6 +37,10 @@ clean git diffs.
|
|
|
37
37
|
- **Shared code and small libraries** — `.ts` nodes import helper files from
|
|
38
38
|
anywhere in the sync dir (`shared/` by default) and opted-in npm packages,
|
|
39
39
|
bundled on push to run anywhere, n8n Cloud included.
|
|
40
|
+
- **The deployed node says where it came from** — a compiled `.ts` node opens
|
|
41
|
+
in n8n with its source path, "do not edit here", the content hash and the
|
|
42
|
+
build stamp (CLI version, git commit, push time) on line 1, so a bundle full
|
|
43
|
+
of inlined helpers is never a mystery artifact.
|
|
40
44
|
- **Agent-native** — `init` scaffolds Claude Code / Cursor / Codex configs;
|
|
41
45
|
`preflight --offline` and `node run` give agents a credential-free loop.
|
|
42
46
|
- **Guarded agent access to n8n's MCP, wired by default** — the scaffolded
|
|
@@ -90,7 +94,9 @@ set a bare `pull`/`push`/`diff` acts on.)
|
|
|
90
94
|
headless/CI — pass it to `init --host <url> --token <mcp-token>` rather than
|
|
91
95
|
hand-writing `.env`, which leaves the config, template, `.gitignore` and agent
|
|
92
96
|
configs unscaffolded; `N8N_API_KEY` is optional, needed only for `executions`,
|
|
93
|
-
`data-tables`, and `backup`.
|
|
97
|
+
`data-tables`, and `backup`. Behind a proxy that attaches the n8n credentials
|
|
98
|
+
itself, `init --auth upstream` sets `N8N_DECANTER_AUTH=upstream` and decanter
|
|
99
|
+
sends none of its own. Details: [Installation](docs/getting-started/installation.md),
|
|
94
100
|
[init](docs/cli/init.md), [Configuration](docs/concepts/configuration.md).
|
|
95
101
|
|
|
96
102
|
## Works with n8n's official skills
|
|
@@ -118,7 +124,7 @@ Full flag reference: `n8n-decanter --help`, or the
|
|
|
118
124
|
|
|
119
125
|
| Verb | What it does |
|
|
120
126
|
|---|---|
|
|
121
|
-
| `init [dir]` | Bootstrap a sync dir — OAuth/token, template, config, agent tooling (`--host`/`--token`/`--api-key` drive it non-interactively) |
|
|
127
|
+
| `init [dir]` | Bootstrap a sync dir — OAuth/token, template, config, agent tooling (`--host`/`--token`/`--api-key` drive it non-interactively; `--reauth` re-consents after a spent token) |
|
|
122
128
|
| `completion zsh\|bash` | Print a shell completion script |
|
|
123
129
|
| `pull [workflow…]` | Code + structure snapshot → `workflows/<kebab>/` |
|
|
124
130
|
| `push [workflow…]` | Push Code-node source to the workflow's **draft** |
|
package/dist/lib/api.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AUTH_MODE_ENV } from "./config.mjs";
|
|
1
2
|
/**
|
|
2
3
|
* n8n public REST API client — since Plan 33 only the surfaces MCP cannot
|
|
3
4
|
* serve: executions (no MCP read of full run data), data-table rows (MCP is
|
|
@@ -16,7 +17,19 @@
|
|
|
16
17
|
* fold into `dataTable:read`, so a key that lists tables fine still 403s on
|
|
17
18
|
* `/columns`.
|
|
18
19
|
*/
|
|
19
|
-
function scopeHint(method, pathname) {
|
|
20
|
+
function scopeHint(method, pathname, authMode) {
|
|
21
|
+
const scope = scopeName(method, pathname);
|
|
22
|
+
// In upstream mode the under-scoped key belongs to the proxy, so the scope
|
|
23
|
+
// name alone would send the reader to an `.env` that holds no key (Plan 92).
|
|
24
|
+
// Credentials mode keeps its exact previous wording.
|
|
25
|
+
if (authMode === "upstream") {
|
|
26
|
+
const where = "The key belongs to the upstream auth proxy, not to your .env — fix it where the proxy mints it.";
|
|
27
|
+
return scope === "" ? where : `${scope} ${where}`;
|
|
28
|
+
}
|
|
29
|
+
return scope === "" ? "Check the key's scopes in n8n → Settings → n8n API." : scope;
|
|
30
|
+
}
|
|
31
|
+
/** The scope a refused path needed, or `""` when the path is not one we map. */
|
|
32
|
+
function scopeName(method, pathname) {
|
|
20
33
|
const readOnly = " decanter only ever READS data tables — no write scope is needed.";
|
|
21
34
|
if (pathname.includes("/data-tables/") && pathname.includes("/columns"))
|
|
22
35
|
return `Add \`dataTableColumn:read\` (listing tables uses \`dataTable:list\`, which does NOT cover columns).${readOnly}`;
|
|
@@ -32,16 +45,18 @@ function scopeHint(method, pathname) {
|
|
|
32
45
|
return "Add `workflow:create` (`backup restore` redeploys a backup as a new workflow).";
|
|
33
46
|
if (pathname.startsWith("/api/v1/workflows"))
|
|
34
47
|
return "Add `workflow:read` (and `workflow:list` for init's connection check).";
|
|
35
|
-
return "
|
|
48
|
+
return "";
|
|
36
49
|
}
|
|
37
50
|
export class N8nApi {
|
|
38
51
|
#host;
|
|
39
52
|
#apiKey;
|
|
40
53
|
#timeoutMs;
|
|
41
|
-
|
|
54
|
+
#authMode;
|
|
55
|
+
constructor({ host, apiKey, requestTimeoutMs = 30_000, authMode = "credentials" }) {
|
|
42
56
|
this.#host = host;
|
|
43
57
|
this.#apiKey = apiKey;
|
|
44
58
|
this.#timeoutMs = requestTimeoutMs;
|
|
59
|
+
this.#authMode = authMode;
|
|
45
60
|
}
|
|
46
61
|
/**
|
|
47
62
|
* Recent executions, newest first. Read-only by design — the executions API is
|
|
@@ -134,7 +149,10 @@ export class N8nApi {
|
|
|
134
149
|
const res = await fetch(this.#host + pathname, {
|
|
135
150
|
method,
|
|
136
151
|
headers: {
|
|
137
|
-
|
|
152
|
+
// OMITTED, not blanked, in upstream mode: the proxy in front of n8n
|
|
153
|
+
// APPENDS its key to whatever the client sent, and n8n answers 401 to
|
|
154
|
+
// the pair (measured 2026-09-09). An empty header is still a header.
|
|
155
|
+
...(this.#authMode === "credentials" && { "X-N8N-API-KEY": this.#apiKey }),
|
|
138
156
|
accept: "application/json",
|
|
139
157
|
...(body !== undefined && { "content-type": "application/json" }),
|
|
140
158
|
},
|
|
@@ -147,8 +165,18 @@ export class N8nApi {
|
|
|
147
165
|
// and n8n does not say which scope is missing. Mapped HERE rather than
|
|
148
166
|
// in each caller so every REST surface (executions, data-tables, backup)
|
|
149
167
|
// gets it: `pathname` already identifies what was refused.
|
|
168
|
+
// 401 used to fall through to the bare status line below — the least
|
|
169
|
+
// helpful place to land, and the failure upstream mode makes most
|
|
170
|
+
// likely (a proxy that forwards but attaches nothing).
|
|
171
|
+
if (res.status === 401) {
|
|
172
|
+
throw new Error(this.#authMode === "upstream"
|
|
173
|
+
? `${method} ${pathname} was rejected (401) — decanter sent no API key by design (${AUTH_MODE_ENV}=upstream); the upstream auth proxy did not attach a valid one`
|
|
174
|
+
: `${method} ${pathname} was rejected (401) — N8N_API_KEY is not valid for ${this.#host} (n8n → Settings → n8n API)`);
|
|
175
|
+
}
|
|
150
176
|
if (res.status === 403) {
|
|
151
|
-
throw new Error(
|
|
177
|
+
throw new Error(this.#authMode === "upstream"
|
|
178
|
+
? `${method} ${pathname} was refused (403) — the API key the upstream auth proxy attaches is valid but lacks a scope. ${scopeHint(method, pathname, this.#authMode)}`
|
|
179
|
+
: `${method} ${pathname} was refused (403) — N8N_API_KEY is valid but lacks a scope. ${scopeHint(method, pathname, this.#authMode)}`);
|
|
152
180
|
}
|
|
153
181
|
throw new Error(`${method} ${pathname} failed: ${res.status} ${res.statusText}\n${text.slice(0, 2000)}`);
|
|
154
182
|
}
|
package/dist/lib/config.mjs
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { credentialFile } from "./git.mjs";
|
|
4
|
+
/** The env file `init` writes next to `decanter.config.json` (gitignored). */
|
|
5
|
+
export const ENV_FILE = ".env";
|
|
6
|
+
/** The env var that selects who attaches the n8n credentials (Plan 92). */
|
|
7
|
+
export const AUTH_MODE_ENV = "N8N_DECANTER_AUTH";
|
|
8
|
+
/**
|
|
9
|
+
* Read `N8N_DECANTER_AUTH`. Unset (or empty, the shape an `.env` line with no
|
|
10
|
+
* value leaves behind) means decanter holds the credentials itself.
|
|
11
|
+
*
|
|
12
|
+
* **An unrecognized value throws rather than falling back to `credentials`.**
|
|
13
|
+
* The whole point of the upstream mode is that decanter sends no credential
|
|
14
|
+
* header; a typo that silently restored the header would reintroduce exactly
|
|
15
|
+
* the 401 this exists to remove, and it would look like a server problem.
|
|
16
|
+
*
|
|
17
|
+
* It is `N8N_DECANTER_*` and not `N8N_AUTH_MODE` on purpose: `N8N_HOST`,
|
|
18
|
+
* `N8N_API_KEY` and `N8N_MCP_TOKEN` carry n8n's name because they ARE n8n's
|
|
19
|
+
* credentials, while this is a decanter behavior switch — the category
|
|
20
|
+
* `N8N_DECANTER_DIR` established. It matters because this variable often sits
|
|
21
|
+
* in a `.env` an n8n container also reads, in a namespace that already holds
|
|
22
|
+
* `N8N_BASIC_AUTH_*` and `N8N_AUTH_EXCLUDE_ENDPOINTS`.
|
|
23
|
+
*/
|
|
24
|
+
export function readAuthMode(raw) {
|
|
25
|
+
const value = (raw ?? "").trim();
|
|
26
|
+
if (value === "" || value === "credentials")
|
|
27
|
+
return "credentials";
|
|
28
|
+
if (value === "upstream")
|
|
29
|
+
return "upstream";
|
|
30
|
+
throw new Error(`${AUTH_MODE_ENV}="${value}" is not a known auth mode — use "upstream" (a proxy in front of n8n attaches the credentials) or "credentials" (the default: decanter attaches them)`);
|
|
31
|
+
}
|
|
3
32
|
/**
|
|
4
33
|
* The one message every cold start hits — a fresh clone has no `.env`, so this
|
|
5
34
|
* is the first thing a user (or their agent) reads. It names the **flag** form
|
|
@@ -32,14 +61,24 @@ export function parseEnvFile(file) {
|
|
|
32
61
|
* key is suddenly needed in an otherwise MCP-only setup.
|
|
33
62
|
*/
|
|
34
63
|
export function requireApiKey(config, verb) {
|
|
64
|
+
// Upstream mode has no key to require: the proxy in front of n8n attaches
|
|
65
|
+
// one, and decanter deliberately sends none (Plan 92).
|
|
66
|
+
if (config.authMode === "upstream")
|
|
67
|
+
return config;
|
|
35
68
|
if (config.apiKey === "") {
|
|
36
69
|
throw new Error(`\`${verb}\` uses the n8n public REST API (MCP does not cover it) — set N8N_API_KEY in .env next to decanter.config.json (n8n → Settings → n8n API)`);
|
|
37
70
|
}
|
|
38
71
|
return config;
|
|
39
72
|
}
|
|
40
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Load the env file (if present) into process.env, not overriding existing
|
|
75
|
+
* vars. In a linked worktree without one of its own, the main checkout's copy
|
|
76
|
+
* is read instead (`credentialFile`) — the file is gitignored, so every fresh
|
|
77
|
+
* worktree starts without it and every credentialed verb would otherwise fail
|
|
78
|
+
* on `HOST_UNSET`.
|
|
79
|
+
*/
|
|
41
80
|
export function loadEnv(dir) {
|
|
42
|
-
for (const [key, value] of Object.entries(parseEnvFile(
|
|
81
|
+
for (const [key, value] of Object.entries(parseEnvFile(credentialFile(dir, ENV_FILE)))) {
|
|
43
82
|
if (process.env[key] === undefined)
|
|
44
83
|
process.env[key] = value;
|
|
45
84
|
}
|
|
@@ -106,8 +145,15 @@ export function resolveSearchStart(dirFlag, cwd = process.cwd(), env = process.e
|
|
|
106
145
|
* One `readdirSync` per visited dir, and the hit is read out of those entries —
|
|
107
146
|
* the earlier shape (readdir the parent, then stat `<child>/decanter.config.json`
|
|
108
147
|
* for every child) paid two round trips per directory to learn the same thing.
|
|
148
|
+
*
|
|
149
|
+
* Exported for `init` (Plan 86), the second caller and the only one that asks
|
|
150
|
+
* *before* writing: 81 taught the failure path of every read verb to recognise a
|
|
151
|
+
* sync dir below the cwd, while `init` — the one verb that scaffolds into a
|
|
152
|
+
* directory the user has not vetted — kept scaffolding a second one on top of it.
|
|
153
|
+
* `start` itself is never a hit, so a re-init inside an existing sync dir is
|
|
154
|
+
* unaffected.
|
|
109
155
|
*/
|
|
110
|
-
function findConfigBelow(start, maxDepth = 3, maxDirs = 400, maxMs = 250) {
|
|
156
|
+
export function findConfigBelow(start, maxDepth = 3, maxDirs = 400, maxMs = 250) {
|
|
111
157
|
const deadline = Date.now() + maxMs;
|
|
112
158
|
const queue = [{ dir: start, depth: 0 }];
|
|
113
159
|
let visited = 0;
|
|
@@ -169,6 +215,11 @@ export function loadConfig(cwd = process.cwd(), { requireHost = true } = {}) {
|
|
|
169
215
|
loadEnv(dir);
|
|
170
216
|
const host = (process.env.N8N_HOST ?? "").replace(/\/+$/, "");
|
|
171
217
|
const apiKey = process.env.N8N_API_KEY ?? "";
|
|
218
|
+
// Deliberately BEFORE the host check, and reached by the offline verbs
|
|
219
|
+
// too (they load config as well): a typo'd mode is a setup error worth
|
|
220
|
+
// hearing about on the first command that reads config, not on the first
|
|
221
|
+
// one that happens to need a credential.
|
|
222
|
+
const authMode = readAuthMode(process.env[AUTH_MODE_ENV]);
|
|
172
223
|
if (requireHost && !host) {
|
|
173
224
|
throw new Error(HOST_UNSET);
|
|
174
225
|
}
|
|
@@ -185,6 +236,7 @@ export function loadConfig(cwd = process.cwd(), { requireHost = true } = {}) {
|
|
|
185
236
|
backupLimit: typeof cfg.backupLimit === "number" && cfg.backupLimit >= 0 ? Math.floor(cfg.backupLimit) : 20,
|
|
186
237
|
host,
|
|
187
238
|
apiKey,
|
|
239
|
+
authMode,
|
|
188
240
|
};
|
|
189
241
|
}
|
|
190
242
|
const parent = path.dirname(dir);
|
package/dist/lib/git.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { promisify } from "node:util";
|
|
3
5
|
const execFile = promisify(execFileCb);
|
|
4
6
|
/** True when `dir` sits inside a git work tree (the live-mirror safety-net gate). */
|
|
@@ -52,3 +54,115 @@ export async function commitWorkflowDir(dir, message, log, extraPaths = []) {
|
|
|
52
54
|
return "failed";
|
|
53
55
|
}
|
|
54
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Short HEAD sha for the provenance line (Plan 84), `+dirty` when anything
|
|
59
|
+
* under `dir` is uncommitted. `null` outside a repo, on an unborn branch, or
|
|
60
|
+
* when git is missing — the marker line simply drops the field.
|
|
61
|
+
*
|
|
62
|
+
* Two things about the value are deliberately imprecise, and the imprecision
|
|
63
|
+
* runs the safe way:
|
|
64
|
+
*
|
|
65
|
+
* - **HEAD is the commit BEFORE the one that will contain the pushed source.**
|
|
66
|
+
* `commitWorkflowDir` runs *after* the MCP write (lib/push.mts), so at build
|
|
67
|
+
* time the source is still working-tree state. Rendering `ca3c201+dirty`
|
|
68
|
+
* says exactly that, and honestly. (Moving the auto-commit ahead of the
|
|
69
|
+
* write was rejected: it would leave a commit claiming a push that failed.)
|
|
70
|
+
* - **The dirty check is scoped to the whole sync dir**, not to this node's
|
|
71
|
+
* own inputs. A bundled node pulls in helpers from anywhere in the sync dir
|
|
72
|
+
* (Plan 79), and esbuild's input list is not plumbed out here — so an
|
|
73
|
+
* unrelated edit elsewhere in the dir also reads as dirty. That over-reports
|
|
74
|
+
* rather than under-reports, which is the side that cannot mislead someone
|
|
75
|
+
* trying to reproduce the artifact from the named commit.
|
|
76
|
+
*/
|
|
77
|
+
export async function headCommit(dir) {
|
|
78
|
+
try {
|
|
79
|
+
const { stdout } = await execFile("git", ["-C", dir, "rev-parse", "--short", "HEAD"]);
|
|
80
|
+
const sha = stdout.trim();
|
|
81
|
+
if (sha === "")
|
|
82
|
+
return null;
|
|
83
|
+
const { stdout: status } = await execFile("git", ["-C", dir, "status", "--porcelain", "--", "."]);
|
|
84
|
+
return status.trim() === "" ? sha : `${sha}+dirty`;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// ---------- linked worktrees ----------
|
|
91
|
+
/**
|
|
92
|
+
* The main checkout's twin of `dir`, or `null` when `dir` is not inside a
|
|
93
|
+
* linked git worktree.
|
|
94
|
+
*
|
|
95
|
+
* Pure filesystem reads — deliberately no `git` subprocess. The callers
|
|
96
|
+
* (`loadEnv`, `resolveMcpAuth`) are synchronous and sit on the startup path of
|
|
97
|
+
* `mcp connect`, which an agent spawns once per session; a spawn there would be
|
|
98
|
+
* paid on every session start of every agent.
|
|
99
|
+
*
|
|
100
|
+
* The walk: a linked worktree's `.git` is a FILE holding
|
|
101
|
+
* `gitdir: <main>/.git/worktrees/<name>`, and that directory's `commondir` file
|
|
102
|
+
* points back at the shared git dir (`../..`), whose parent is the main
|
|
103
|
+
* checkout. A submodule's `.git` is a file too, but points into
|
|
104
|
+
* `.git/modules/<name>` — the `worktrees` parent segment is what tells the two
|
|
105
|
+
* apart, and a submodule must NOT resolve against its superproject.
|
|
106
|
+
*
|
|
107
|
+
* The twin is a path, not a promise that anything exists there: the sync dir is
|
|
108
|
+
* tracked content (`decanter.config.json` is committed), so its path relative
|
|
109
|
+
* to the checkout root is identical on both sides — but a worktree branched
|
|
110
|
+
* from before the sync dir existed has no twin on disk. Callers check.
|
|
111
|
+
*/
|
|
112
|
+
export function mainCheckoutTwin(dir) {
|
|
113
|
+
const resolved = path.resolve(dir);
|
|
114
|
+
let root = resolved;
|
|
115
|
+
while (!existsSync(path.join(root, ".git"))) {
|
|
116
|
+
const parent = path.dirname(root);
|
|
117
|
+
if (parent === root)
|
|
118
|
+
return null; // walked to the filesystem root, no repo
|
|
119
|
+
root = parent;
|
|
120
|
+
}
|
|
121
|
+
const dotGit = path.join(root, ".git");
|
|
122
|
+
// A directory means the main checkout itself (or a plain clone) — no twin.
|
|
123
|
+
if (statSync(dotGit, { throwIfNoEntry: false })?.isFile() !== true)
|
|
124
|
+
return null;
|
|
125
|
+
const pointer = readFileSync(dotGit, "utf8").trim();
|
|
126
|
+
if (!pointer.startsWith("gitdir:"))
|
|
127
|
+
return null;
|
|
128
|
+
const gitdir = path.resolve(root, pointer.slice("gitdir:".length).trim());
|
|
129
|
+
if (path.basename(path.dirname(gitdir)) !== "worktrees")
|
|
130
|
+
return null; // submodule
|
|
131
|
+
const commonFile = path.join(gitdir, "commondir");
|
|
132
|
+
const common = existsSync(commonFile)
|
|
133
|
+
? path.resolve(gitdir, readFileSync(commonFile, "utf8").trim())
|
|
134
|
+
: path.resolve(gitdir, "..", ".."); // every git writes commondir; belt and braces
|
|
135
|
+
const twin = path.join(path.dirname(common), path.relative(root, resolved));
|
|
136
|
+
return twin === resolved ? null : twin;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Where to read a gitignored credential file (`.env`, `.decanter-auth.json`)
|
|
140
|
+
* from: this dir when it has its own, else the main checkout's copy when this
|
|
141
|
+
* is a linked worktree.
|
|
142
|
+
*
|
|
143
|
+
* **Local wins, always.** A worktree deliberately pointed at a staging instance
|
|
144
|
+
* keeps its own credentials; the fallback only fires where the alternative is
|
|
145
|
+
* failing outright, because both files are gitignored and therefore absent from
|
|
146
|
+
* every fresh worktree.
|
|
147
|
+
*
|
|
148
|
+
* For the auth file the shared copy is not merely convenient, it is the only
|
|
149
|
+
* correct shape: the OAuth refresh token is single-use and rotates on every
|
|
150
|
+
* redemption, and the cross-process recovery in `lib/mcp.mts` re-reads *the
|
|
151
|
+
* same file* to adopt the winner's token. Two copies fork into two token chains
|
|
152
|
+
* that recovery cannot repair — which is exactly what copying the file into a
|
|
153
|
+
* worktree (the obvious workaround, and the one Claude Code's `.worktreeinclude`
|
|
154
|
+
* would perform) silently does.
|
|
155
|
+
*
|
|
156
|
+
* Falls back to the local path when nothing is found, so a "not set" error
|
|
157
|
+
* still names the file the user is expected to create.
|
|
158
|
+
*/
|
|
159
|
+
export function credentialFile(dir, name) {
|
|
160
|
+
const local = path.join(dir, name);
|
|
161
|
+
if (existsSync(local))
|
|
162
|
+
return local;
|
|
163
|
+
const twin = mainCheckoutTwin(dir);
|
|
164
|
+
if (twin === null)
|
|
165
|
+
return local;
|
|
166
|
+
const shared = path.join(twin, name);
|
|
167
|
+
return existsSync(shared) ? shared : local;
|
|
168
|
+
}
|