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 }); + }, + }); + }); +});