Skip to content

Commit b5d848f

Browse files
authored
feat: add node-version-file input for reading Node.js version from files (#5)
Support reading Node.js version from .nvmrc, .node-version, .tool-versions, and package.json (devEngines.runtime then engines.node), matching actions/setup-node behavior. Ignored when node-version is set. Also extracts resolveWorkspacePath utility to reduce duplication.
1 parent 976b1aa commit b5d848f

10 files changed

Lines changed: 509 additions & 79 deletions

File tree

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ steps:
3030
node-version: "22"
3131
```
3232
33+
### With Node.js Version File
34+
35+
```yaml
36+
steps:
37+
- uses: actions/checkout@v6
38+
- uses: voidzero-dev/setup-vp@v1
39+
with:
40+
node-version-file: ".node-version"
41+
```
42+
3343
### With Caching and Install
3444
3545
```yaml
@@ -89,13 +99,14 @@ jobs:
8999
90100
## Inputs
91101
92-
| Input | Description | Required | Default |
93-
| ----------------------- | ------------------------------------------------------------------------------ | -------- | ------------- |
94-
| `version` | Version of Vite+ to install | No | `latest` |
95-
| `node-version` | Node.js version to install via `vp env use` | No | Latest LTS |
96-
| `run-install` | Run `vp install` after setup. Accepts boolean or YAML object with `cwd`/`args` | No | `true` |
97-
| `cache` | Enable caching of project dependencies | No | `false` |
98-
| `cache-dependency-path` | Path to lock file for cache key generation | No | Auto-detected |
102+
| Input | Description | Required | Default |
103+
| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------- | ------------- |
104+
| `version` | Version of Vite+ to install | No | `latest` |
105+
| `node-version` | Node.js version to install via `vp env use` | No | Latest LTS |
106+
| `node-version-file` | Path to file containing Node.js version (`.nvmrc`, `.node-version`, `.tool-versions`, `package.json`) | No | |
107+
| `run-install` | Run `vp install` after setup. Accepts boolean or YAML object with `cwd`/`args` | No | `true` |
108+
| `cache` | Enable caching of project dependencies | No | `false` |
109+
| `cache-dependency-path` | Path to lock file for cache key generation | No | Auto-detected |
99110

100111
## Outputs
101112

action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ inputs:
1717
node-version:
1818
description: "Node.js version to install via `vp env use`. Defaults to Node.js latest LTS version."
1919
required: false
20+
node-version-file:
21+
description: "Path to file containing the Node.js version spec (.nvmrc, .node-version, .tool-versions, package.json). Ignored when node-version is specified."
22+
required: false
2023
cache:
2124
description: "Enable caching of project dependencies"
2225
required: false

dist/index.mjs

Lines changed: 69 additions & 67 deletions
Large diffs are not rendered by default.

src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { restoreCache } from "./cache-restore.js";
77
import { saveCache } from "./cache-save.js";
88
import { State, Outputs } from "./types.js";
99
import type { Inputs } from "./types.js";
10+
import { resolveNodeVersionFile } from "./node-version-file.js";
1011

1112
async function runMain(inputs: Inputs): Promise<void> {
1213
// Mark that post action should run
@@ -16,9 +17,14 @@ async function runMain(inputs: Inputs): Promise<void> {
1617
await installVitePlus(inputs);
1718

1819
// Step 2: Set up Node.js version if specified
19-
if (inputs.nodeVersion) {
20-
info(`Setting up Node.js ${inputs.nodeVersion} via vp env use...`);
21-
await exec("vp", ["env", "use", inputs.nodeVersion]);
20+
let nodeVersion = inputs.nodeVersion;
21+
if (!nodeVersion && inputs.nodeVersionFile) {
22+
nodeVersion = resolveNodeVersionFile(inputs.nodeVersionFile);
23+
}
24+
25+
if (nodeVersion) {
26+
info(`Setting up Node.js ${nodeVersion} via vp env use...`);
27+
await exec("vp", ["env", "use", nodeVersion]);
2228
}
2329

2430
// Step 3: Restore cache if enabled

src/inputs.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ describe("getInputs", () => {
2525

2626
expect(inputs).toEqual({
2727
version: "latest",
28+
nodeVersion: undefined,
29+
nodeVersionFile: undefined,
2830
runInstall: [],
2931
cache: false,
3032
cacheDependencyPath: undefined,
@@ -103,6 +105,18 @@ describe("getInputs", () => {
103105
expect(inputs.cache).toBe(true);
104106
});
105107

108+
it("should parse node-version-file input", () => {
109+
vi.mocked(getInput).mockImplementation((name) => {
110+
if (name === "node-version-file") return ".nvmrc";
111+
return "";
112+
});
113+
vi.mocked(getBooleanInput).mockReturnValue(false);
114+
115+
const inputs = getInputs();
116+
117+
expect(inputs.nodeVersionFile).toBe(".nvmrc");
118+
});
119+
106120
it("should parse cache-dependency-path input", () => {
107121
vi.mocked(getInput).mockImplementation((name) => {
108122
if (name === "cache-dependency-path") return "custom-lock.yaml";

src/inputs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export function getInputs(): Inputs {
88
return {
99
version: getInput("version") || "latest",
1010
nodeVersion: getInput("node-version") || undefined,
11+
nodeVersionFile: getInput("node-version-file") || undefined,
1112
runInstall: parseRunInstall(getInput("run-install")),
1213
cache: getBooleanInput("cache"),
1314
cacheDependencyPath: getInput("cache-dependency-path") || undefined,

src/node-version-file.test.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
2+
import { readFileSync } from "node:fs";
3+
import { resolveNodeVersionFile } from "./node-version-file.js";
4+
5+
vi.mock("@actions/core", () => ({
6+
info: vi.fn(),
7+
}));
8+
9+
vi.mock("node:fs", () => ({
10+
readFileSync: vi.fn(),
11+
}));
12+
13+
describe("resolveNodeVersionFile", () => {
14+
const originalEnv = process.env;
15+
16+
beforeEach(() => {
17+
vi.resetAllMocks();
18+
process.env = { ...originalEnv, GITHUB_WORKSPACE: "/workspace" };
19+
});
20+
21+
afterEach(() => {
22+
process.env = originalEnv;
23+
});
24+
25+
describe("path resolution", () => {
26+
it("should resolve relative path against GITHUB_WORKSPACE", () => {
27+
vi.mocked(readFileSync).mockReturnValue("20.0.0\n");
28+
29+
resolveNodeVersionFile(".nvmrc");
30+
31+
expect(readFileSync).toHaveBeenCalledWith("/workspace/.nvmrc", "utf-8");
32+
});
33+
34+
it("should use absolute path as-is", () => {
35+
vi.mocked(readFileSync).mockReturnValue("20.0.0\n");
36+
37+
resolveNodeVersionFile("/custom/path/.nvmrc");
38+
39+
expect(readFileSync).toHaveBeenCalledWith("/custom/path/.nvmrc", "utf-8");
40+
});
41+
42+
it("should throw if file does not exist", () => {
43+
vi.mocked(readFileSync).mockImplementation(() => {
44+
throw new Error("ENOENT");
45+
});
46+
47+
expect(() => resolveNodeVersionFile(".nvmrc")).toThrow(
48+
"node-version-file not found: /workspace/.nvmrc",
49+
);
50+
});
51+
});
52+
53+
describe(".nvmrc / .node-version", () => {
54+
it("should parse plain version", () => {
55+
vi.mocked(readFileSync).mockReturnValue("20.11.0\n");
56+
57+
expect(resolveNodeVersionFile(".nvmrc")).toBe("20.11.0");
58+
});
59+
60+
it("should strip v prefix", () => {
61+
vi.mocked(readFileSync).mockReturnValue("v22.1.0\n");
62+
63+
expect(resolveNodeVersionFile(".node-version")).toBe("22.1.0");
64+
});
65+
66+
it("should skip comments and empty lines", () => {
67+
vi.mocked(readFileSync).mockReturnValue("# use latest LTS\n\n18.19.0\n");
68+
69+
expect(resolveNodeVersionFile(".nvmrc")).toBe("18.19.0");
70+
});
71+
72+
it("should preserve lts/* alias", () => {
73+
vi.mocked(readFileSync).mockReturnValue("lts/*\n");
74+
75+
expect(resolveNodeVersionFile(".nvmrc")).toBe("lts/*");
76+
});
77+
78+
it("should normalize 'node' alias to latest", () => {
79+
vi.mocked(readFileSync).mockReturnValue("node\n");
80+
81+
expect(resolveNodeVersionFile(".nvmrc")).toBe("latest");
82+
});
83+
84+
it("should normalize 'stable' alias to latest", () => {
85+
vi.mocked(readFileSync).mockReturnValue("stable\n");
86+
87+
expect(resolveNodeVersionFile(".nvmrc")).toBe("latest");
88+
});
89+
90+
it("should strip inline comments", () => {
91+
vi.mocked(readFileSync).mockReturnValue("20.11.0 # LTS version\n");
92+
93+
expect(resolveNodeVersionFile(".nvmrc")).toBe("20.11.0");
94+
});
95+
96+
it("should throw on empty file", () => {
97+
vi.mocked(readFileSync).mockReturnValue("\n\n");
98+
99+
expect(() => resolveNodeVersionFile(".nvmrc")).toThrow("No Node.js version found in .nvmrc");
100+
});
101+
});
102+
103+
describe(".tool-versions", () => {
104+
it("should parse nodejs entry", () => {
105+
vi.mocked(readFileSync).mockReturnValue("python 3.11.0\nnodejs 20.11.0\nruby 3.2.0\n");
106+
107+
expect(resolveNodeVersionFile(".tool-versions")).toBe("20.11.0");
108+
});
109+
110+
it("should parse node entry", () => {
111+
vi.mocked(readFileSync).mockReturnValue("node 22.0.0\n");
112+
113+
expect(resolveNodeVersionFile(".tool-versions")).toBe("22.0.0");
114+
});
115+
116+
it("should strip v prefix from tool-versions", () => {
117+
vi.mocked(readFileSync).mockReturnValue("nodejs v20.11.0\n");
118+
119+
expect(resolveNodeVersionFile(".tool-versions")).toBe("20.11.0");
120+
});
121+
122+
it("should skip 'system' and use fallback version", () => {
123+
vi.mocked(readFileSync).mockReturnValue("nodejs system 20.11.0\n");
124+
125+
expect(resolveNodeVersionFile(".tool-versions")).toBe("20.11.0");
126+
});
127+
128+
it("should skip ref: and path: specs", () => {
129+
vi.mocked(readFileSync).mockReturnValue("nodejs ref:v1.0.2 path:/opt/node 22.0.0\n");
130+
131+
expect(resolveNodeVersionFile(".tool-versions")).toBe("22.0.0");
132+
});
133+
134+
it("should use first installable version from multiple fallbacks", () => {
135+
vi.mocked(readFileSync).mockReturnValue("nodejs 20.11.0 18.19.0\n");
136+
137+
expect(resolveNodeVersionFile(".tool-versions")).toBe("20.11.0");
138+
});
139+
140+
it("should throw when only non-installable specs present", () => {
141+
vi.mocked(readFileSync).mockReturnValue("nodejs system\n");
142+
143+
expect(() => resolveNodeVersionFile(".tool-versions")).toThrow(
144+
"No Node.js version found in .tool-versions",
145+
);
146+
});
147+
148+
it("should throw if no node entry found", () => {
149+
vi.mocked(readFileSync).mockReturnValue("python 3.11.0\nruby 3.2.0\n");
150+
151+
expect(() => resolveNodeVersionFile(".tool-versions")).toThrow(
152+
"No Node.js version found in .tool-versions",
153+
);
154+
});
155+
156+
it("should skip comments in .tool-versions", () => {
157+
vi.mocked(readFileSync).mockReturnValue("# tools\n\nnodejs 20.0.0\n");
158+
159+
expect(resolveNodeVersionFile(".tool-versions")).toBe("20.0.0");
160+
});
161+
});
162+
163+
describe("package.json", () => {
164+
it("should read devEngines.runtime with name node", () => {
165+
vi.mocked(readFileSync).mockReturnValue(
166+
JSON.stringify({
167+
devEngines: { runtime: { name: "node", version: "^20.0.0" } },
168+
}),
169+
);
170+
171+
expect(resolveNodeVersionFile("package.json")).toBe("^20.0.0");
172+
});
173+
174+
it("should read devEngines.runtime from array", () => {
175+
vi.mocked(readFileSync).mockReturnValue(
176+
JSON.stringify({
177+
devEngines: {
178+
runtime: [
179+
{ name: "bun", version: "^1.0.0" },
180+
{ name: "node", version: "^22.0.0" },
181+
],
182+
},
183+
}),
184+
);
185+
186+
expect(resolveNodeVersionFile("package.json")).toBe("^22.0.0");
187+
});
188+
189+
it("should read engines.node", () => {
190+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ engines: { node: ">=18" } }));
191+
192+
expect(resolveNodeVersionFile("package.json")).toBe(">=18");
193+
});
194+
195+
it("should prefer devEngines.runtime over engines.node", () => {
196+
vi.mocked(readFileSync).mockReturnValue(
197+
JSON.stringify({
198+
devEngines: { runtime: { name: "node", version: "22.0.0" } },
199+
engines: { node: ">=18" },
200+
}),
201+
);
202+
203+
expect(resolveNodeVersionFile("package.json")).toBe("22.0.0");
204+
});
205+
206+
it("should fall back to engines.node when devEngines has no node runtime", () => {
207+
vi.mocked(readFileSync).mockReturnValue(
208+
JSON.stringify({
209+
devEngines: { runtime: { name: "bun", version: "^1.0.0" } },
210+
engines: { node: ">=20" },
211+
}),
212+
);
213+
214+
expect(resolveNodeVersionFile("package.json")).toBe(">=20");
215+
});
216+
217+
it("should throw if no node version found", () => {
218+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: "test" }));
219+
220+
expect(() => resolveNodeVersionFile("package.json")).toThrow(
221+
"No Node.js version found in package.json",
222+
);
223+
});
224+
225+
it("should strip v prefix from devEngines.runtime version", () => {
226+
vi.mocked(readFileSync).mockReturnValue(
227+
JSON.stringify({
228+
devEngines: { runtime: { name: "node", version: "v20.11.0" } },
229+
}),
230+
);
231+
232+
expect(resolveNodeVersionFile("package.json")).toBe("20.11.0");
233+
});
234+
235+
it("should throw on invalid JSON", () => {
236+
vi.mocked(readFileSync).mockReturnValue("not json{");
237+
238+
expect(() => resolveNodeVersionFile("package.json")).toThrow(
239+
"Failed to parse package.json: invalid JSON",
240+
);
241+
});
242+
});
243+
});

0 commit comments

Comments
 (0)