From 860dcd80391cab6a11d2b681a4aa1b193a831dab Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:02:36 -0400 Subject: [PATCH 1/7] feat(cli)!: drop Node 20, require Node >= 22.12 Node 20 is EOL (2026-04-30). The new floor matches commander 15's engine requirement, so the later commander major needs no second engines change. The runtime guard reads engines from the manifest, so the refusal message updates itself. Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 3 +-- .github/workflows/ci.yml | 4 ++-- CONTRIBUTING.md | 4 ++-- README.md | 4 ++-- cli/README.md | 2 +- cli/package.json | 2 +- cli/src/__tests__/node-version.test.ts | 2 +- cli/src/node-version.ts | 4 ++-- deploy/local/README.md | 2 +- deploy/remote/README.md | 4 ++-- 10 files changed, 15 insertions(+), 16 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f579eefdf..3063a8a25 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,8 +11,7 @@ updates: dependency-type: development ignore: # commander is version-synced with cli/package.json (a test enforces - # it), and the cli supports Node >=20.12 while commander 15 requires - # Node >=22.12. Major bumps are a deliberate cli decision. + # it). Major bumps are a deliberate cli decision. - dependency-name: commander update-types: ["version-update:semver-major"] # typescript is aliased to @typescript/typescript6 (the JS compiler diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a4ce019b..521d6dfed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,14 +66,14 @@ jobs: - run: npm run build - # The cli package targets older runtimes than the server (engines >=20.12), + # The cli package targets older runtimes than the server (engines >=22.12), # but the repo's @types/node tracks the server's Node version — so only a # runtime smoke test catches accidental use of too-new APIs in the CLI. cli-smoke: runs-on: ubuntu-latest strategy: matrix: - node-version: [20, 22, 24] + node-version: [22, 24] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4f6ecc90..d703e08d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -140,10 +140,10 @@ truth. Key points: ``` 4. **Fill out the PR template** — the checklist mirrors CI -5. **Required checks must pass** — the `main` ruleset requires all eight; +5. **Required checks must pass** — the `main` ruleset requires all seven; each blocks the merge and the finding details are in its job log: - `checks` — prettier, lint, markdownlint, knip, test, and build - - `cli-smoke (20)` / `cli-smoke (22)` / `cli-smoke (24)` — builds the + - `cli-smoke (22)` / `cli-smoke (24)` — builds the CLI and runs `init` on each supported Node major, catching APIs too new for the CLI's `engines` range - `arch-smoke (amd64)` / `arch-smoke (arm64)` — builds the Docker image diff --git a/README.md b/README.md index 883b8b533..8032f9005 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ ### Local (2 minutes — Docker + your vault folder) -**Prerequisites:** [Docker](https://docs.docker.com/get-docker/) (or a Docker-compatible runtime, e.g. OrbStack, Colima, Podman), Node.js >= 20.12 (only for the CLI — the server itself runs in Docker), and an Obsidian vault (or any folder of `.md` files). +**Prerequisites:** [Docker](https://docs.docker.com/get-docker/) (or a Docker-compatible runtime, e.g. OrbStack, Colima, Podman), Node.js >= 22.12 (only for the CLI — the server itself runs in Docker), and an Obsidian vault (or any folder of `.md` files). ```bash npx vault-cortex@latest init @@ -111,7 +111,7 @@ All three need an [Obsidian Sync](https://obsidian.md/sync) subscription. Whiche #### Self-hosted: your own VPS -The [vault-cortex CLI](./cli/) sets up the same container on any Linux box you run — you manage the server, the image, and updates. You need Node.js >= 20.12 for the CLI itself; the server runs in Docker. +The [vault-cortex CLI](./cli/) sets up the same container on any Linux box you run — you manage the server, the image, and updates. You need Node.js >= 22.12 for the CLI itself; the server runs in Docker. ```bash # On your VPS: diff --git a/cli/README.md b/cli/README.md index d340fcf70..f98166a01 100644 --- a/cli/README.md +++ b/cli/README.md @@ -206,7 +206,7 @@ During `init --mode remote`, this flow is offered automatically. ## Requirements -- Node.js >= 20.12 (only for this CLI — the server itself runs in Docker) +- Node.js >= 22.12 (only for this CLI — the server itself runs in Docker) - [Docker](https://docs.docker.com/get-docker/) or a Docker-compatible runtime (e.g. OrbStack, Colima, Podman) to run the server — the CLI manages the container through the `docker` command (on Linux, see diff --git a/cli/package.json b/cli/package.json index 91ade7228..803b7e400 100644 --- a/cli/package.json +++ b/cli/package.json @@ -12,7 +12,7 @@ "README.md" ], "engines": { - "node": ">=20.12.0" + "node": ">=22.12.0" }, "repository": { "type": "git", diff --git a/cli/src/__tests__/node-version.test.ts b/cli/src/__tests__/node-version.test.ts index 6f7bc5a61..b2fc71f22 100644 --- a/cli/src/__tests__/node-version.test.ts +++ b/cli/src/__tests__/node-version.test.ts @@ -27,7 +27,7 @@ describe("minimumNodeVersion", () => { const minimum = minimumNodeVersion(manifest.engines.node) - expect(minimum).toBe("20.12.0") + expect(minimum).toBe("22.12.0") }) it("throws on a range with no version in it", () => { diff --git a/cli/src/node-version.ts b/cli/src/node-version.ts index 90a67ea9f..c83527218 100644 --- a/cli/src/node-version.ts +++ b/cli/src/node-version.ts @@ -1,8 +1,8 @@ -/** Matches the first dotted version number in an engines range like ">=20.12.0". */ +/** Matches the first dotted version number in an engines range like ">=22.12.0". */ const VERSION_IN_RANGE = /(\d+)\.(\d+)(?:\.(\d+))?/ /** - * Extracts the minimum version from a simple engines range (">=20.12.0"). + * Extracts the minimum version from a simple engines range (">=22.12.0"). * The CLI only ever declares a floor, so the first version in the string * is the minimum. */ diff --git a/deploy/local/README.md b/deploy/local/README.md index 735569682..18d7c7600 100644 --- a/deploy/local/README.md +++ b/deploy/local/README.md @@ -8,7 +8,7 @@ Obsidian Sync — just Docker and a folder of `.md` files. ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) (v20.10+) -- Node.js >= 20.12 — only for the CLI setup below; the +- Node.js >= 22.12 — only for the CLI setup below; the [manual setup](#setup) needs just Docker - An Obsidian vault (or any folder of Markdown files) diff --git a/deploy/remote/README.md b/deploy/remote/README.md index 54c3ac992..bfae64fb6 100644 --- a/deploy/remote/README.md +++ b/deploy/remote/README.md @@ -39,7 +39,7 @@ starts the server and prints the connection details for your MCP client
Don't have Node.js installed? -The CLI needs Node.js >= 20.12 (the server itself runs in Docker). On Ubuntu/Debian: +The CLI needs Node.js >= 22.12 (the server itself runs in Docker). On Ubuntu/Debian: ```bash curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - @@ -68,7 +68,7 @@ Or clone the repo and `cd deploy/remote`. **3. Generate your Obsidian Sync auth token** (one-time): -If you have Node.js >= 20.12 on this machine, the CLI signs in to your +If you have Node.js >= 22.12 on this machine, the CLI signs in to your Obsidian account and captures the token: ```bash From 3eca710f9ecd28e97f729d89a067176484de1b20 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:08:57 -0400 Subject: [PATCH 2/7] fix(cli): point the Node-floor refusal at both no-Node setups The message named only the local guide; a remote-mode user was misdirected to a bind-mount walkthrough. Co-Authored-By: Claude Fable 5 --- cli/src/bin.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/src/bin.ts b/cli/src/bin.ts index 5f7ae2bbb..0b1e81e37 100644 --- a/cli/src/bin.ts +++ b/cli/src/bin.ts @@ -20,8 +20,9 @@ const requiredNodeVersion = minimumNodeVersion(engines.node) if (!satisfiesMinimum(process.versions.node, requiredNodeVersion)) { console.error( `vault-cortex requires Node.js >= ${requiredNodeVersion} (you have ${process.versions.node}).\n` + - `Upgrade at https://nodejs.org — or use the no-Node manual setup:\n` + - `https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/README.md`, + `Upgrade at https://nodejs.org — or use a no-Node manual setup:\n` + + ` local: https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/README.md\n` + + ` remote: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md`, ) process.exit(1) } From 4c727ab278eaf00e562b4057ad7b6e2e58c464b1 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:24:31 -0400 Subject: [PATCH 3/7] style: use truthy check for regex .exec() result in node-version Co-Authored-By: Claude Fable 5 --- cli/src/node-version.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/src/node-version.ts b/cli/src/node-version.ts index c83527218..83a7f3b5d 100644 --- a/cli/src/node-version.ts +++ b/cli/src/node-version.ts @@ -8,8 +8,7 @@ const VERSION_IN_RANGE = /(\d+)\.(\d+)(?:\.(\d+))?/ */ export const minimumNodeVersion = (enginesRange: string): string => { const match = VERSION_IN_RANGE.exec(enginesRange) - if (match === null) - throw new Error(`Cannot parse engines range: ${enginesRange}`) + if (!match) throw new Error(`Cannot parse engines range: ${enginesRange}`) const [, major, minor, patch] = match return `${major}.${minor}.${patch ?? "0"}` } From f7d218e422da5a46910f8135ad7d80dcc89f0f93 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:28:26 -0400 Subject: [PATCH 4/7] test: assert exact error message in minimumNodeVersion throw test The toThrow assertion used a substring ('Cannot parse engines range') instead of the full deterministic message including the input value. Co-Authored-By: Claude Fable 5 --- cli/src/__tests__/node-version.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/__tests__/node-version.test.ts b/cli/src/__tests__/node-version.test.ts index b2fc71f22..88065126c 100644 --- a/cli/src/__tests__/node-version.test.ts +++ b/cli/src/__tests__/node-version.test.ts @@ -32,7 +32,7 @@ describe("minimumNodeVersion", () => { it("throws on a range with no version in it", () => { expect(() => minimumNodeVersion("latest")).toThrow( - "Cannot parse engines range", + "Cannot parse engines range: latest", ) }) }) From ac76e541b3cd7823fc3a7c3071c20bf42e2b2446 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:43:31 -0400 Subject: [PATCH 5/7] test(cli): pin the Node-floor refusal message with a unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the message into node-version.ts — the zero-import module the entry guard already loads before any dependency-laden import — so the version string and both no-Node setup links cannot silently drift. Co-Authored-By: Claude Fable 5 --- cli/src/__tests__/node-version.test.ts | 22 +++++++++++++++++++++- cli/src/bin.ts | 14 +++++++++----- cli/src/node-version.ts | 20 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/cli/src/__tests__/node-version.test.ts b/cli/src/__tests__/node-version.test.ts index 88065126c..1f51cf5ad 100644 --- a/cli/src/__tests__/node-version.test.ts +++ b/cli/src/__tests__/node-version.test.ts @@ -2,7 +2,11 @@ import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import { describe, expect, it } from "vitest" -import { minimumNodeVersion, satisfiesMinimum } from "../node-version.js" +import { + minimumNodeVersion, + nodeVersionRefusalMessage, + satisfiesMinimum, +} from "../node-version.js" describe("minimumNodeVersion", () => { it("extracts the floor from a >= range", () => { @@ -95,3 +99,19 @@ describe("satisfiesMinimum", () => { expect(satisfied).toBe(expected) }) }) + +describe("nodeVersionRefusalMessage", () => { + it("names the floor, the running version, and both no-Node setup guides", () => { + const message = nodeVersionRefusalMessage({ + minimum: "22.12.0", + current: "20.20.2", + }) + + expect(message).toBe( + "vault-cortex requires Node.js >= 22.12.0 (you have 20.20.2).\n" + + "Upgrade at https://nodejs.org — or use a no-Node manual setup:\n" + + " local: https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/README.md\n" + + " remote: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md", + ) + }) +}) diff --git a/cli/src/bin.ts b/cli/src/bin.ts index 0b1e81e37..4ca904445 100644 --- a/cli/src/bin.ts +++ b/cli/src/bin.ts @@ -2,7 +2,11 @@ import { readFileSync } from "node:fs" import { fileURLToPath } from "node:url" -import { minimumNodeVersion, satisfiesMinimum } from "./node-version.js" +import { + minimumNodeVersion, + nodeVersionRefusalMessage, + satisfiesMinimum, +} from "./node-version.js" const pkg: { version: string; engines: { node: string } } = JSON.parse( readFileSync( @@ -19,10 +23,10 @@ const { version, engines } = pkg const requiredNodeVersion = minimumNodeVersion(engines.node) if (!satisfiesMinimum(process.versions.node, requiredNodeVersion)) { console.error( - `vault-cortex requires Node.js >= ${requiredNodeVersion} (you have ${process.versions.node}).\n` + - `Upgrade at https://nodejs.org — or use a no-Node manual setup:\n` + - ` local: https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/README.md\n` + - ` remote: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md`, + nodeVersionRefusalMessage({ + minimum: requiredNodeVersion, + current: process.versions.node, + }), ) process.exit(1) } diff --git a/cli/src/node-version.ts b/cli/src/node-version.ts index 83a7f3b5d..81ba5e9b0 100644 --- a/cli/src/node-version.ts +++ b/cli/src/node-version.ts @@ -13,6 +13,26 @@ export const minimumNodeVersion = (enginesRange: string): string => { return `${major}.${minor}.${patch ?? "0"}` } +/** + * The refusal printed when the running Node is below the engines floor. + * Lives in this zero-import module so bin.ts can build it before any + * dependency-laden import runs on an unsupported runtime. + */ +export const nodeVersionRefusalMessage = ({ + minimum, + current, +}: { + minimum: string + current: string +}): string => { + return ( + `vault-cortex requires Node.js >= ${minimum} (you have ${current}).\n` + + `Upgrade at https://nodejs.org — or use a no-Node manual setup:\n` + + ` local: https://github.com/aliasunder/vault-cortex/blob/main/deploy/local/README.md\n` + + ` remote: https://github.com/aliasunder/vault-cortex/blob/main/deploy/remote/README.md` + ) +} + /** * Numeric major.minor.patch comparison: is current >= minimum? * The most significant differing segment decides; equal versions satisfy. From 23119b1e739dd48396f64bec0b301544d848511f Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:55:08 -0400 Subject: [PATCH 6/7] ci: pin the cli-smoke floor row to Node 22.12, keep check names stable The matrix's 22 row resolved to the newest 22.x, so the engines floor's API surface never ran in CI. The label keeps the required-check contexts at the major, so the ruleset survives future floor bumps. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 10 +++++++++- CONTRIBUTING.md | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 521d6dfed..ee4f456aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,11 +69,19 @@ jobs: # The cli package targets older runtimes than the server (engines >=22.12), # but the repo's @types/node tracks the server's Node version — so only a # runtime smoke test catches accidental use of too-new APIs in the CLI. + # The floor row pins 22.12 so the oldest supported API surface actually + # runs; label keeps the required-check name at the major, so the ruleset + # contexts survive future floor bumps. cli-smoke: + name: cli-smoke (${{ matrix.label }}) runs-on: ubuntu-latest strategy: matrix: - node-version: [22, 24] + include: + - node-version: "22.12" + label: "22" + - node-version: "24" + label: "24" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d703e08d4..4eb96a2e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,8 +144,8 @@ truth. Key points: each blocks the merge and the finding details are in its job log: - `checks` — prettier, lint, markdownlint, knip, test, and build - `cli-smoke (22)` / `cli-smoke (24)` — builds the - CLI and runs `init` on each supported Node major, catching APIs too - new for the CLI's `engines` range + CLI and runs `init` on the engines floor (22.12) and the newest + major (24), catching APIs too new for the CLI's `engines` range - `arch-smoke (amd64)` / `arch-smoke (arm64)` — builds the Docker image and boots it on a native runner for each architecture, then boots the remote image with a stubbed Sync client to run its init chain From 8c845049aed4f976ce5b01393bd32b71326dd78e Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:21:07 -0400 Subject: [PATCH 7/7] ci: assert the Node 20 refusal in the cli-smoke floor row The refusal branch never executes on supported majors, so no CI job ran it; the floor row now re-runs the built CLI on Node 20 and asserts nonzero exit, the floor in the message, and both setup pointers. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 19 +++++++++++++++++++ CONTRIBUTING.md | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee4f456aa..60922106f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,3 +106,22 @@ jobs: mkdir -p /tmp/smoke-vault/.obsidian node cli/dist/bin.js init --yes --vault-path /tmp/smoke-vault --dir /tmp/smoke-out grep -q "VAULT_PATH=/tmp/smoke-vault" /tmp/smoke-out/.env + + # The refusal branch never executes on supported majors, so the floor + # row re-runs the built CLI on EOL Node 20 and asserts the refusal + # shape: nonzero exit, the floor in the message, both setup pointers. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: matrix.label == '22' + with: + node-version: 20 + + - name: Refusal smoke on Node 20 + if: matrix.label == '22' + run: | + if node cli/dist/bin.js --help > refusal-output.txt 2>&1; then + echo "expected the CLI to refuse Node 20" + exit 1 + fi + grep -F "requires Node.js >= 22.12.0" refusal-output.txt + grep -F "deploy/local/README.md" refusal-output.txt + grep -F "deploy/remote/README.md" refusal-output.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4eb96a2e8..c14602d71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,7 +145,8 @@ truth. Key points: - `checks` — prettier, lint, markdownlint, knip, test, and build - `cli-smoke (22)` / `cli-smoke (24)` — builds the CLI and runs `init` on the engines floor (22.12) and the newest - major (24), catching APIs too new for the CLI's `engines` range + major (24), catching APIs too new for the CLI's `engines` range; + the floor row also asserts the too-old refusal on Node 20 - `arch-smoke (amd64)` / `arch-smoke (arm64)` — builds the Docker image and boots it on a native runner for each architecture, then boots the remote image with a stubbed Sync client to run its init chain