Skip to content

Commit a009fce

Browse files
committed
feat: npm resource
1 parent 62d2122 commit a009fce

File tree

6 files changed

+237
-3
lines changed

6 files changed

+237
-3
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"ajv": "^8.12.0",
2424
"ajv-formats": "^2.1.1",
2525
"semver": "^7.6.0",
26-
"codify-plugin-lib": "1.0.173",
26+
"codify-plugin-lib": "1.0.174",
2727
"codify-schemas": "1.0.63",
2828
"chalk": "^5.3.0",
2929
"debug": "^4.3.4",
@@ -50,7 +50,7 @@
5050
"@types/debug": "4.1.12",
5151
"@types/plist": "^3.0.5",
5252
"@types/lodash.isequal": "^4.5.8",
53-
"codify-plugin-test": "0.0.50",
53+
"codify-plugin-test": "0.0.51",
5454
"commander": "^12.1.0",
5555
"eslint": "^8.51.0",
5656
"eslint-config-oclif": "^5",

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { TerraformResource } from './resources/terraform/terraform.js';
3434
import { VscodeResource } from './resources/vscode/vscode.js';
3535
import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js';
3636
import { MacportsResource } from './resources/macports/macports.js';
37+
import { Npm } from './resources/node/npm/npm.js';
3738

