Skip to content

Commit 596bf47

Browse files
committed
feat: Add npm install resource
1 parent 040dd0c commit 596bf47

3 files changed

Lines changed: 174 additions & 0 deletions

File tree

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { WaitGithubSshKey } from './resources/git/wait-github-ssh-key/wait-githu
1919
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';
22+
import { NpmInstallResource } from './resources/javascript/npm/npm-install.js';
2223
import { NpmLoginResource } from './resources/javascript/npm/npm-login.js';
2324
import { NvmResource } from './resources/javascript/nvm/nvm.js';
2425
import { Pnpm } from './resources/javascript/pnpm/pnpm.js';
@@ -91,6 +92,7 @@ runPlugin(Plugin.create(
9192
new PipSync(),
9293
new MacportsResource(),
9394
new Npm(),
95+
new NpmInstallResource(),
9496
new NpmLoginResource(),
9597
new DockerResource(),
9698
new AptResource(),
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { ExampleConfig, Resource, ResourceSettings, getPty } from '@codifycli/plugin-core';
2+
import { OS, ResourceConfig } from '@codifycli/schemas';
3+
import * as fs from 'node:fs/promises';
4+
import * as path from 'node:path';
5+
import { z } from 'zod';
6+
7+
const schema = z.object({
8+
directories: z.array(z.string()).describe('List of directories to run npm install in'),
9+
});
10+
11+
export type NpmInstallConfig = z.infer<typeof schema> & ResourceConfig;
12+
13+
const defaultConfig: Partial<NpmInstallConfig> = {
14+
directories: [],
15+
};
16+
17+
const exampleSingleProject: ExampleConfig = {
18+
title: 'Run npm install in a project directory',
19+
description: 'Ensure npm dependencies are installed in a specific project directory.',
20+
configs: [{
21+
type: 'npm-install',
22+
directories: ['~/projects/my-app'],
23+
}],
24+
};
25+
26+
const exampleMultipleProjects: ExampleConfig = {
27+
title: 'Run npm install in multiple directories',
28+
description: 'Install npm dependencies across multiple projects in one step.',
29+
configs: [{
30+
type: 'npm-install',
31+
directories: ['~/projects/frontend', '~/projects/backend', '~/projects/shared'],
32+
}],
33+
};
34+
35+
export class NpmInstallResource extends Resource<NpmInstallConfig> {
36+
getSettings(): ResourceSettings<NpmInstallConfig> {
37+
return {
38+
id: 'npm-install',
39+
defaultConfig,
40+
exampleConfigs: {
41+
example1: exampleSingleProject,
42+
example2: exampleMultipleProjects,
43+
},
44+
operatingSystems: [OS.Darwin, OS.Linux],
45+
schema,
46+
parameterSettings: {
47+
directories: {
48+
type: 'array',
49+
itemType: 'directory',
50+
canModify: true,
51+
isElementEqual: (a, b) => path.resolve(a) === path.resolve(b),
52+
filterInStatelessMode: (desired, current) =>
53+
current.filter((c) => desired.some((d) => path.resolve(d) === path.resolve(c))),
54+
},
55+
},
56+
dependencies: ['npm', 'nvm', 'pnpm'],
57+
importAndDestroy: {
58+
preventDestroy: true,
59+
},
60+
};
61+
}
62+
63+
async refresh(parameters: Partial<NpmInstallConfig>): Promise<Partial<NpmInstallConfig> | null> {
64+
const pty = getPty();
65+
const { status } = await pty.spawnSafe('which npm');
66+
if (status === 'error') {
67+
return null;
68+
}
69+
70+
if (!parameters.directories || parameters.directories.length === 0) {
71+
return parameters;
72+
}
73+
74+
// Return only directories that have node_modules installed
75+
const installed: string[] = [];
76+
for (const dir of parameters.directories) {
77+
const resolved = dir.replace(/^~/, process.env.HOME ?? '~');
78+
try {
79+
await fs.access(path.join(resolved, 'node_modules'));
80+
installed.push(dir);
81+
} catch {
82+
// node_modules doesn't exist — not installed
83+
}
84+
}
85+
86+
return { directories: installed };
87+
}
88+
89+
async create(plan: { desiredConfig: NpmInstallConfig }): Promise<void> {
90+
await this.runInstall(plan.desiredConfig.directories ?? []);
91+
}
92+
93+
async modify(
94+
_pc: unknown,
95+
plan: { desiredConfig: NpmInstallConfig },
96+
): Promise<void> {
97+
await this.runInstall(plan.desiredConfig.directories ?? []);
98+
}
99+
100+
async destroy(): Promise<void> {
101+
// node_modules removal is intentionally left to the user; prevent destroy is set
102+
}
103+
104+
private async runInstall(directories: string[]): Promise<void> {
105+
const $ = getPty();
106+
for (const dir of directories) {
107+
const resolved = dir.replace(/^~/, process.env.HOME ?? '~');
108+
await $.spawn(`npm install`, { cwd: resolved, interactive: true });
109+
}
110+
}
111+
}

test/node/npm/npm-install.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { SpawnStatus } from '@codifycli/plugin-core';
2+
import { PluginTester, testSpawn } from '@codifycli/plugin-test';
3+
import * as fs from 'node:fs/promises';
4+
import * as os from 'node:os';
5+
import * as path from 'node:path';
6+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7+
8+
describe('NpmInstall tests', () => {
9+
const pluginPath = path.resolve('./src/index.ts');
10+
let projectDir: string;
11+
12+
beforeAll(async () => {
13+
// Ensure nvm + Node.js are available
14+
await PluginTester.install(pluginPath, [
15+
{
16+
type: 'nvm',
17+
global: '24',
18+
nodeVersions: ['24'],
19+
},
20+
]);
21+
22+
// Create a minimal npm project to run install against
23+
projectDir = path.join(os.tmpdir(), 'codify-npm-install-test');
24+
await fs.mkdir(projectDir, { recursive: true });
25+
await fs.writeFile(
26+
path.join(projectDir, 'package.json'),
27+
JSON.stringify({ name: 'test-project', version: '1.0.0', dependencies: { 'is-odd': '3.0.1' } }),
28+
);
29+
}, 500000);
30+
31+
afterAll(async () => {
32+
await fs.rm(projectDir, { recursive: true, force: true });
33+
});
34+
35+
it('Runs npm install in the specified directory', { timeout: 500000 }, async () => {
36+
await PluginTester.fullTest(
37+
pluginPath,
38+
[
39+
{
40+
type: 'npm-install',
41+
directories: [projectDir],
42+
},
43+
],
44+
{
45+
skipUninstall: true,
46+
validateApply: async () => {
47+
const nodeModulesExists = await fs
48+
.access(path.join(projectDir, 'node_modules'))
49+
.then(() => true)
50+
.catch(() => false);
51+
expect(nodeModulesExists).toBe(true);
52+
53+
const { status } = await testSpawn(
54+
`node -e "require('${path.join(projectDir, 'node_modules', 'is-odd')}')"`,
55+
);
56+
expect(status).toBe(SpawnStatus.SUCCESS);
57+
},
58+
},
59+
);
60+
});
61+
});

0 commit comments

Comments
 (0)