diff --git a/docs/resources/(resources)/javascript/fast-node-manager.mdx b/docs/resources/(resources)/javascript/fast-node-manager.mdx
new file mode 100644
index 00000000..21b50c0a
--- /dev/null
+++ b/docs/resources/(resources)/javascript/fast-node-manager.mdx
@@ -0,0 +1,50 @@
+---
+title: fast-node-manager
+description: A reference page for the fast-node-manager resource
+---
+import { Step, Steps } from 'fumadocs-ui/components/steps';
+
+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.
+
+## Parameters
+
+- **nodeVersions**: *(array[string])* Node.js versions to install. Supports partial semver (`"20"`), exact versions (`"20.18.0"`), and aliases (`"lts"`, `"latest"`).
+
+- **defaultVersion**: *(string)* The global default Node.js version set via `fnm default`.
+
+## Example usage
+
+```json title="codify.json"
+[
+ {
+ "type": "fnm",
+ "nodeVersions": ["20", "18"],
+ "defaultVersion": "20"
+ }
+]
+```
+
+### Setting up Node.js with fnm
+
+
+ Create a `codify.json` file anywhere.
+ Open `codify.json` and paste in the following config.
+
+
+```json title="codify.json"
+[
+ {
+ "type": "fnm",
+ "nodeVersions": ["lts"],
+ "defaultVersion": "lts"
+ }
+]
+```
+
+
+ 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.
+
+
+```sh title="terminal"
+codify apply
+```
diff --git a/docs/resources/(resources)/javascript/meta.json b/docs/resources/(resources)/javascript/meta.json
index 7622cdb9..8e63b54f 100644
--- a/docs/resources/(resources)/javascript/meta.json
+++ b/docs/resources/(resources)/javascript/meta.json
@@ -1,4 +1,4 @@
{
"title": "javascript",
- "pages": ["npm", "npm-login", "nvm", "pnpm"]
+ "pages": ["fast-node-manager", "npm", "npm-login", "nvm", "pnpm"]
}
diff --git a/package.json b/package.json
index fd66d017..ef83727c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "default",
- "version": "1.1.0-beta.20",
+ "version": "1.1.0-beta.22",
"description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux",
"main": "dist/index.js",
"scripts": {
diff --git a/src/index.ts b/src/index.ts
index 5f68f6a3..076647c3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -20,6 +20,7 @@ import { HomebrewResource } from './resources/homebrew/homebrew.js';
import { JenvResource } from './resources/java/jenv/jenv.js';
import { Npm } from './resources/javascript/npm/npm.js';
import { NpmLoginResource } from './resources/javascript/npm/npm-login.js';
+import { FnmResource } from './resources/javascript/fast-node-manager/fast-node-manager.js';
import { NvmResource } from './resources/javascript/nvm/nvm.js';
import { Pnpm } from './resources/javascript/pnpm/pnpm.js';
import { MacportsResource } from './resources/macports/macports.js';
@@ -73,6 +74,7 @@ runPlugin(Plugin.create(
new AwsProfileResource(),
new TerraformResource(),
new NvmResource(),
+ new FnmResource(),
new JenvResource(),
new GoenvResource(),
new PgcliResource(),
diff --git a/src/resources/javascript/fast-node-manager/default-version-parameter.ts b/src/resources/javascript/fast-node-manager/default-version-parameter.ts
new file mode 100644
index 00000000..a1a6ee72
--- /dev/null
+++ b/src/resources/javascript/fast-node-manager/default-version-parameter.ts
@@ -0,0 +1,43 @@
+import { getPty, ParameterSetting, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core';
+
+import { FnmConfig } from './fast-node-manager.js';
+
+export class FnmDefaultVersionParameter extends StatefulParameter {
+ getSettings(): ParameterSetting {
+ return {
+ type: 'version',
+ };
+ }
+
+ override async refresh(): Promise {
+ const $ = getPty();
+ const { data, status } = await $.spawnSafe('fnm list', { interactive: true });
+
+ if (status === SpawnStatus.ERROR) {
+ return null;
+ }
+
+ for (const line of data.split('\n')) {
+ if (line.includes('default')) {
+ const match = line.match(/v?(\d+\.\d+\.\d+)/);
+ if (match) return match[1];
+ }
+ }
+
+ return null;
+ }
+
+ override async add(valueToAdd: string): Promise {
+ const $ = getPty();
+ await $.spawn(`fnm default ${valueToAdd}`, { interactive: true });
+ }
+
+ override async modify(newValue: string): Promise {
+ const $ = getPty();
+ await $.spawn(`fnm default ${newValue}`, { interactive: true });
+ }
+
+ override async remove(valueToRemove: string): Promise {
+ console.warn(`fnm does not support unsetting the default version. Node.js will remain at ${valueToRemove}. Skipping...`);
+ }
+}
diff --git a/src/resources/javascript/fast-node-manager/fast-node-manager.ts b/src/resources/javascript/fast-node-manager/fast-node-manager.ts
new file mode 100644
index 00000000..771d4907
--- /dev/null
+++ b/src/resources/javascript/fast-node-manager/fast-node-manager.ts
@@ -0,0 +1,119 @@
+import { ExampleConfig, FileUtils, getPty, Resource, ResourceSettings, SpawnStatus, Utils, z } from '@codifycli/plugin-core';
+import { OS } from '@codifycli/schemas';
+import os from 'node:os';
+import path from 'node:path';
+
+import { FnmDefaultVersionParameter } from './default-version-parameter.js';
+import { FnmNodeVersionsParameter } from './node-versions-parameter.js';
+
+const FNM_DIR = path.join(os.homedir(), '.fnm');
+const FNM_MULTISHELL_EXPORT = 'export FNM_MULTISHELL_PATH="${TMPDIR:-/tmp}/fnm_multishells"';
+
+const schema = z.object({
+ nodeVersions: z
+ .array(z.string())
+ .describe('Node.js versions to install via fnm (e.g. ["20", "18.20.0", "lts"])')
+ .optional(),
+ defaultVersion: z
+ .string()
+ .describe('The default (global) Node.js version set by fnm.')
+ .optional(),
+})
+ .describe('fast-node-manager resource — install and manage multiple Node.js versions via fnm');
+
+export type FnmConfig = z.infer;
+
+const defaultConfig: Partial = {
+ nodeVersions: [],
+};
+
+const exampleLts: ExampleConfig = {
+ title: 'Install Node.js LTS via fnm',
+ description: 'Install fnm and set the latest LTS release as the global Node.js version.',
+ configs: [{
+ type: 'fnm',
+ nodeVersions: ['lts'],
+ defaultVersion: 'lts',
+ }],
+};
+
+const exampleMultiVersion: ExampleConfig = {
+ title: 'Install multiple Node.js versions via fnm',
+ description: 'Install fnm with multiple Node.js versions side by side, using Node.js 22 as the global default.',
+ configs: [{
+ type: 'fnm',
+ nodeVersions: ['18', '20', '22'],
+ defaultVersion: '22',
+ }],
+};
+
+export class FnmResource extends Resource {
+ getSettings(): ResourceSettings {
+ return {
+ id: 'fnm',
+ operatingSystems: [OS.Darwin, OS.Linux],
+ schema,
+ defaultConfig,
+ exampleConfigs: {
+ example1: exampleLts,
+ example2: exampleMultiVersion,
+ },
+ parameterSettings: {
+ nodeVersions: { type: 'stateful', definition: new FnmNodeVersionsParameter(), order: 1 },
+ defaultVersion: { type: 'stateful', definition: new FnmDefaultVersionParameter(), order: 2 },
+ },
+ };
+ }
+
+ override async refresh(): Promise | null> {
+ const $ = getPty();
+ const { status } = await $.spawnSafe('fnm --version', { interactive: true });
+ return status === SpawnStatus.SUCCESS ? {} : null;
+ }
+
+ override async create(): Promise {
+ await install();
+ }
+
+ override async destroy(): Promise {
+ await uninstall();
+ }
+}
+
+async function install(): Promise {
+ if (Utils.isLinux()) {
+ await Utils.installViaPkgMgr('curl unzip');
+ }
+
+ const $ = getPty();
+ await $.spawn('curl -fsSL https://fnm.vercel.app/install | bash', { interactive: true });
+ await FileUtils.addToShellRc(FNM_MULTISHELL_EXPORT);
+}
+
+async function uninstall(): Promise {
+ const $ = getPty();
+
+ const { status: brewStatus } = await $.spawnSafe('brew list fnm', {
+ env: { HOMEBREW_NO_AUTO_UPDATE: '1' },
+ });
+
+ if (brewStatus === SpawnStatus.SUCCESS) {
+ await Utils.uninstallViaPkgMgr('fnm');
+ } else {
+ await $.spawnSafe(`rm -rf ${FNM_DIR}`);
+ }
+
+ // Remove the block the installer appends to the shell rc file
+ for (const line of [
+ '# fnm',
+ `FNM_PATH="${FNM_DIR}"`,
+ 'if [ -d "$FNM_PATH" ]; then',
+ ' export PATH="$FNM_PATH:$PATH"',
+ ' eval "$(fnm env --shell zsh)"',
+ ' eval "$(fnm env --shell bash)"',
+ 'fi',
+ FNM_MULTISHELL_EXPORT,
+ ]) {
+ await FileUtils.removeLineFromShellRc(line);
+ }
+}
diff --git a/src/resources/javascript/fast-node-manager/node-versions-parameter.ts b/src/resources/javascript/fast-node-manager/node-versions-parameter.ts
new file mode 100644
index 00000000..3779c6a8
--- /dev/null
+++ b/src/resources/javascript/fast-node-manager/node-versions-parameter.ts
@@ -0,0 +1,43 @@
+import { ArrayParameterSetting, ArrayStatefulParameter, getPty, SpawnStatus } from '@codifycli/plugin-core';
+
+import { FnmConfig } from './fast-node-manager.js';
+
+export class FnmNodeVersionsParameter extends ArrayStatefulParameter {
+ getSettings(): ArrayParameterSetting {
+ return {
+ type: 'array',
+ isElementEqual: (desired, current) => current.includes(desired),
+ };
+ }
+
+ override async refresh(_desired: string[] | null): Promise {
+ const $ = getPty();
+ const { data, status } = await $.spawnSafe('fnm list', { interactive: true });
+
+ if (status === SpawnStatus.ERROR) {
+ return null;
+ }
+
+ return parseInstalledVersions(data);
+ }
+
+ override async addItem(version: string): Promise {
+ const $ = getPty();
+ await $.spawn(`fnm install ${version}`, { interactive: true });
+ }
+
+ override async removeItem(version: string): Promise {
+ const $ = getPty();
+ await $.spawn(`fnm uninstall ${version}`, { interactive: true });
+ }
+}
+
+function parseInstalledVersions(output: string): string[] {
+ return output
+ .split('\n')
+ .map((line) => {
+ const match = line.match(/v?(\d+\.\d+\.\d+)/);
+ return match ? match[1] : null;
+ })
+ .filter((v): v is string => v !== null);
+}
diff --git a/test/node/fast-node-manager/fast-node-manager.test.ts b/test/node/fast-node-manager/fast-node-manager.test.ts
new file mode 100644
index 00000000..e581c19a
--- /dev/null
+++ b/test/node/fast-node-manager/fast-node-manager.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { PluginTester, testSpawn } from '@codifycli/plugin-test';
+import path from 'node:path';
+import { SpawnStatus } from '@codifycli/plugin-core';
+
+describe('fast-node-manager tests', () => {
+ const pluginPath = path.resolve('./src/index.ts');
+
+ it('Can install fnm and node', { timeout: 500000 }, async () => {
+ await PluginTester.fullTest(pluginPath, [
+ {
+ type: 'fnm',
+ defaultVersion: '20',
+ nodeVersions: ['20', '18'],
+ },
+ ], {
+ validateApply: async () => {
+ expect(testSpawn('fnm --version')).resolves.toMatchObject({ status: SpawnStatus.SUCCESS });
+ expect(testSpawn('fnm exec --using=20 node --version')).resolves.toMatchObject({ data: expect.stringContaining('20') });
+
+ const { data: installedVersions } = await testSpawn('fnm list');
+ expect(installedVersions).toContain('20');
+ expect(installedVersions).toContain('18');
+ },
+ testModify: {
+ modifiedConfigs: [{
+ type: 'fnm',
+ defaultVersion: '22',
+ nodeVersions: ['22'],
+ }],
+ validateModify: async () => {
+ expect(testSpawn('fnm exec --using=22 node --version')).resolves.toMatchObject({ data: expect.stringContaining('22') });
+ },
+ },
+ validateDestroy: async () => {
+ expect(testSpawn('fnm --version')).resolves.toMatchObject({ status: SpawnStatus.ERROR });
+ },
+ });
+ });
+});