Skip to content

Commit cfab618

Browse files
syucreamclaude
andauthored
add daily update check with release notice (#33)
* feat(cli): add daily update check with release notice Query GitHub Releases at most once every 24h and show a one-line notice on stderr when a newer version is available. Skipped for dev builds, CI environments, and when N8N_CLI_DISABLE_UPDATE_CHECK=1. Cache is stored under XDG_CACHE_HOME / Library/Caches / LOCALAPPDATA depending on platform. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: bump version to 2.1.1 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9f6755f commit cfab618

4 files changed

Lines changed: 238 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "n8n-cli",
3-
"version": "2.1.0",
3+
"version": "2.1.1",
44
"module": "src/index.ts",
55
"type": "module",
66
"private": true,

src/cli/update-check.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
5+
declare const CLI_VERSION: string;
6+
7+
const REPO = "ubie-oss/n8n-cli";
8+
const LATEST_RELEASE_URL = `https://api.github.com/repos/${REPO}/releases/latest`;
9+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
10+
const FETCH_TIMEOUT_MS = 3000;
11+
12+
interface CacheEntry {
13+
lastCheckedAt: string;
14+
latestVersion: string | null;
15+
}
16+
17+
/** Resolve the cache file path in a platform-appropriate location. */
18+
export function cacheFilePath(
19+
env: NodeJS.ProcessEnv = process.env,
20+
platform: NodeJS.Platform = process.platform,
21+
home: string = os.homedir(),
22+
): string {
23+
if (env.XDG_CACHE_HOME) {
24+
return path.join(env.XDG_CACHE_HOME, "n8n-cli", "update-check.json");
25+
}
26+
switch (platform) {
27+
case "darwin":
28+
return path.join(home, "Library", "Caches", "n8n-cli", "update-check.json");
29+
case "win32":
30+
return path.join(
31+
env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"),
32+
"n8n-cli",
33+
"Cache",
34+
"update-check.json",
35+
);
36+
default:
37+
return path.join(home, ".cache", "n8n-cli", "update-check.json");
38+
}
39+
}
40+
41+
/**
42+
* Compare two semver-ish versions. Returns 1 if a>b, -1 if a<b, 0 if equal.
43+
* Strips leading "v" and trailing "-dirty"/pre-release suffixes for comparison.
44+
*/
45+
export function compareVersions(a: string, b: string): number {
46+
const normalize = (v: string): number[] =>
47+
v
48+
.replace(/^v/, "")
49+
.split("-")[0]!
50+
.split(".")
51+
.map((s) => Number.parseInt(s, 10))
52+
.map((n) => (Number.isNaN(n) ? 0 : n));
53+
54+
const parsedA = normalize(a);
55+
const parsedB = normalize(b);
56+
const len = Math.max(parsedA.length, parsedB.length);
57+
for (let i = 0; i < len; i++) {
58+
const x = parsedA[i] ?? 0;
59+
const y = parsedB[i] ?? 0;
60+
if (x > y) return 1;
61+
if (x < y) return -1;
62+
}
63+
return 0;
64+
}
65+
66+
function isCheckDisabled(): boolean {
67+
if (process.env.N8N_CLI_DISABLE_UPDATE_CHECK === "1") return true;
68+
if (process.env.CI === "true" || process.env.CI === "1") return true;
69+
return false;
70+
}
71+
72+
function readCache(filePath: string): CacheEntry | null {
73+
try {
74+
const raw = fs.readFileSync(filePath, "utf8");
75+
const parsed = JSON.parse(raw) as unknown;
76+
if (
77+
typeof parsed === "object" &&
78+
parsed !== null &&
79+
typeof (parsed as CacheEntry).lastCheckedAt === "string"
80+
) {
81+
return parsed as CacheEntry;
82+
}
83+
return null;
84+
} catch {
85+
return null;
86+
}
87+
}
88+
89+
function writeCache(filePath: string, entry: CacheEntry): void {
90+
try {
91+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
92+
fs.writeFileSync(filePath, JSON.stringify(entry, null, 2));
93+
} catch {
94+
// ignore — cache write failures must not affect the CLI
95+
}
96+
}
97+
98+
async function fetchLatestVersion(): Promise<string | null> {
99+
const controller = new AbortController();
100+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
101+
try {
102+
const res = await fetch(LATEST_RELEASE_URL, {
103+
headers: {
104+
Accept: "application/vnd.github+json",
105+
"User-Agent": "n8n-cli-update-check",
106+
},
107+
signal: controller.signal,
108+
});
109+
if (!res.ok) return null;
110+
const json = (await res.json()) as { tag_name?: unknown };
111+
return typeof json.tag_name === "string" ? json.tag_name : null;
112+
} catch {
113+
return null;
114+
} finally {
115+
clearTimeout(timer);
116+
}
117+
}
118+
119+
function currentVersion(): string | null {
120+
const v = typeof CLI_VERSION !== "undefined" ? CLI_VERSION : "dev";
121+
if (v === "dev" || v === "unknown" || v === "") return null;
122+
return v;
123+
}
124+
125+
/**
126+
* Kick off an update check. Returns a promise so the caller can await it
127+
* before showing the notice. Safe to fire-and-forget if the caller prefers.
128+
* Silent on all errors.
129+
*/
130+
export async function runUpdateCheck(): Promise<void> {
131+
if (isCheckDisabled()) return;
132+
if (currentVersion() === null) return;
133+
134+
const file = cacheFilePath();
135+
const cache = readCache(file);
136+
const now = Date.now();
137+
if (cache) {
138+
const last = Date.parse(cache.lastCheckedAt);
139+
if (!Number.isNaN(last) && now - last < CHECK_INTERVAL_MS) return;
140+
}
141+
142+
const latest = await fetchLatestVersion();
143+
writeCache(file, {
144+
lastCheckedAt: new Date(now).toISOString(),
145+
latestVersion: latest,
146+
});
147+
}
148+
149+
/**
150+
* If a newer version is known (from a prior check), print a one-line notice
151+
* to stderr. Never throws.
152+
*/
153+
export function maybeShowUpdateNotice(): void {
154+
if (isCheckDisabled()) return;
155+
const current = currentVersion();
156+
if (current === null) return;
157+
158+
const cache = readCache(cacheFilePath());
159+
if (!cache || !cache.latestVersion) return;
160+
161+
if (compareVersions(cache.latestVersion, current) > 0) {
162+
const latest = cache.latestVersion;
163+
process.stderr.write(
164+
`\n[n8n-cli] A new version ${latest} is available (current: ${current}).\n` +
165+
` Update: git pull && make build (https://github.com/${REPO}/releases/latest)\n` +
166+
` Silence: export N8N_CLI_DISABLE_UPDATE_CHECK=1\n`,
167+
);
168+
}
169+
}

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ import { registerTestCommand } from "./cli/commands/test.ts";
1212
import { registerTraceCommand } from "./cli/commands/trace.ts";
1313
import { registerWorkflowCommand } from "./cli/commands/workflow.ts";
1414
import { createProgram } from "./cli/root.ts";
15+
import { maybeShowUpdateNotice, runUpdateCheck } from "./cli/update-check.ts";
1516

1617
const program = createProgram();
1718

19+
// Kick off the update check in the background — result is persisted to the
20+
// cache file and shown on the *next* run (keeps this run's exit path fast).
21+
const updateCheckPromise = runUpdateCheck().catch(() => {});
22+
1823
// Register commands
1924
registerWorkflowCommand(program);
2025
registerExecutionCommand(program);
@@ -32,6 +37,8 @@ registerTraceCommand(program);
3237

3338
try {
3439
await program.parseAsync(process.argv);
40+
await updateCheckPromise;
41+
maybeShowUpdateNotice();
3542
} catch (err) {
3643
if (err instanceof Error) {
3744
console.error(`Error: ${err.message}`);

tests/cli/update-check.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { cacheFilePath, compareVersions } from "@/cli/update-check.ts";
3+
4+
describe("compareVersions", () => {
5+
test("returns 0 for equal versions", () => {
6+
expect(compareVersions("2.1.0", "2.1.0")).toBe(0);
7+
});
8+
9+
test("returns 1 when a is newer", () => {
10+
expect(compareVersions("2.2.0", "2.1.0")).toBe(1);
11+
expect(compareVersions("2.1.1", "2.1.0")).toBe(1);
12+
expect(compareVersions("3.0.0", "2.9.9")).toBe(1);
13+
});
14+
15+
test("returns -1 when a is older", () => {
16+
expect(compareVersions("2.0.9", "2.1.0")).toBe(-1);
17+
});
18+
19+
test("strips leading v prefix", () => {
20+
expect(compareVersions("v2.2.0", "2.1.0")).toBe(1);
21+
expect(compareVersions("v2.1.0", "v2.1.0")).toBe(0);
22+
});
23+
24+
test("ignores -dirty / pre-release suffixes", () => {
25+
expect(compareVersions("2.1.0-dirty", "2.1.0")).toBe(0);
26+
expect(compareVersions("2.1.0-rc.1", "2.1.0")).toBe(0);
27+
});
28+
29+
test("handles missing segments as zero", () => {
30+
expect(compareVersions("2.1", "2.1.0")).toBe(0);
31+
expect(compareVersions("2", "2.0.1")).toBe(-1);
32+
});
33+
});
34+
35+
describe("cacheFilePath", () => {
36+
test("honors XDG_CACHE_HOME when set", () => {
37+
const p = cacheFilePath({ XDG_CACHE_HOME: "/tmp/xdg" }, "linux", "/home/u");
38+
expect(p).toBe("/tmp/xdg/n8n-cli/update-check.json");
39+
});
40+
41+
test("uses ~/Library/Caches on darwin", () => {
42+
const p = cacheFilePath({}, "darwin", "/Users/u");
43+
expect(p).toBe("/Users/u/Library/Caches/n8n-cli/update-check.json");
44+
});
45+
46+
test("uses LOCALAPPDATA on win32", () => {
47+
const p = cacheFilePath(
48+
{ LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" },
49+
"win32",
50+
"C:\\Users\\u",
51+
);
52+
expect(p).toContain("AppData");
53+
expect(p).toContain("n8n-cli");
54+
expect(p).toContain("update-check.json");
55+
});
56+
57+
test("falls back to ~/.cache on linux", () => {
58+
const p = cacheFilePath({}, "linux", "/home/u");
59+
expect(p).toBe("/home/u/.cache/n8n-cli/update-check.json");
60+
});
61+
});

0 commit comments

Comments
 (0)