Skip to content
Merged
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
50 changes: 50 additions & 0 deletions docs/resources/(resources)/javascript/fast-node-manager.mdx
Original file line number Diff line number Diff line change
@@ -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

<Steps>
<Step>Create a `codify.json` file anywhere.</Step>
<Step>Open `codify.json` and paste in the following config.</Step>
</Steps>

```json title="codify.json"
[
{
"type": "fnm",
"nodeVersions": ["lts"],
"defaultVersion": "lts"
}
]
```

<Steps>
<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>
</Steps>

```sh title="terminal"
codify apply
```
2 changes: 1 addition & 1 deletion docs/resources/(resources)/javascript/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "javascript",
"pages": ["npm", "npm-login", "nvm", "pnpm"]
"pages": ["fast-node-manager", "npm", "npm-login", "nvm", "pnpm"]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -73,6 +74,7 @@ runPlugin(Plugin.create(
new AwsProfileResource(),
new TerraformResource(),
new NvmResource(),
new FnmResource(),
new JenvResource(),
new GoenvResource(),
new PgcliResource(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FnmConfig, string> {
getSettings(): ParameterSetting {
return {
type: 'version',
};
}

override async refresh(): Promise<string | null> {
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<void> {
const $ = getPty();
await $.spawn(`fnm default ${valueToAdd}`, { interactive: true });
}

override async modify(newValue: string): Promise<void> {
const $ = getPty();
await $.spawn(`fnm default ${newValue}`, { interactive: true });
}

override async remove(valueToRemove: string): Promise<void> {
console.warn(`fnm does not support unsetting the default version. Node.js will remain at ${valueToRemove}. Skipping...`);
}
}
119 changes: 119 additions & 0 deletions src/resources/javascript/fast-node-manager/fast-node-manager.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schema>;

const defaultConfig: Partial<FnmConfig> = {
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<FnmConfig> {
getSettings(): ResourceSettings<FnmConfig> {
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<Partial<FnmConfig> | null> {
const $ = getPty();
const { status } = await $.spawnSafe('fnm --version', { interactive: true });
return status === SpawnStatus.SUCCESS ? {} : null;
}

override async create(): Promise<void> {
await install();
}

override async destroy(): Promise<void> {
await uninstall();
}
}

async function install(): Promise<void> {
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<void> {
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);
}
}
Original file line number Diff line number Diff line change
@@ -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<FnmConfig, string> {
getSettings(): ArrayParameterSetting {
return {
type: 'array',
isElementEqual: (desired, current) => current.includes(desired),
};
}

override async refresh(_desired: string[] | null): Promise<string[] | null> {
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<void> {
const $ = getPty();
await $.spawn(`fnm install ${version}`, { interactive: true });
}

override async removeItem(version: string): Promise<void> {
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);
}
40 changes: 40 additions & 0 deletions test/node/fast-node-manager/fast-node-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
},
});
});
});
Loading