Skip to content

Commit 7138258

Browse files
committed
feat: Add npm login resource
1 parent d9b9e2c commit 7138258

File tree

4 files changed

+449
-0
lines changed

4 files changed

+449
-0
lines changed

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { HomebrewResource } from './resources/homebrew/homebrew.js';
1919
import { JenvResource } from './resources/java/jenv/jenv.js';
2020
import { MacportsResource } from './resources/macports/macports.js';
2121
import { Npm } from './resources/javascript/npm/npm.js';
22+
import { NpmLoginResource } from './resources/javascript/npm/npm-login.js';
2223
import { NvmResource } from './resources/javascript/nvm/nvm.js';
2324
import { Pnpm } from './resources/javascript/pnpm/pnpm.js';
2425
import { PgcliResource } from './resources/pgcli/pgcli.js';
@@ -77,6 +78,7 @@ runPlugin(Plugin.create(
7778
new PipSync(),
7879
new MacportsResource(),
7980
new Npm(),
81+
new NpmLoginResource(),
8082
new DockerResource(),
8183
])
8284
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "https://www.codifycli.com/npm-login.json",
4+
"$comment": "https://docs.codifycli.com/core-resources/javascript/npm-login/",
5+
"title": "Npm login resource",
6+
"description": "Manage npm login/authentication in ~/.npmrc, including scoped registry mapping and tokens.",
7+
"type": "object",
8+
"properties": {
9+
"authToken": {
10+
"type": "string",
11+
"description": "The npm auth token used for authenticating with the registry. If not provided, then web login is assumed"
12+
},
13+
"scope": {
14+
"type": "string",
15+
"description": "Optional npm scope (e.g. @myorg) to bind to a specific registry.",
16+
"pattern": "^@.+\\.?"
17+
},
18+
"registry": {
19+
"type": "string",
20+
"description": "Registry URL to use for authentication and optional scope mapping."
21+
}
22+
},
23+
"required": ["authToken"],
24+
"additionalProperties": false
25+
}
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import {
2+
CreatePlan,
3+
DestroyPlan, getPty,
4+
ModifyPlan,
5+
ParameterChange,
6+
Resource,
7+
ResourceSettings,
8+
} from 'codify-plugin-lib';
9+
import { ResourceConfig } from 'codify-schemas';
10+
import * as fsSync from 'node:fs';
11+
import fs from 'node:fs/promises';
12+
import os from 'node:os';
13+
import path from 'node:path';
14+
15+
import { codifySpawn } from '../../../utils/codify-spawn.js';
16+
import schema from './npm-login-schema.json';
17+
18+
export interface NpmLoginConfig extends ResourceConfig {
19+
authToken: string;
20+
scope?: string; // Example: "@myorg"
21+
registry?: string; // Example: "https://registry.npmjs.org/"
22+
}
23+
24+
export class NpmLoginResource extends Resource<NpmLoginConfig> {
25+
getSettings(): ResourceSettings<NpmLoginConfig> {
26+
return {
27+
id: 'npm-login',
28+
schema,
29+
isSensitive: true,
30+
dependencies: ['npm'],
31+
parameterSettings: {
32+
authToken: { canModify: true, isSensitive: true },
33+
scope: { canModify: true },
34+
registry: { canModify: true, default: 'https://registry.npmjs.org/' }
35+
},
36+
importAndDestroy: {
37+
requiredParameters: [],
38+
defaultRefreshValues: {
39+
authToken: '',
40+
}
41+
},
42+
allowMultiple: {
43+
identifyingParameters: ['scope', 'registry'],
44+
findAllParameters: async () => {
45+
const npmrcPath = this.getNpmrcPath();
46+
if (!fsSync.existsSync(npmrcPath)) {
47+
return [];
48+
}
49+
50+
const content = await fs.readFile(npmrcPath, 'utf8');
51+
const lines = content.split(/\n/);
52+
53+
const results: Array<Partial<NpmLoginConfig>> = [];
54+
55+
for (const line of lines) {
56+
// @scope:registry=URL
57+
const scopeMatch = line.match(/^\s*(@[^:]+):registry\s*=\s*(\S+)\s*$/);
58+
if (scopeMatch) {
59+
const [, scope, registry] = scopeMatch;
60+
results.push({ scope, registry: this.normalizeRegistry(registry) });
61+
}
62+
}
63+
64+
// If no scoped entries exist, don't suggest anything by default
65+
return results;
66+
}
67+
}
68+
};
69+
}
70+
71+
override async refresh(parameters: Partial<NpmLoginConfig>): Promise<Partial<NpmLoginConfig> | null> {
72+
const $ = getPty();
73+
74+
// Ensure npm is available
75+
const { status } = await $.spawnSafe('which npm');
76+
if (status === 'error') {
77+
return null;
78+
}
79+
80+
const npmrcPath = this.getNpmrcPath();
81+
if (!fsSync.existsSync(npmrcPath)) {
82+
return null;
83+
}
84+
85+
const content = await fs.readFile(npmrcPath, 'utf8');
86+
87+
// Determine registry to check
88+
let registryToCheck = parameters.registry ?? 'https://registry.npmjs.org/';
89+
90+
if (parameters.scope) {
91+
const currentRegistry = this.getRegistryForScope(content, parameters.scope);
92+
if (!currentRegistry) {
93+
// If a scope was requested but mapping doesn't exist, resource doesn't exist
94+
return null;
95+
}
96+
97+
if (parameters.registry !== undefined) {
98+
registryToCheck = currentRegistry;
99+
}
100+
}
101+
102+
const result: Partial<NpmLoginConfig> = {};
103+
104+
if (parameters.scope !== undefined) {
105+
result.scope = parameters.scope;
106+
}
107+
108+
if (parameters.registry !== undefined) {
109+
result.registry = this.normalizeRegistry(
110+
parameters.scope ? this.getRegistryForScope(content, parameters.scope) ?? registryToCheck : registryToCheck
111+
);
112+
}
113+
114+
result.authToken = this.getAuthToken(content, registryToCheck) ?? undefined;
115+
116+
return result;
117+
}
118+
119+
override async create(plan: CreatePlan<NpmLoginConfig>): Promise<void> {
120+
await this.ensureNpmAvailable();
121+
122+
const { authToken, registry, scope } = plan.desiredConfig;
123+
const npmrcPath = this.getNpmrcPath();
124+
125+
await this.ensureFile(npmrcPath);
126+
let content = await fs.readFile(npmrcPath, 'utf8');
127+
128+
const normalizedRegistry = this.normalizeRegistry(registry ?? 'https://registry.npmjs.org/');
129+
130+
// Add/update scope mapping
131+
if (scope) {
132+
content = this.setScopeRegistry(content, scope, normalizedRegistry);
133+
}
134+
135+
// Set token for registry
136+
content = this.setAuthToken(content, normalizedRegistry, authToken);
137+
138+
await fs.writeFile(npmrcPath, content, 'utf8');
139+
}
140+
141+
override async modify(pc: ParameterChange<NpmLoginConfig>, plan: ModifyPlan<NpmLoginConfig>): Promise<void> {
142+
const npmrcPath = this.getNpmrcPath();
143+
await this.ensureFile(npmrcPath);
144+
145+
let content = await fs.readFile(npmrcPath, 'utf8');
146+
147+
const prevRegistry = this.normalizeRegistry(plan.currentConfig.registry ?? 'https://registry.npmjs.org/');
148+
const newRegistry = this.normalizeRegistry(plan.desiredConfig.registry ?? 'https://registry.npmjs.org/');
149+
150+
switch (pc.name) {
151+
case 'scope': {
152+
// Remove previous mapping if it existed, add new mapping if provided
153+
if (plan.currentConfig.scope) {
154+
content = this.removeScopeRegistry(content, plan.currentConfig.scope);
155+
}
156+
157+
if (plan.desiredConfig.scope) {
158+
content = this.setScopeRegistry(content, plan.desiredConfig.scope, newRegistry);
159+
}
160+
161+
break;
162+
}
163+
164+
case 'registry': {
165+
// Move token from prev registry to new registry
166+
const token = plan.desiredConfig.authToken ?? plan.currentConfig.authToken ?? '';
167+
content = this.removeAuthToken(content, prevRegistry);
168+
content = this.setAuthToken(content, newRegistry, token);
169+
170+
// Update scope mapping if scope exists
171+
if (plan.desiredConfig.scope) {
172+
content = this.setScopeRegistry(content, plan.desiredConfig.scope, newRegistry);
173+
}
174+
175+
break;
176+
}
177+
178+
case 'authToken': {
179+
// Update token on current registry
180+
content = this.setAuthToken(content, newRegistry, pc.newValue as string);
181+
break;
182+
}
183+
184+
default: {
185+
break;
186+
}
187+
}
188+
189+
await fs.writeFile(npmrcPath, content, 'utf8');
190+
}
191+
192+
override async destroy(plan: DestroyPlan<NpmLoginConfig>): Promise<void> {
193+
const npmrcPath = this.getNpmrcPath();
194+
if (!fsSync.existsSync(npmrcPath)) {
195+
return;
196+
}
197+
198+
let content = await fs.readFile(npmrcPath, 'utf8');
199+
200+
const registry = this.normalizeRegistry(plan.currentConfig.registry ?? 'https://registry.npmjs.org/');
201+
202+
// Remove scope mapping if provided
203+
if (plan.currentConfig.scope) {
204+
content = this.removeScopeRegistry(content, plan.currentConfig.scope);
205+
}
206+
207+
// Remove token for registry
208+
content = this.removeAuthToken(content, registry);
209+
210+
await fs.writeFile(npmrcPath, content, 'utf8');
211+
}
212+
213+
private getNpmrcPath(): string {
214+
return path.resolve(os.homedir(), '.npmrc');
215+
}
216+
217+
private async ensureNpmAvailable(): Promise<void> {
218+
await codifySpawn('which npm');
219+
}
220+
221+
private async ensureFile(filePath: string): Promise<void> {
222+
const dir = path.dirname(filePath);
223+
if (!fsSync.existsSync(dir)) {
224+
await fs.mkdir(dir, { recursive: true });
225+
}
226+
227+
if (!fsSync.existsSync(filePath)) {
228+
await fs.writeFile(filePath, '', 'utf8');
229+
}
230+
}
231+
232+
private normalizeRegistry(registry: string): string {
233+
try {
234+
const url = new URL(registry);
235+
// Ensure trailing slash
236+
const normalized = `${url.protocol}//${url.host}${url.pathname.endsWith('/') ? url.pathname : url.pathname + '/'}`;
237+
return normalized;
238+
} catch {
239+
// Fallback to default if invalid
240+
return 'https://registry.npmjs.org/';
241+
}
242+
}
243+
244+
private registryHost(registry: string): string {
245+
try {
246+
const { host } = new URL(this.normalizeRegistry(registry));
247+
return host;
248+
} catch {
249+
return 'registry.npmjs.org';
250+
}
251+
}
252+
253+
private getRegistryForScope(content: string, scope: string): string | undefined {
254+
const regex = new RegExp(`^\\s*${this.escapeRegExp(scope)}:registry\\s*=\\s*(\\S+)\\s*$`, 'm');
255+
const match = content.match(regex);
256+
return match ? this.normalizeRegistry(match[1]) : undefined;
257+
}
258+
259+
private setScopeRegistry(content: string, scope: string, registry: string): string {
260+
const line = `${scope}:registry=${registry}`;
261+
const regex = new RegExp(`^\\s*${this.escapeRegExp(scope)}:registry\\s*=.*$`, 'm');
262+
if (regex.test(content)) {
263+
return content.replace(regex, line);
264+
}
265+
266+
return this.appendLine(content, line);
267+
}
268+
269+
private removeScopeRegistry(content: string, scope: string): string {
270+
const regex = new RegExp(`^\\s*${this.escapeRegExp(scope)}:registry\\s*=.*$`, 'm');
271+
return content.replace(regex, '').replaceAll(/\n{2,}/g, '\n').trim() + '\n';
272+
}
273+
274+
private getAuthToken(content: string, registry: string): null | string {
275+
const host = this.registryHost(registry);
276+
const key = `//${host}/:_authToken`;
277+
278+
const regex = new RegExp(`^\\s*${this.escapeRegExp(key)}\\s*=(.*)$`, 'm');
279+
if (regex.test(content)) {
280+
console.log(content.match(regex)?.[1])
281+
282+
return content.match(regex)?.[1] ?? null
283+
}
284+
285+
return null
286+
}
287+
288+
private setAuthToken(content: string, registry: string, token: string): string {
289+
const host = this.registryHost(registry);
290+
const key = `//${host}/:_authToken`;
291+
const line = `${key}=${token}`;
292+
293+
const regex = new RegExp(`^\\s*${this.escapeRegExp(key)}\\s*=.*$`, 'm');
294+
if (regex.test(content)) {
295+
return content.replace(regex, line);
296+
}
297+
298+
return this.appendLine(content, line);
299+
}
300+
301+
private removeAuthToken(content: string, registry: string): string {
302+
const host = this.registryHost(registry);
303+
const key = `//${host}/:_authToken`;
304+
const regex = new RegExp(`^\\s*${this.escapeRegExp(key)}\\s*=.*$`, 'm');
305+
return content.replace(regex, '').replaceAll(/\n{2,}/g, '\n').trim() + '\n';
306+
}
307+
308+
private appendLine(content: string, line: string): string {
309+
if (!content.endsWith('\n') && content.length > 0) {
310+
return content + '\n' + line + '\n';
311+
}
312+
313+
return content + line + '\n';
314+
}
315+
316+
private escapeRegExp(s: string): string {
317+
return s.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&');
318+
}
319+
}

0 commit comments

Comments
 (0)