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 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`. Details: [Installation](docs/getting-started/installation.md),
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 "Check the key's scopes in n8n → Settings → n8n API.";
48
+ return "";
36
49
  }
37
50
  export class N8nApi {
38
51
  #host;
39
52
  #apiKey;
40
53
  #timeoutMs;
41
- constructor({ host, apiKey, requestTimeoutMs = 30_000 }) {
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
- "X-N8N-API-KEY": this.#apiKey,
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(`${method} ${pathname} was refused (403) — N8N_API_KEY is valid but lacks a scope. ${scopeHint(method, pathname)}`);
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
  }
@@ -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
- /** Load .env (if present) into process.env, not overriding existing vars. */
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(path.join(dir, ".env")))) {
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
+ }