Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,55 @@ jobs:

- name: Doctor
run: node dist/cli.js doctor

unsupported-node:
name: Unsupported Node error
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4
Comment on lines +56 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Disable persisted checkout credentials before running dependency scripts.

npm ci can execute lifecycle scripts; with checkout’s default credential persistence, a compromised dependency can read the GitHub token from local Git configuration. Set persist-credentials: false.

Proposed fix
       - name: Checkout
         uses: actions/checkout@v4
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Checkout
uses: actions/checkout@v4
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 56-57: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 56 - 57, Update the Checkout step
using actions/checkout@v4 to set persist-credentials to false before dependency
installation runs.

Source: Linters/SAST tools


- name: Setup supported Node
uses: actions/setup-node@v4
with:
node-version: 22.19
cache: npm

- name: Install dependencies
run: npm ci

- name: Build
run: npm run build

- name: Setup unsupported Node
uses: actions/setup-node@v4
with:
node-version: 18.19.1

- name: Verify preinstall rejection
shell: bash
run: |
set +e
output="$(node scripts/check-node-version.cjs 2>&1)"
status=$?
set -e

test "$status" -ne 0
grep -F "DevSpace requires Node.js >=22.19 <27." <<<"$output"
grep -F "Current Node.js: v18.19.1" <<<"$output"

- name: Verify CLI bootstrap error
shell: bash
run: |
set +e
output="$(node scripts/devspace.cjs --version 2>&1)"
status=$?
set -e

test "$status" -ne 0
grep -F "DevSpace requires Node.js >=22.19 <27." <<<"$output"
grep -F "Current Node.js: v18.19.1" <<<"$output"
if grep -F "Invalid regular expression flags" <<<"$output"; then
exit 1
fi
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"node": ">=22.19 <27"
},
"bin": {
"devspace": "dist/cli.js"
"devspace": "scripts/devspace.cjs"
},
"files": [
"dist",
Expand All @@ -26,8 +26,9 @@
"build": "npm run clean && npm run build:app && tsc -p tsconfig.build.json",
"build:app": "vite build",
"dev": "node scripts/dev-server.mjs",
"preinstall": "node scripts/check-node-version.cjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"start": "node scripts/devspace.cjs serve",
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
Expand Down
12 changes: 12 additions & 0 deletions scripts/check-node-version.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env node
"use strict";

const {
formatUnsupportedNodeMessage,
isSupportedNodeVersion,
} = require("./node-version.cjs");

if (!isSupportedNodeVersion(process.versions.node)) {
console.error(formatUnsupportedNodeMessage());
process.exit(1);
}
17 changes: 17 additions & 0 deletions scripts/devspace.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env node
"use strict";

const {
formatUnsupportedNodeMessage,
isSupportedNodeVersion,
} = require("./node-version.cjs");

if (!isSupportedNodeVersion(process.versions.node)) {
console.error(formatUnsupportedNodeMessage());
process.exitCode = 1;
} else {
import("../dist/cli.js").catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}
46 changes: 46 additions & 0 deletions scripts/node-version.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use strict";

const SUPPORTED_NODE_RANGE = ">=22.19 <27";

function parseNodeVersion(version) {
const match = /^(?:v)?(\d+)\.(\d+)\.(\d+)/.exec(version);
if (!match) return null;

return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}

function isSupportedNodeVersion(version) {
const parsed = parseNodeVersion(version);
if (!parsed) return false;

return (
(parsed.major === 22 && parsed.minor >= 19) ||
(parsed.major > 22 && parsed.major < 27)
);
}

function formatUnsupportedNodeMessage(version = process.versions.node) {
const displayVersion = version.startsWith("v") ? version : `v${version}`;

return [
`DevSpace requires Node.js ${SUPPORTED_NODE_RANGE}.`,
`Current Node.js: ${displayVersion}`,
"",
"Install or switch to Node.js 22 LTS, then retry:",
"",
" nvm install 22 && nvm use 22",
" mise use --global node@22",
"",
"Then rerun your DevSpace installation.",
].join("\n");
}

module.exports = {
SUPPORTED_NODE_RANGE,
formatUnsupportedNodeMessage,
isSupportedNodeVersion,
};
36 changes: 36 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,50 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfig } from "./config.js";
import { LocalAgentStore } from "./local-agent-store.js";

const require = createRequire(import.meta.url);
const {
SUPPORTED_NODE_RANGE,
formatUnsupportedNodeMessage,
isSupportedNodeVersion,
} = require("../scripts/node-version.cjs") as {
SUPPORTED_NODE_RANGE: string;
formatUnsupportedNodeMessage(version?: string): string;
isSupportedNodeVersion(version: string): boolean;
};
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
bin: { devspace: string };
engines: { node: string };
files: string[];
scripts: { preinstall: string };
version: string;
};
const packageLock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8")) as {
packages: { "": { bin: { devspace: string } } };
};

assert.equal(packageJson.bin.devspace, "scripts/devspace.cjs");
assert.equal(packageLock.packages[""].bin.devspace, packageJson.bin.devspace);
assert.equal(packageJson.engines.node, SUPPORTED_NODE_RANGE);
assert.equal(packageJson.scripts.preinstall, "node scripts/check-node-version.cjs");
assert.ok(packageJson.files.includes("scripts"));
assert.equal(isSupportedNodeVersion("18.19.1"), false);
assert.equal(isSupportedNodeVersion("20.20.0"), false);
assert.equal(isSupportedNodeVersion("22.18.0"), false);
assert.equal(isSupportedNodeVersion("22.19.0"), true);
assert.equal(isSupportedNodeVersion("v22.19.0"), true);
assert.equal(isSupportedNodeVersion("23.0.0"), true);
assert.equal(isSupportedNodeVersion("26.9.0"), true);
assert.equal(isSupportedNodeVersion("27.0.0"), false);
assert.equal(isSupportedNodeVersion("invalid"), false);
assert.match(formatUnsupportedNodeMessage("v18.19.1"), /requires Node\.js >=22\.19 <27/);
assert.match(formatUnsupportedNodeMessage("v18.19.1"), /Current Node\.js: v18\.19\.1/);
assert.match(formatUnsupportedNodeMessage("18.19.1"), /Current Node\.js: v18\.19\.1/);

for (const flag of ["-v", "--version"]) {
const output = execFileSync("node", ["--import", "tsx", "src/cli.ts", flag], {
Expand Down
4 changes: 3 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ import { shutdownHttpServer } from "./server-shutdown.js";

type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version";
const require = createRequire(import.meta.url);
const SUPPORTED_NODE_RANGE = ">=20.12 <27";
const { SUPPORTED_NODE_RANGE } = require("../scripts/node-version.cjs") as {
SUPPORTED_NODE_RANGE: string;
};

async function main(argv: string[]): Promise<void> {
assertSupportedNode();
Expand Down
Loading