Skip to content

Commit b1bfecf

Browse files
committed
feat: Add fast-node-manager resource (auto-generated from issue #30)
1 parent 59a6acf commit b1bfecf

7 files changed

Lines changed: 292 additions & 1 deletion

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
title: fast-node-manager
3+
description: A reference page for the fast-node-manager resource
4+
---
5+
import { Step, Steps } from 'fumadocs-ui/components/steps';
6+
7+
The fast-node-manager resource installs [fnm](https://github.com/Schniz/fnm) — a fast, cross-platform Node.js version manager built in Rust. fnm lets you install and switch between multiple Node.js versions and respects `.nvmrc` and `.node-version` files.
8+
9+
## Parameters
10+
11+
- **nodeVersions**: *(array[string])* Node.js versions to install. Supports partial semver (`"20"`), exact versions (`"20.18.0"`), and aliases (`"lts"`, `"latest"`).
12+
13+
- **defaultVersion**: *(string)* The global default Node.js version set via `fnm default`.
14+
15+
## Example usage
16+
17+
```json title="codify.json"
18+
[
19+
{
20+
"type": "fast-node-manager",
21+
"nodeVersions": ["20", "18"],
22+
"defaultVersion": "20"
23+
}
24+
]
25+
```
26+
27+
### Setting up Node.js with fnm
28+
29+
<Steps>
30+
<Step>Create a `codify.json` file anywhere.</Step>
31+
<Step>Open `codify.json` and paste in the following config.</Step>
32+
</Steps>
33+
34+
```json title="codify.json"
35+
[
36+
{
37+
"type": "fast-node-manager",
38+
"nodeVersions": ["lts"],
39+
"defaultVersion": "lts"
40+
}
41+
]
42+
```
43+
44+
<Steps>
45+
<Step>Run `codify apply` in the directory of the file. Open a new terminal and run `node -v` — the installed LTS version should be returned. Node.js is now managed by fnm.</Step>
46+
</Steps>
47+
48+
```sh title="terminal"
49+
codify apply
50+
```
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"title": "javascript",
3-
"pages": ["npm", "npm-login", "nvm", "pnpm"]
3+
"pages": ["fast-node-manager", "npm", "npm-login", "nvm", "pnpm"]
44
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { HomebrewResource } from './resources/homebrew/homebrew.js';
2020
import { JenvResource } from './resources/java/jenv/jenv.js';
2121
import { Npm } from './resources/javascript/npm/npm.js';
2222
import { NpmLoginResource } from './resources/javascript/npm/npm-login.js';
23+
import { FnmResource } from './resources/javascript/fast-node-manager/fast-node-manager.js';
2324
import { NvmResource } from './resources/javascript/nvm/nvm.js';
2425
import { Pnpm } from './resources/javascript/pnpm/pnpm.js';
2526
import { MacportsResource } from './resources/macports/macports.js';
@@ -73,6 +74,7 @@ runPlugin(Plugin.create(
7374
new AwsProfileResource(),
7475
new TerraformResource(),
7576
new NvmResource(),
77+
new FnmResource(),
7678
new JenvResource(),
7779
new GoenvResource(),
7880
new PgcliResource(),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getPty, ParameterSetting, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core';
2+
3+
import { FnmConfig } from './fast-node-manager.js';
4+
5+
export class FnmDefaultVersionParameter extends StatefulParameter<FnmConfig, string> {
6+
getSettings(): ParameterSetting {
7+
return {
8+
type: 'version',
9+
};
10+
}
11+
12+
override async refresh(): Promise<string | null> {
13+
const $ = getPty();
14+
const { data, status } = await $.spawnSafe('fnm list', { interactive: true });
15+
16+
if (status === SpawnStatus.ERROR) {
17+
return null;
18+
}
19+
20+
for (const line of data.split('\n')) {
21+
if (line.includes('default')) {
22+
const match = line.match(/v?(\d+\.\d+\.\d+)/);
23+
if (match) return match[1];
24+
}
25+
}
26+
27+
return null;
28+
}
29+
30+
override async add(valueToAdd: string): Promise<void> {
31+
const $ = getPty();
32+
await $.spawn(`fnm default ${valueToAdd}`, { interactive: true });
33+
}
34+
35+
override async modify(newValue: string): Promise<void> {
36+
const $ = getPty();
37+
await $.spawn(`fnm default ${newValue}`, { interactive: true });
38+
}
39+
40+
override async remove(valueToRemove: string): Promise<void> {
41+
console.warn(`fnm does not support unsetting the default version. Node.js will remain at ${valueToRemove}. Skipping...`);
42+
}
43+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { ExampleConfig, FileUtils, getPty, Resource, ResourceSettings, SpawnStatus, Utils, z } from '@codifycli/plugin-core';
2+
import { OS } from '@codifycli/schemas';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
6+
import { FnmDefaultVersionParameter } from './default-version-parameter.js';
7+
import { FnmNodeVersionsParameter } from './node-versions-parameter.js';
8+
9+
const FNM_DIR = path.join(os.homedir(), '.fnm');
10+
const FNM_PATH_EXPORT = 'export PATH="$HOME/.fnm:$PATH"';
11+
const FNM_EVAL = 'eval "$(fnm env --use-on-cd)"';
12+
13+
const schema = z.object({
14+
nodeVersions: z
15+
.array(z.string())
16+
.describe('Node.js versions to install via fnm (e.g. ["20", "18.20.0", "lts"])')
17+
.optional(),
18+
defaultVersion: z
19+
.string()
20+
.describe('The default (global) Node.js version set by fnm.')
21+
.optional(),
22+
})
23+
.describe('fast-node-manager resource — install and manage multiple Node.js versions via fnm');
24+
25+
export type FnmConfig = z.infer<typeof schema>;
26+
27+
const defaultConfig: Partial<FnmConfig> = {
28+
nodeVersions: [],
29+
};
30+
31+
const exampleLts: ExampleConfig = {
32+
title: 'Install Node.js LTS via fnm',
33+
description: 'Install fnm and set the latest LTS release as the global Node.js version.',
34+
configs: [{
35+
type: 'fast-node-manager',
36+
nodeVersions: ['lts'],
37+
defaultVersion: 'lts',
38+
}],
39+
};
40+
41+
const exampleMultiVersion: ExampleConfig = {
42+
title: 'Install multiple Node.js versions via fnm',
43+
description: 'Install fnm with multiple Node.js versions side by side, using Node.js 22 as the global default.',
44+
configs: [{
45+
type: 'fast-node-manager',
46+
nodeVersions: ['18', '20', '22'],
47+
defaultVersion: '22',
48+
}],
49+
};
50+
51+
export class FnmResource extends Resource<FnmConfig> {
52+
getSettings(): ResourceSettings<FnmConfig> {
53+
return {
54+
id: 'fast-node-manager',
55+
operatingSystems: [OS.Darwin, OS.Linux],
56+
schema,
57+
defaultConfig,
58+
exampleConfigs: {
59+
example1: exampleLts,
60+
example2: exampleMultiVersion,
61+
},
62+
parameterSettings: {
63+
nodeVersions: { type: 'stateful', definition: new FnmNodeVersionsParameter(), order: 1 },
64+
defaultVersion: { type: 'stateful', definition: new FnmDefaultVersionParameter(), order: 2 },
65+
},
66+
};
67+
}
68+
69+
override async refresh(): Promise<Partial<FnmConfig> | null> {
70+
const $ = getPty();
71+
const { status } = await $.spawnSafe('fnm --version', { interactive: true });
72+
return status === SpawnStatus.SUCCESS ? {} : null;
73+
}
74+
75+
override async create(): Promise<void> {
76+
if (Utils.isMacOS()) {
77+
await installOnMacOS();
78+
} else {
79+
await installOnLinux();
80+
}
81+
}
82+
83+
override async destroy(): Promise<void> {
84+
if (Utils.isMacOS()) {
85+
await uninstallOnMacOS();
86+
} else {
87+
await uninstallOnLinux();
88+
}
89+
}
90+
}
91+
92+
async function installOnMacOS(): Promise<void> {
93+
await Utils.installViaPkgMgr('fnm');
94+
await FileUtils.addToShellRc(FNM_EVAL);
95+
}
96+
97+
async function installOnLinux(): Promise<void> {
98+
const $ = getPty();
99+
await $.spawn('curl -fsSL https://fnm.vercel.app/install | bash -s -- --skip-shell', { interactive: true });
100+
await FileUtils.addAllToShellRc([FNM_PATH_EXPORT, FNM_EVAL]);
101+
}
102+
103+
async function uninstallOnMacOS(): Promise<void> {
104+
await Utils.uninstallViaPkgMgr('fnm');
105+
await FileUtils.removeLineFromShellRc(FNM_EVAL);
106+
}
107+
108+
async function uninstallOnLinux(): Promise<void> {
109+
const $ = getPty();
110+
await $.spawnSafe(`rm -rf ${FNM_DIR}`);
111+
await FileUtils.removeLineFromShellRc(FNM_PATH_EXPORT);
112+
await FileUtils.removeLineFromShellRc(FNM_EVAL);
113+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { ArrayParameterSetting, ArrayStatefulParameter, getPty, SpawnStatus } from '@codifycli/plugin-core';
2+
3+
import { FnmConfig } from './fast-node-manager.js';
4+
5+
export class FnmNodeVersionsParameter extends ArrayStatefulParameter<FnmConfig, string> {
6+
getSettings(): ArrayParameterSetting {
7+
return {
8+
type: 'array',
9+
isElementEqual: (desired, current) => current.includes(desired),
10+
};
11+
}
12+
13+
override async refresh(_desired: string[] | null): Promise<string[] | null> {
14+
const $ = getPty();
15+
const { data, status } = await $.spawnSafe('fnm list', { interactive: true });
16+
17+
if (status === SpawnStatus.ERROR) {
18+
return null;
19+
}
20+
21+
return parseInstalledVersions(data);
22+
}
23+
24+
override async addItem(version: string): Promise<void> {
25+
const $ = getPty();
26+
await $.spawn(`fnm install ${version}`, { interactive: true });
27+
}
28+
29+
override async removeItem(version: string): Promise<void> {
30+
const $ = getPty();
31+
await $.spawn(`fnm uninstall ${version}`, { interactive: true });
32+
}
33+
}
34+
35+
function parseInstalledVersions(output: string): string[] {
36+
return output
37+
.split('\n')
38+
.map((line) => {
39+
const match = line.match(/v?(\d+\.\d+\.\d+)/);
40+
return match ? match[1] : null;
41+
})
42+
.filter((v): v is string => v !== null);
43+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { PluginTester, testSpawn } from '@codifycli/plugin-test';
3+
import path from 'node:path';
4+
import { SpawnStatus } from '@codifycli/plugin-core';
5+
6+
describe('fast-node-manager tests', () => {
7+
const pluginPath = path.resolve('./src/index.ts');
8+
9+
it('Can install fnm and node', { timeout: 500000, skip: true }, async () => {
10+
await PluginTester.fullTest(pluginPath, [
11+
{
12+
type: 'fast-node-manager',
13+
defaultVersion: '20',
14+
nodeVersions: ['20', '18'],
15+
},
16+
], {
17+
validateApply: async () => {
18+
expect(testSpawn('fnm --version', { interactive: true })).resolves.toMatchObject({ status: SpawnStatus.SUCCESS });
19+
expect(testSpawn('node --version', { interactive: true })).resolves.toMatchObject({ data: expect.stringContaining('20') });
20+
21+
const { data: installedVersions } = await testSpawn('fnm list', { interactive: true });
22+
expect(installedVersions).toContain('20');
23+
expect(installedVersions).toContain('18');
24+
},
25+
testModify: {
26+
modifiedConfigs: [{
27+
type: 'fast-node-manager',
28+
defaultVersion: '22',
29+
nodeVersions: ['22'],
30+
}],
31+
validateModify: async () => {
32+
expect(testSpawn('node --version', { interactive: true })).resolves.toMatchObject({ data: expect.stringContaining('22') });
33+
},
34+
},
35+
validateDestroy: async () => {
36+
expect(testSpawn('fnm --version', { interactive: true })).resolves.toMatchObject({ status: SpawnStatus.ERROR });
37+
},
38+
});
39+
});
40+
});

0 commit comments

Comments
 (0)