3839
runPlugin(Plugin.create(
3940
'default',
@@ -71,6 +72,7 @@ runPlugin(Plugin.create(
7172
new VenvProject(),
7273
new Pip(),
7374
new PipSync(),
74-
new MacportsResource()
75+
new MacportsResource(),
76+
new Npm(),
7577
])
7678
)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { ParameterSetting, Plan, StatefulParameter, getPty } from 'codify-plugin-lib';
2+
3+
import { codifySpawn } from '../../../utils/codify-spawn.js';
4+
import { NpmConfig } from './npm.js';
5+
6+
export interface NpmPackage {
7+
name: string;
8+
version?: string;
9+
}
10+
11+
interface NpmLsResponse {
12+
version: string;
13+
name: string;
14+
dependencies?: Record<string, {
15+
version: string;
16+
resolved: string;
17+
overridden: boolean;
18+
}>;
19+
}
20+
21+
export class NpmGlobalInstallParameter extends StatefulParameter<NpmConfig, Array<NpmPackage | string>> {
22+
23+
getSettings(): ParameterSetting {
24+
return {
25+
type: 'array',
26+
isElementEqual: this.isEqual,
27+
filterInStatelessMode: (desired, current) =>
28+
current.filter((c) => desired.some((d) => this.isSamePackage(d, c))),
29+
}
30+
}
31+
32+
async refresh(desired: (NpmPackage | string)[] | null, config: Partial<NpmConfig>): Promise<(NpmPackage | string)[] | null> {
33+
const pty = getPty();
34+
35+
const { data } = await pty.spawnSafe('npm ls --json --global --depth=0 --loglevel=error')
36+
if (!data) {
37+
return null;
38+
}
39+
40+
const parsedData = JSON.parse(data) as NpmLsResponse;
41+
const dependencies = Object.entries(parsedData.dependencies ?? {})
42+
.map(([name, info]) => ({
43+
name,
44+
version: info.version,
45+
}))
46+
47+
return dependencies.map((c) => {
48+
if (desired?.some((d) => typeof d === 'string' && d === c.name)) {
49+
return c.name;
50+
}
51+
52+
if(desired?.some((d) => typeof d === 'object' && d.name === c.name && !d.version)) {
53+
return { name: c.name };
54+
}
55+
56+
return c;
57+
})
58+
}
59+
60+
async add(valueToAdd: Array<NpmPackage | string>, plan: Plan<NpmConfig>): Promise<void> {
61+
await this.install(valueToAdd);
62+
}
63+
64+
async modify(newValue: (NpmPackage | string)[], previousValue: (NpmPackage | string)[], plan: Plan<NpmConfig>): Promise<void> {
65+
const toInstall = newValue.filter((n) => !previousValue.some((p) => this.isSamePackage(n, p)));
66+
const toUninstall = previousValue.filter((p) => !newValue.some((n) => this.isSamePackage(n, p)));
67+
68+
await this.uninstall(toUninstall);
69+
await this.install(toInstall);
70+
}
71+
72+
async remove(valueToRemove: (NpmPackage | string)[], plan: Plan<NpmConfig>): Promise<void> {
73+
await this.uninstall(valueToRemove);
74+
}
75+
76+
async install(packages: Array<NpmPackage | string>): Promise<void> {
77+
const installStatements = packages.map((p) => {
78+
if (typeof p === 'string') {
79+
return p;
80+
}
81+
82+
if (p.version) {
83+
return `${p.name}@${p.version}`;
84+
}
85+
86+
return p.name;
87+
})
88+
89+
await codifySpawn(`npm install --global ${installStatements.join(' ')}`);
90+
}
91+
92+
async uninstall(packages: Array<NpmPackage | string>): Promise<void> {
93+
const uninstallStatements = packages.map((p) => {
94+
if (typeof p === 'string') {
95+
return p;
96+
}
97+
98+
return p.name;
99+
})
100+
101+
await codifySpawn(`npm uninstall --global ${uninstallStatements.join(' ')}`);
102+
}
103+
104+
105+
isSamePackage(desired: NpmPackage | string, current: NpmPackage | string): boolean {
106+
if (typeof desired === 'string' && typeof current === 'string') {
107+
return desired === current;
108+
}
109+
110+
if (typeof desired === 'object' && typeof current === 'object') {
111+
return desired.name === current.name;
112+
}
113+
114+
return false;
115+
}
116+
117+
isEqual(desired: NpmPackage | string, current: NpmPackage | string): boolean {
118+
if (typeof desired === 'string' && typeof current === 'string') {
119+
return desired === current;
120+
}
121+
122+
if (typeof desired === 'object' && typeof current === 'object') {
123+
return desired.version
124+
? desired.name === current.name && desired.version === current.version
125+
: desired.name === current.name;
126+
}
127+
128+
return false;
129+
}
130+
131+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "https://www.codifycli.com/npm.json",
4+
"title": "Npm resource",
5+
"description": "Install and manage packages using NPM.",
6+
"type": "object",
7+
"properties": {
8+
"globalInstall": {
9+
"type": "array",
10+
"description": "An array of",
11+
"items": {
12+
"oneOf": [
13+
{ "type": "string", "description": "Npm packages to install globally" },
14+
{
15+
"type": "object",
16+
"properties": {
17+
"name": { "type": "string", "description": "The name of the package to install" },
18+
"version": { "type": "string", "description": "The version of package to install" }
19+
},
20+
"required": ["name"]
21+
}
22+
]
23+
}
24+
}
25+
},
26+
"additionalProperties": false
27+
}

src/resources/node/npm/npm.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { CreatePlan, DestroyPlan, RefreshContext, Resource, ResourceSettings, getPty } from 'codify-plugin-lib';
2+
import { ResourceConfig } from 'codify-schemas';
3+
4+
import { NpmGlobalInstallParameter, NpmPackage } from './global-install.js';
5+
import schema from './npm-shema.json'
6+
7+
export interface NpmConfig extends ResourceConfig {
8+
globalInstall: Array<NpmPackage | string>
9+
}
10+
11+
export class Npm extends Resource<NpmConfig> {
12+
getSettings(): ResourceSettings<NpmConfig> {
13+
return {
14+
id: 'npm',
15+
schema,
16+
parameterSettings: {
17+
globalInstall: { type: 'stateful', definition: new NpmGlobalInstallParameter() },
18+
},
19+
importAndDestroy: {
20+
preventDestroy: true,
21+
}
22+
}
23+
}
24+
25+
async refresh(parameters: Partial<NpmConfig>, context: RefreshContext<NpmConfig>): Promise<Partial<NpmConfig> | Partial<NpmConfig>[] | null> {
26+
const pty = getPty();
27+
28+
const { status } = await pty.spawnSafe('which npm')
29+
if (status === 'error') {
30+
return null;
31+
}
32+
33+
return parameters;
34+
}
35+
36+
// Npm gets created with NodeJS
37+
async create(plan: CreatePlan<NpmConfig>): Promise<void> {}
38+
39+
// Npm is destroyed with NodeJS
40+
destroy(plan: DestroyPlan<NpmConfig>): Promise<void> {}
41+
42+
}

test/node/npm/npm.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { PluginTester } from 'codify-plugin-test';
3+
import path from 'node:path';
4+
import { execSync } from 'child_process';
5+
6+
// Example test suite
7+
describe('Npm tests', () => {
8+
const pluginPath = path.resolve('./src/index.ts');
9+
10+
it('Can install nvm and a global package with npm', { timeout: 500000 }, async () => {
11+
await PluginTester.fullTest(pluginPath, [
12+
{
13+
type: 'nvm',
14+
global: '20',
15+
nodeVersions: ['20']
16+
},
17+
{
18+
type: 'npm',
19+
globalInstall: ['pnpm'],
20+
}
21+
], {
22+
validateApply: () => {
23+
expect(() => execSync('source ~/.zshrc; which nvm', { shell: 'zsh' })).to.not.throw();
24+
expect(execSync('source ~/.zshrc; node --version', { shell: 'zsh' }).toString('utf-8').trim()).to.include('20');
25+
26+
const installedVersions = execSync('source ~/.zshrc; nvm list', { shell: 'zsh' }).toString('utf-8').trim();
27+
expect(installedVersions).to.include('20');
28+
expect(installedVersions).to.include('18');
29+
},
30+
});
31+
});
32+
});

0 commit comments

Comments
 (0)