From 09d5cb643e9aba50cacacf49077286cfdd1048ff Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 11 Sep 2026 16:53:49 +0200 Subject: [PATCH 1/4] fix(cli,vscode): quote for cmd.exe, and stop keeping four copies of the rule quoteShellArg wrapped paths in POSIX single quotes. cmd.exe does not strip them: it hands them to the program verbatim, so a single-quoted path fails as soon as it contains a space. Measured on Windows 11 with node 24.14.0: node 'C:\dir with space\e.js' -> MODULE_NOT_FOUND, quotes included in the path node "C:\dir with space\e.js" -> runs Double quotes are understood by cmd.exe, PowerShell and bash alike, so they are the only portable choice there. POSIX keeps single quotes, which suppress every expansion where double quotes do not. Nothing here is executed by us. The quoted string is written into generated agent context and into CLI hints, for a person or an agent to paste into whatever shell they happen to have, which on Windows is often cmd.exe. The same three-line function was defined four times, all byte-identical and all wrong the same way, which is why the blast radius reached update-ai, promote, sync-manager and the extension. The three copies inside packages/cli collapse onto packages/cli/src/utils/shell.ts. The extension keeps its own: a three-line pure function is not worth widening the n8nac public type surface across a package boundary, and the comment there says so and points at the canonical one. The sync-manager tests pinned the single-quoted form. They now build the expectation from the helper instead of restating it, so the quoting style stays the helper's business. Known ceiling, written into the helper rather than pretended away: on Windows a value containing $ or a backtick still expands under PowerShell and bash. Both are legal in Windows filenames and no escaping satisfies all three shells at once. Closes #658 --- packages/cli/src/commands/promote.ts | 4 +-- packages/cli/src/commands/update-ai.ts | 4 +-- .../cli/src/core/services/sync-manager.ts | 8 ++--- packages/cli/src/utils/shell.ts | 24 ++++++++++++++ packages/cli/tests/unit/shell.test.ts | 32 +++++++++++++++++++ packages/cli/tests/unit/sync-manager.test.ts | 5 +-- packages/vscode-extension/src/extension.ts | 9 +++++- 7 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/utils/shell.ts create mode 100644 packages/cli/tests/unit/shell.test.ts diff --git a/packages/cli/src/commands/promote.ts b/packages/cli/src/commands/promote.ts index 0057ae3c4..0037127c5 100644 --- a/packages/cli/src/commands/promote.ts +++ b/packages/cli/src/commands/promote.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import { quoteShellArg } from '../utils/shell.js'; import path from 'path'; import chalk from 'chalk'; import { TypeScriptParser, WorkflowBuilder } from '@n8n-as-code/transformer'; @@ -1235,6 +1236,3 @@ function quoteString(value: string): string { return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; } -function quoteShellArg(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} diff --git a/packages/cli/src/commands/update-ai.ts b/packages/cli/src/commands/update-ai.ts index 76ddd4e3c..fd02d6a3d 100644 --- a/packages/cli/src/commands/update-ai.ts +++ b/packages/cli/src/commands/update-ai.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import { quoteShellArg } from '../utils/shell.js'; import fs from 'fs'; import { readFileSync, existsSync } from 'fs'; import { join, dirname, resolve, delimiter, basename } from 'path'; @@ -61,9 +62,6 @@ function readAgentsMdLevel(projectRoot: string): number | undefined { return Number.isSafeInteger(parsed) ? parsed : undefined; } -function quoteShellArg(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} function hasWorkspaceDevCommand(projectRoot: string): boolean { return N8NAC_DEV_CONFIG_FILENAMES.some((filename) => existsSync(join(projectRoot, filename))); diff --git a/packages/cli/src/core/services/sync-manager.ts b/packages/cli/src/core/services/sync-manager.ts index 06449eaed..8fc459142 100644 --- a/packages/cli/src/core/services/sync-manager.ts +++ b/packages/cli/src/core/services/sync-manager.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import { quoteShellArg } from '../../utils/shell.js'; import path from 'path'; import EventEmitter from 'events'; import { N8nApiClient } from './n8n-api-client.js'; @@ -395,7 +396,7 @@ export class SyncManager extends EventEmitter { const suggestedPath = scopeRelativeToCwd === '' ? `./${trimmed}` : path.join(scopeRelativeToCwd, trimmed); throw new Error( `Cannot push "${trimmed}": use the full relative path to the workflow file, not a bare filename.\n` + - `Example: n8nac push ${this.quoteShellArg(suggestedPath)}` + `Example: n8nac push ${quoteShellArg(suggestedPath)}` ); } @@ -424,7 +425,7 @@ export class SyncManager extends EventEmitter { `Cannot push "${trimmed}": path is not within the active sync scope.\n` + `Active sync scope : ${scopeLabel}\n` + `Expected path form: ${suggestedPath}\n` + - `Run : n8nac push ${this.quoteShellArg(suggestedPath)}\n\n` + + `Run : n8nac push ${quoteShellArg(suggestedPath)}\n\n` + `Tip: run \`n8nac workspace status --json\` and read \`workflowsPath\` to ` + `get the exact relative path where workflow files must be created and pushed from.` ); @@ -447,9 +448,6 @@ export class SyncManager extends EventEmitter { } } - private quoteShellArg(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; - } public async resolveConflict(workflowId: string, filename: string, resolution: 'local' | 'remote'): Promise { await this.ensureInitialized(); diff --git a/packages/cli/src/utils/shell.ts b/packages/cli/src/utils/shell.ts new file mode 100644 index 000000000..96499ed33 --- /dev/null +++ b/packages/cli/src/utils/shell.ts @@ -0,0 +1,24 @@ +/** + * Quote a path or argument for a shell we do not control. + * + * Nothing here is executed by us. The result is written into generated agent context + * and into CLI hints, for a person or an agent to paste into whatever shell they have. + * On Windows that is often `cmd.exe`, which does not strip POSIX single quotes: it hands + * them to the program verbatim, so a single-quoted path fails the moment it contains a + * space. Double quotes are understood by `cmd.exe`, PowerShell and bash alike, which + * makes them the only portable choice there. Windows forbids `"` in a path, so nothing + * needs escaping. + * + * POSIX keeps single quotes, which suppress every expansion; double quotes do not. + * + * Known ceiling: on Windows a value containing `$` or a backtick still expands under + * PowerShell and bash. Both are legal in Windows filenames, and no escaping satisfies + * cmd.exe, PowerShell and bash at once. Emit a relative path where you can. + * + * `platform` is injectable so the Windows branch is testable from any host. + */ +export function quoteShellArg(value: string, platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' + ? `"${value}"` + : `'${value.replace(/'/g, `'\\''`)}'`; +} diff --git a/packages/cli/tests/unit/shell.test.ts b/packages/cli/tests/unit/shell.test.ts new file mode 100644 index 000000000..aae04017b --- /dev/null +++ b/packages/cli/tests/unit/shell.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { quoteShellArg } from '../../src/utils/shell.js'; + +// Written with an explicit BACKSLASH constant rather than source escapes. The escaping is +// what these tests are about, so a literal that a tool or an editor can quietly reshape +// would let the test agree with a broken implementation. +const BACKSLASH = String.fromCharCode(92); + +describe('quoteShellArg', () => { + it('double-quotes on Windows, because cmd.exe does not strip single quotes', () => { + // Measured: `node ''` fails under cmd.exe with MODULE_NOT_FOUND, + // the quotes reaching node as part of the path. Double quotes work in cmd.exe, + // PowerShell and bash alike. + const p = 'C:' + BACKSLASH + 'dir with space' + BACKSLASH + 'e.js'; + + expect(quoteShellArg(p, 'win32')).toBe('"' + p + '"'); + }); + + it('single-quotes on POSIX, where they suppress every expansion', () => { + expect(quoteShellArg('/home/u/my dir/e.js', 'linux')).toBe("'/home/u/my dir/e.js'"); + }); + + it('closes, escapes and reopens around an embedded single quote on POSIX', () => { + expect(quoteShellArg("/home/u/it's/e.js", 'darwin')) + .toBe("'/home/u/it'" + BACKSLASH + "''s/e.js'"); + }); + + it('quotes a plain value on both, so callers never concatenate bare', () => { + expect(quoteShellArg('n8nac', 'win32')).toBe('"n8nac"'); + expect(quoteShellArg('n8nac', 'linux')).toBe("'n8nac'"); + }); +}); diff --git a/packages/cli/tests/unit/sync-manager.test.ts b/packages/cli/tests/unit/sync-manager.test.ts index 6b20294aa..8e01c6074 100644 --- a/packages/cli/tests/unit/sync-manager.test.ts +++ b/packages/cli/tests/unit/sync-manager.test.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import { quoteShellArg } from '../../src/utils/shell.js'; import os from 'os'; import path from 'path'; import { describe, it, expect, vi } from 'vitest'; @@ -74,7 +75,7 @@ describe('SyncManager push filename contract', () => { const outsidePath = path.join(TMP, 'outside-workflow.workflow.ts'); expect(() => manager.resolvePushTarget(outsidePath)).toThrowError( expect.objectContaining({ - message: expect.stringContaining("Run : n8nac push '") + message: expect.stringContaining('Run : n8nac push ') }) ); }); @@ -109,7 +110,7 @@ describe('SyncManager push filename contract', () => { ); expect(() => manager.resolvePushTarget(path.join(TMP, 'outside workflow.workflow.ts'))).toThrowError( expect.objectContaining({ - message: expect.stringContaining("Run : n8nac push './outside workflow.workflow.ts'") + message: expect.stringContaining(`Run : n8nac push ${quoteShellArg('./outside workflow.workflow.ts')}`) }) ); diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 492a89367..5a7afb4d8 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -2444,8 +2444,15 @@ function resolveAiContextManagerCommandOverride(context: vscode.ExtensionContext return `node ${quoteShellArg(siblingManagerCliPath)}`; } +// Mirrors packages/cli/src/utils/shell.ts. Duplicated on purpose: a three-line pure +// function is not worth widening the n8nac public type surface across a package boundary. +// cmd.exe does not strip POSIX single quotes, so a single-quoted path reaches the program +// with the quotes still attached and fails on the first space. Double quotes are understood +// by cmd.exe, PowerShell and bash alike, and Windows forbids `"` in a path. function quoteShellArg(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; + return process.platform === 'win32' + ? `"${value}"` + : `'${value.replace(/'/g, `'\\''`)}'`; } async function updateAiContextAfterSyncInitialization( From 9f159f55a2ef69151253695543a236717e890ef8 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 11 Sep 2026 17:13:43 +0200 Subject: [PATCH 2/4] docs(cli): say what the Windows quoting does not promise Review point, and it is right: double quotes group an argument, they do not stop cmd.exe expanding it. Measured: "C:\dir\%VAR%\b.js" -> %VAR% expands, with or without the quotes "C:\dir\!VAR!\b.js" -> expands too, when delayed expansion is on The old comment claimed Windows values need no escaping. That was about the double quote character, which Windows forbids in a path, but it read as a guarantee about expansion and there is none. No escaping fixes it. %% is batch-file syntax a prompt takes literally, ^ is inert inside quotes, and either would corrupt the same string under PowerShell and bash, where $ and a backtick expand instead. One string cannot be safe in three shells at once, so the contract now says what it covers and tells callers to prefer a relative path in generated text. Worth noting the hazard is not new: the single-quoted form this PR replaces expanded %VAR% in cmd.exe exactly the same way, and failed on spaces as well. Two test cases pin the behaviour: a Windows value carrying % and ! passes through unchanged, and the POSIX branch does suppress $ expansion. --- packages/cli/src/utils/shell.ts | 31 ++++++++++++++-------- packages/cli/tests/unit/shell.test.ts | 16 +++++++++++ packages/vscode-extension/src/extension.ts | 8 +++++- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/utils/shell.ts b/packages/cli/src/utils/shell.ts index 96499ed33..15b082793 100644 --- a/packages/cli/src/utils/shell.ts +++ b/packages/cli/src/utils/shell.ts @@ -1,19 +1,28 @@ /** * Quote a path or argument for a shell we do not control. * - * Nothing here is executed by us. The result is written into generated agent context - * and into CLI hints, for a person or an agent to paste into whatever shell they have. - * On Windows that is often `cmd.exe`, which does not strip POSIX single quotes: it hands - * them to the program verbatim, so a single-quoted path fails the moment it contains a - * space. Double quotes are understood by `cmd.exe`, PowerShell and bash alike, which - * makes them the only portable choice there. Windows forbids `"` in a path, so nothing - * needs escaping. + * Nothing here is executed by us. The result is written into generated agent context and + * into CLI hints, for a person or an agent to paste into whatever shell they have. On + * Windows that is often `cmd.exe`, which does not strip POSIX single quotes: it hands them + * to the program verbatim, so a single-quoted path fails the moment it contains a space. + * Double quotes are understood by `cmd.exe`, PowerShell and bash alike, which makes them + * the only portable choice there. * - * POSIX keeps single quotes, which suppress every expansion; double quotes do not. + * POSIX keeps single quotes, which suppress every expansion. That branch is airtight. * - * Known ceiling: on Windows a value containing `$` or a backtick still expands under - * PowerShell and bash. Both are legal in Windows filenames, and no escaping satisfies - * cmd.exe, PowerShell and bash at once. Emit a relative path where you can. + * What this promises, and what it does not: it groups a value into a single argument. On + * Windows it does not suppress expansion, and cannot. Measured on cmd.exe: + * + * "C:\dir\%VAR%\b.js" -> %VAR% expands, with or without the quotes + * "C:\dir\!VAR!\b.js" -> expands too, when delayed expansion is on + * + * No escaping fixes that. `%%` is batch-file syntax a prompt takes literally, `^` is inert + * inside quotes, and either would corrupt the same string under PowerShell and bash, where + * `$` and a backtick expand instead. One string cannot be safe in three shells at once. + * + * So the rule for callers is to prefer a relative path in generated text wherever one will + * do. `%`, `!`, `$` and a backtick are all legal in Windows filenames, and a value carrying + * one reaches the program changed. * * `platform` is injectable so the Windows branch is testable from any host. */ diff --git a/packages/cli/tests/unit/shell.test.ts b/packages/cli/tests/unit/shell.test.ts index aae04017b..d9a1e7f6f 100644 --- a/packages/cli/tests/unit/shell.test.ts +++ b/packages/cli/tests/unit/shell.test.ts @@ -5,6 +5,7 @@ import { quoteShellArg } from '../../src/utils/shell.js'; // what these tests are about, so a literal that a tool or an editor can quietly reshape // would let the test agree with a broken implementation. const BACKSLASH = String.fromCharCode(92); +const DOLLAR = String.fromCharCode(36); describe('quoteShellArg', () => { it('double-quotes on Windows, because cmd.exe does not strip single quotes', () => { @@ -29,4 +30,19 @@ describe('quoteShellArg', () => { expect(quoteShellArg('n8nac', 'win32')).toBe('"n8nac"'); expect(quoteShellArg('n8nac', 'linux')).toBe("'n8nac'"); }); + + it('groups a Windows value without suppressing expansion, which no escaping can fix', () => { + // Measured on cmd.exe: %VAR% expands inside double quotes, and !VAR! expands when + // delayed expansion is on. The value passes through unchanged on purpose. %% is + // batch-file syntax a prompt takes literally, ^ is inert inside quotes, and either + // would corrupt the same string under PowerShell and bash. Callers avoid this by + // preferring a relative path, not by escaping. + const p = 'C:' + BACKSLASH + 'dir' + BACKSLASH + '%VAR%' + BACKSLASH + '!VAR!.js'; + + expect(quoteShellArg(p, 'win32')).toBe('"' + p + '"'); + }); + + it('does suppress expansion on POSIX, where single quotes can', () => { + expect(quoteShellArg('/home/u/' + DOLLAR + 'VAR/e.js', 'linux')).toBe("'/home/u/" + DOLLAR + "VAR/e.js'"); + }); }); diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 5a7afb4d8..82834d7ab 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -2446,9 +2446,15 @@ function resolveAiContextManagerCommandOverride(context: vscode.ExtensionContext // Mirrors packages/cli/src/utils/shell.ts. Duplicated on purpose: a three-line pure // function is not worth widening the n8nac public type surface across a package boundary. +// // cmd.exe does not strip POSIX single quotes, so a single-quoted path reaches the program // with the quotes still attached and fails on the first space. Double quotes are understood -// by cmd.exe, PowerShell and bash alike, and Windows forbids `"` in a path. +// by cmd.exe, PowerShell and bash alike. +// +// This groups a value into one argument; on Windows it does not suppress expansion and +// cannot. %VAR% expands in cmd.exe with or without quotes, !VAR! expands under delayed +// expansion, and $ and a backtick expand under PowerShell and bash. See the canonical file +// for why no escaping satisfies all three shells. function quoteShellArg(value: string): string { return process.platform === 'win32' ? `"${value}"` From a2cb7e1fcb1661d19b962d9768b1bf980b3fe1f2 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 11 Sep 2026 16:59:37 +0200 Subject: [PATCH 3/4] feat(cli): say so when the installed n8nac is behind what is published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in this repo ever compared the installed CLI against the registry. That was survivable while generated agent instructions ran `npx --yes n8nac@`: npx re-resolved the dist tag on every single call, so an agent silently ran the newest publish and nobody had to think about versions. Naming a local install is roughly eight times faster per call but pins the version, so the invisible update is gone and something has to say so out loud. One dim line on stderr at the end of a successful `update-ai`, which is exactly the moment the agent context is regenerated and already the command that knows both the version and the dist tag. It reads about 80 bytes from the registry's dist-tags document, not a packument. Everything about it is best-effort: a three-second abort signal, and a catch that swallows offline, refused, malformed and unexpected alike. `update-ai` is documented as never throwing and that stays true. Respects the opt-outs that already exist rather than inventing one — `CI=true` and `DO_NOT_TRACK=1`, both already read by the telemetry package — plus `NO_UPDATE_NOTIFIER`, the de-facto standard name for this specific check. Silent under `--silent`, and silent in a dev checkout, where a maintainer on a locally bumped version should not be told to update. No semver dependency: a plain inequality against the dist tag is enough for a hint, and skipping dev checkouts removes the only case where it could read backwards. The dev-checkout probe that `inferFastCliCommand` had inline is now a named function, since two callers need it. Closes #660 --- packages/cli/src/commands/update-ai.ts | 54 +++++++++++++-- packages/cli/src/utils/version-check.ts | 66 +++++++++++++++++++ packages/cli/tests/unit/version-check.test.ts | 55 ++++++++++++++++ 3 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/utils/version-check.ts create mode 100644 packages/cli/tests/unit/version-check.test.ts diff --git a/packages/cli/src/commands/update-ai.ts b/packages/cli/src/commands/update-ai.ts index fd02d6a3d..133214b5b 100644 --- a/packages/cli/src/commands/update-ai.ts +++ b/packages/cli/src/commands/update-ai.ts @@ -1,5 +1,6 @@ import chalk from 'chalk'; import { quoteShellArg } from '../utils/shell.js'; +import { findNewerPublishedVersion } from '../utils/version-check.js'; import fs from 'fs'; import { readFileSync, existsSync } from 'fs'; import { join, dirname, resolve, delimiter, basename } from 'path'; @@ -82,17 +83,29 @@ function isN8nacOnShellPath(): boolean { && extensions.some((ext) => existsSync(join(dir, `n8nac${ext}`)))); } +/** + * The monorepo entry point, when the CLI is running from a dev checkout rather than an + * install. Used both to emit a fast command and to stay quiet about version drift: a + * maintainer on a locally bumped version should not be told to update. + */ +function devCheckoutEntrypoint(): string | undefined { + const entrypoint = process.argv[1] ? resolve(process.argv[1]) : ''; + return entrypoint + && !entrypoint.includes(`${join('node_modules', '')}`) + && entrypoint.endsWith(join('packages', 'cli', 'dist', 'index.js')) + && existsSync(entrypoint) + ? entrypoint + : undefined; +} + function inferFastCliCommand(projectRoot: string): string | undefined { if (process.env.N8NAC_COMMAND || hasWorkspaceDevCommand(projectRoot)) { return undefined; } - const entrypoint = process.argv[1] ? resolve(process.argv[1]) : ''; - if (entrypoint - && !entrypoint.includes(`${join('node_modules', '')}`) - && entrypoint.endsWith(join('packages', 'cli', 'dist', 'index.js')) - && existsSync(entrypoint)) { - return `node ${quoteShellArg(entrypoint)}`; + const devEntrypoint = devCheckoutEntrypoint(); + if (devEntrypoint) { + return `node ${quoteShellArg(devEntrypoint)}`; } // Prefer the installed binary: npx pays npm's own startup on every invocation. @@ -149,6 +162,33 @@ async function createAiContextGenerator(): Promise { return new mod.AiContextGenerator(); } +/** + * One dim line when a newer version is published. Runs only after update-ai has already + * succeeded, and swallows everything: a version check has no business failing a command. + * Written to stderr, like the refresh notice, so machine-readable stdout stays clean. + * + * Built by concatenation rather than a template literal so the backticks in the message + * are plainly literal. + */ +async function noticeIfOutdated(): Promise { + try { + if (devCheckoutEntrypoint()) return; + + const current = getCliVersion(); + const distTag = getDistTag(); + const published = await findNewerPublishedVersion(current, distTag); + if (!published) return; + + console.error(chalk.dim( + 'ℹ n8nac: ' + published + ' is published, this is ' + current + '. ' + + 'Update with `npm i n8nac@' + (distTag ?? 'latest') + '`, adding `-g` if you installed ' + + 'globally, then rerun `update-ai`.', + )); + } catch { + // Never surface a version check to the user as a failure. + } +} + export class UpdateAiCommand { /** @@ -261,6 +301,8 @@ export class UpdateAiCommand { console.log(chalk.gray(' ✔ .agents/skills: Portable n8n-architect skill fallback')); console.log(chalk.gray(' ✔ n8n-workflows.d.ts: TypeScript stubs (per environment)')); console.log(chalk.gray(' ✔ Source of truth: n8n-nodes-technical.json (via @n8n-as-code/skills)\n')); + + await noticeIfOutdated(); } else if (updatedCount > 0 || existsSync(join(projectRoot, 'AGENTS.md'))) { // Single dim notice so the user knows a refresh happened — written to stderr // to avoid corrupting machine-readable stdout output (e.g. `n8nac list --raw`) diff --git a/packages/cli/src/utils/version-check.ts b/packages/cli/src/utils/version-check.ts new file mode 100644 index 000000000..a26ff0e51 --- /dev/null +++ b/packages/cli/src/utils/version-check.ts @@ -0,0 +1,66 @@ +/** + * Tell the user when the installed CLI is behind what is published. + * + * This exists because of what it replaced. Generated agent instructions used to run + * `npx --yes n8nac@`, which re-resolved the dist tag from the registry on every + * single call, so an agent silently ran the newest publish and nobody had to think + * about versions. Naming a local install is far faster but pins it, so the invisible + * update is gone and something has to say so out loud. + * + * Everything here is best-effort: it must never fail a command, never block for long, + * and never reach the network when the user has said not to. + */ + +/** The whole document is about 80 bytes, so there is no packument to download. */ +const DIST_TAGS_URL = 'https://registry.npmjs.org/-/package/n8nac/dist-tags'; +const TIMEOUT_MS = 3000; + +/** + * Reuses the vocabulary the telemetry package already established rather than inventing + * a variable, plus `NO_UPDATE_NOTIFIER`, the de-facto standard name for this one check. + */ +function optedOut(env: NodeJS.ProcessEnv): boolean { + return env.CI === 'true' + || env.DO_NOT_TRACK === '1' + || Boolean(env.NO_UPDATE_NOTIFIER); +} + +/** + * The version published under `distTag`, when it differs from what is running. + * Undefined for every other outcome, including every failure. + */ +export async function findNewerPublishedVersion( + currentVersion: string | undefined, + distTag: string | undefined, + env: NodeJS.ProcessEnv = process.env, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!currentVersion || optedOut(env)) return undefined; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetchImpl(DIST_TAGS_URL, { + headers: { 'User-Agent': 'n8n-as-code' }, + signal: controller.signal, + redirect: 'error', + }); + if (!response.ok) return undefined; + + const tags = await response.json() as Record; + const published = tags?.[distTag ?? 'latest']; + + // Plain inequality, not a semver comparison. The caller skips dev checkouts, which + // is the only case where the running version can legitimately be ahead of the tag, + // and a dependency for one string compare would not earn its place. + return typeof published === 'string' && published !== currentVersion + ? published + : undefined; + } catch { + // Offline, slow, rate-limited, or a response shaped differently than expected. + // None of that is the user's problem in the middle of an update-ai. + return undefined; + } finally { + clearTimeout(timer); + } +} diff --git a/packages/cli/tests/unit/version-check.test.ts b/packages/cli/tests/unit/version-check.test.ts new file mode 100644 index 000000000..35ea200e7 --- /dev/null +++ b/packages/cli/tests/unit/version-check.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { findNewerPublishedVersion } from '../../src/utils/version-check.js'; + +const TAGS = { latest: '2.7.0', next: '2.7.1-rc.1' }; + +/** A fetch that answers with a dist-tags document, and records how it was called. */ +function tagsFetch(body: unknown = TAGS, ok = true) { + return vi.fn(async () => ({ ok, json: async () => body })) as unknown as typeof fetch; +} + +describe('findNewerPublishedVersion', () => { + it('reports the published version for the running dist tag', async () => { + await expect(findNewerPublishedVersion('2.6.0', undefined, {}, tagsFetch())).resolves.toBe('2.7.0'); + await expect(findNewerPublishedVersion('2.6.0-rc.9', 'next', {}, tagsFetch())).resolves.toBe('2.7.1-rc.1'); + }); + + it('stays quiet when the running version is already the published one', async () => { + await expect(findNewerPublishedVersion('2.7.0', undefined, {}, tagsFetch())).resolves.toBeUndefined(); + }); + + it('never reaches the network when the environment has opted out', async () => { + for (const env of [{ CI: 'true' }, { DO_NOT_TRACK: '1' }, { NO_UPDATE_NOTIFIER: '1' }]) { + const fetchImpl = tagsFetch(); + await expect(findNewerPublishedVersion('2.6.0', undefined, env, fetchImpl)).resolves.toBeUndefined(); + expect(fetchImpl).not.toHaveBeenCalled(); + } + }); + + it('stays quiet without a version to compare against', async () => { + const fetchImpl = tagsFetch(); + await expect(findNewerPublishedVersion(undefined, undefined, {}, fetchImpl)).resolves.toBeUndefined(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('swallows a refused, malformed or unreachable registry', async () => { + const rejects = vi.fn(async () => { throw new Error('ENOTFOUND'); }) as unknown as typeof fetch; + + await expect(findNewerPublishedVersion('2.6.0', undefined, {}, rejects)).resolves.toBeUndefined(); + await expect(findNewerPublishedVersion('2.6.0', undefined, {}, tagsFetch(TAGS, false))).resolves.toBeUndefined(); + await expect(findNewerPublishedVersion('2.6.0', undefined, {}, tagsFetch(null))).resolves.toBeUndefined(); + await expect(findNewerPublishedVersion('2.6.0', undefined, {}, tagsFetch({ latest: 42 }))).resolves.toBeUndefined(); + }); + + it('stays quiet when the running dist tag is absent from the document', async () => { + await expect(findNewerPublishedVersion('2.6.0', 'canary', {}, tagsFetch())).resolves.toBeUndefined(); + }); + + it('sends an abort signal, so a hanging registry cannot hold the command open', async () => { + const fetchImpl = tagsFetch(); + await findNewerPublishedVersion('2.6.0', undefined, {}, fetchImpl); + + const init = (fetchImpl as unknown as ReturnType).mock.calls[0][1]; + expect(init.signal).toBeInstanceOf(AbortSignal); + }); +}); From d2f7a02327d1922a6bda693578462b638fd6e028 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 11 Sep 2026 17:08:46 +0200 Subject: [PATCH 4/4] docs(troubleshooting): how to silence the update notice The check opts out on NO_UPDATE_NOTIFIER, DO_NOT_TRACK and CI, but an opt-out nobody can find is not an opt-out. Put in troubleshooting rather than the CLI Update section, which #657 is currently rewriting. --- docs/docs/troubleshooting.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/docs/troubleshooting.md b/docs/docs/troubleshooting.md index 29293bf94..56f2223dc 100644 --- a/docs/docs/troubleshooting.md +++ b/docs/docs/troubleshooting.md @@ -235,6 +235,19 @@ n8nac --help The current package is `n8nac`. +## Update Notice + +`n8nac update-ai` checks whether a newer version is published and, if so, prints one line telling you. It reads about 80 bytes from the npm registry, gives up after three seconds, and never fails the command. + +Turn it off with any of these: + +```bash +NO_UPDATE_NOTIFIER=1 +DO_NOT_TRACK=1 +``` + +It is already silent in CI, under `--silent`, and when you are running from a checkout of this repository. + ## Complete Reset Back up workflows first, then recreate the environment: