Skip to content

Commit 4239c73

Browse files
committed
add npm handler
1 parent 5c58cd2 commit 4239c73

File tree

2 files changed

+301
-7
lines changed

2 files changed

+301
-7
lines changed

bin/handlers/npm-handler.ts

Lines changed: 295 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,299 @@
1+
import { promisify } from 'node:util';
2+
import child_process from 'node:child_process';
3+
4+
const exec = promisify(child_process.exec);
5+
const { execSync } = child_process;
6+
17
import type { PackageManagerCompletion } from '../package-manager-completion.js';
8+
import { Command, Option } from '../../src/t.js';
9+
import {
10+
packageJsonScriptCompletion,
11+
packageJsonDependencyCompletion,
12+
} from '../completions/completion-producers.js';
13+
import { getWorkspacePatterns } from '../utils/filesystem-utils.js';
14+
import { stripAnsiEscapes, type ParsedOption } from '../utils/text-utils.js';
15+
16+
interface LazyCommand extends Command {
17+
_lazyCommand?: string;
18+
_optionsLoaded?: boolean;
19+
optionsRaw?: Map<string, Option>;
20+
}
21+
22+
// regex patterns to avoid recompilation in loops
23+
const ALL_COMMANDS_RE = /^All commands:\s*$/i;
24+
const OPTIONS_SECTION_RE = /^Options:\s*$/i;
25+
const SECTION_END_RE = /^(aliases|run|more)/i;
26+
const COMMAND_VALIDATION_RE = /^[a-z][a-z0-9-]*$/;
27+
const NPM_OPTION_RE =
28+
/(?:\[)?(?:-([a-z])\|)?--([a-z][a-z0-9-]+)(?:\s+<[^>]+>)?(?:\])?/gi;
29+
const OPTION_VALUE_RE = /<[^>]+>/;
30+
const NON_INDENTED_LINE_RE = /^\s/;
31+
32+
// completion handlers for npm options that take values
33+
const npmOptionHandlers = {
34+
loglevel: function (complete: (value: string, description: string) => void) {
35+
// npm log levels from documentation
36+
[
37+
'silent',
38+
'error',
39+
'warn',
40+
'notice',
41+
'http',
42+
'info',
43+
'verbose',
44+
'silly',
45+
].forEach((level) => complete(level, `Log level: ${level}`));
46+
},
47+
48+
registry: function (complete: (value: string, description: string) => void) {
49+
complete('https://registry.npmjs.org/', 'Official npm registry');
50+
complete('https://registry.npmmirror.com/', 'npm China mirror');
51+
},
52+
53+
'install-strategy': function (
54+
complete: (value: string, description: string) => void
55+
) {
56+
// From npm help: hoisted|nested|shallow|linked
57+
complete('hoisted', 'Hoist all dependencies to top level');
58+
complete('nested', 'Create nested node_modules structure');
59+
complete('shallow', 'Shallow dependency installation');
60+
complete('linked', 'Use linked dependencies');
61+
},
62+
63+
workspace: function (complete: (value: string, description: string) => void) {
64+
// Get workspace patterns from package.json workspaces or pnpm-workspace.yaml
65+
const workspacePatterns = getWorkspacePatterns();
66+
workspacePatterns.forEach((pattern) => {
67+
complete(pattern, `Workspace pattern: ${pattern}`);
68+
});
69+
70+
// Common workspace patterns
71+
complete('packages/*', 'All packages in packages directory');
72+
complete('apps/*', 'All apps in apps directory');
73+
},
74+
75+
omit: function (complete: (value: string, description: string) => void) {
76+
// From npm help: dev|optional|peer
77+
complete('dev', 'Omit devDependencies');
78+
complete('optional', 'Omit optionalDependencies');
79+
complete('peer', 'Omit peerDependencies');
80+
},
81+
82+
include: function (complete: (value: string, description: string) => void) {
83+
// From npm help: prod|dev|optional|peer
84+
complete('prod', 'Include production dependencies');
85+
complete('dev', 'Include devDependencies');
86+
complete('optional', 'Include optionalDependencies');
87+
complete('peer', 'Include peerDependencies');
88+
},
89+
};
90+
91+
// parse npm help text to extract commands and their descriptions
92+
export function parseNpmHelp(helpText: string): Record<string, string> {
93+
const helpLines = stripAnsiEscapes(helpText).split(/\r?\n/);
94+
95+
// find "All commands:" section
96+
let startIndex = -1;
97+
for (let i = 0; i < helpLines.length; i++) {
98+
if (ALL_COMMANDS_RE.test(helpLines[i].trim())) {
99+
startIndex = i + 1;
100+
break;
101+
}
102+
}
103+
104+
if (startIndex === -1) return {};
105+
106+
const commands: Record<string, string> = {};
107+
let commandsText = '';
108+
109+
// collect all lines that are part of the commands section
110+
for (let i = startIndex; i < helpLines.length; i++) {
111+
const line = helpLines[i];
112+
113+
// stop if we hit a non-indented line that starts a new section
114+
if (!NON_INDENTED_LINE_RE.test(line) && line.trim() && !line.includes(','))
115+
break;
116+
117+
// add this line to our commands text
118+
if (NON_INDENTED_LINE_RE.test(line)) {
119+
commandsText += ' ' + line.trim();
120+
}
121+
}
122+
123+
// parse the comma-separated command list
124+
const commandList = commandsText
125+
.split(',')
126+
.map((cmd) => cmd.trim())
127+
.filter((cmd) => cmd && COMMAND_VALIDATION_RE.test(cmd));
128+
129+
// npm does not ptrovide descriptions in the main help.
130+
commandList.forEach((cmd) => {
131+
commands[cmd] = ' ';
132+
});
133+
134+
// this is the most common used aliase that isn't in the main list
135+
commands['run'] = ' ';
136+
137+
return commands;
138+
}
139+
140+
// Get npm commands from the main help output
141+
export async function getNpmCommandsFromMainHelp(): Promise<
142+
Record<string, string>
143+
> {
144+
try {
145+
const { stdout } = await exec('npm --help', {
146+
encoding: 'utf8',
147+
timeout: 500,
148+
maxBuffer: 4 * 1024 * 1024,
149+
});
150+
return parseNpmHelp(stdout);
151+
} catch (error: any) {
152+
// npm --help exits with status 1 but still provides output
153+
if (error.stdout) {
154+
return parseNpmHelp(error.stdout);
155+
}
156+
return {};
157+
}
158+
}
159+
160+
// Parse npm options from help text (npm has a different format than pnpm)
161+
export function parseNpmOptions(
162+
helpText: string,
163+
{ flagsOnly = true }: { flagsOnly?: boolean } = {}
164+
): ParsedOption[] {
165+
const helpLines = stripAnsiEscapes(helpText).split(/\r?\n/);
166+
const optionsOut: ParsedOption[] = [];
167+
168+
// Find the Options: section
169+
let optionsStartIndex = -1;
170+
for (let i = 0; i < helpLines.length; i++) {
171+
if (OPTIONS_SECTION_RE.test(helpLines[i].trim())) {
172+
optionsStartIndex = i + 1;
173+
break;
174+
}
175+
}
176+
177+
if (optionsStartIndex === -1) return [];
178+
179+
// Parse the compact npm option format: [-S|--save|--no-save] etc.
180+
for (let i = optionsStartIndex; i < helpLines.length; i++) {
181+
const line = helpLines[i];
182+
183+
// Stop at aliases or other sections
184+
if (SECTION_END_RE.test(line.trim())) break;
185+
186+
// Parse option patterns like [-S|--save] or [--loglevel <level>]
187+
const optionMatches = line.matchAll(NPM_OPTION_RE);
188+
189+
for (const match of optionMatches) {
190+
const short = match[1] || undefined;
191+
const long = match[2];
192+
193+
// Check if this option takes a value
194+
const takesValue = OPTION_VALUE_RE.test(match[0]);
195+
196+
if (flagsOnly && takesValue) continue;
197+
198+
optionsOut.push({
199+
short,
200+
long,
201+
desc: `npm ${long} option`,
202+
});
203+
}
204+
}
205+
206+
return optionsOut;
207+
}
208+
209+
// Load dynamic options synchronously when requested
210+
export function loadDynamicOptionsSync(
211+
cmd: LazyCommand,
212+
command: string
213+
): void {
214+
try {
215+
const stdout = execSync(`npm ${command} --help`, {
216+
encoding: 'utf8',
217+
timeout: 500,
218+
});
219+
220+
const allOptions = parseNpmOptions(stdout, { flagsOnly: false });
221+
222+
for (const { long, short, desc } of allOptions) {
223+
const alreadyDefined = cmd.optionsRaw?.get?.(long);
224+
if (!alreadyDefined) {
225+
const handler =
226+
npmOptionHandlers[long as keyof typeof npmOptionHandlers];
227+
if (handler) {
228+
cmd.option(long, desc, handler, short);
229+
} else {
230+
cmd.option(long, desc, short);
231+
}
232+
}
233+
}
234+
} catch (error: unknown) {
235+
// npm help commands may exit with status 1 but still provide output
236+
if (error instanceof Error && 'stdout' in error) {
237+
try {
238+
const allOptions = parseNpmOptions(error.stdout as string, {
239+
flagsOnly: false,
240+
});
241+
for (const { long, short, desc } of allOptions) {
242+
const alreadyDefined = cmd.optionsRaw?.get?.(long);
243+
if (!alreadyDefined) {
244+
const handler =
245+
npmOptionHandlers[long as keyof typeof npmOptionHandlers];
246+
if (handler) {
247+
cmd.option(long, desc, handler, short);
248+
} else {
249+
cmd.option(long, desc, short);
250+
}
251+
}
252+
}
253+
} catch {}
254+
}
255+
}
256+
}
257+
258+
// Setup lazy option loading for a command
259+
function setupLazyOptionLoading(cmd: LazyCommand, command: string): void {
260+
cmd._lazyCommand = command;
261+
cmd._optionsLoaded = false;
262+
263+
const optionsStore = cmd.options;
264+
cmd.optionsRaw = optionsStore;
265+
266+
Object.defineProperty(cmd, 'options', {
267+
get() {
268+
if (!this._optionsLoaded) {
269+
this._optionsLoaded = true;
270+
loadDynamicOptionsSync(this, this._lazyCommand);
271+
}
272+
return optionsStore;
273+
},
274+
configurable: true,
275+
});
276+
}
2277

3278
export async function setupNpmCompletions(
4279
completion: PackageManagerCompletion
5-
): Promise<void> {}
280+
): Promise<void> {
281+
try {
282+
const commandsWithDescriptions = await getNpmCommandsFromMainHelp();
283+
284+
for (const [command, description] of Object.entries(
285+
commandsWithDescriptions
286+
)) {
287+
const cmd = completion.command(command, description);
288+
289+
if (['remove', 'rm', 'uninstall', 'un'].includes(command)) {
290+
cmd.argument('package', packageJsonDependencyCompletion);
291+
}
292+
if (['run', 'run-script'].includes(command)) {
293+
cmd.argument('script', packageJsonScriptCompletion, true);
294+
}
295+
296+
setupLazyOptionLoading(cmd, command);
297+
}
298+
} catch (_err) {}
299+
}

bin/handlers/pnpm-handler.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ import {
2727
type ParsedOption,
2828
} from '../utils/text-utils.js';
2929

30-
// regex to detect options section in help text
30+
// regex patterns to avoid recompilation in loops
3131
const OPTIONS_SECTION_RE = /^\s*Options:/i;
32+
const LEVEL_MATCH_RE = /(?:levels?|options?|values?)[^:]*:\s*([^.]+)/i;
33+
const REPORTER_MATCH_RE = /--reporter\s+(\w+)/g;
3234

3335
function extractValidValuesFromHelp(
3436
helpText: string,
@@ -42,9 +44,7 @@ function extractValidValuesFromHelp(
4244
for (let j = i; j < Math.min(i + 3, lines.length); j++) {
4345
const searchLine = lines[j];
4446

45-
const levelMatch = searchLine.match(
46-
/(?:levels?|options?|values?)[^:]*:\s*([^.]+)/i
47-
);
47+
const levelMatch = searchLine.match(LEVEL_MATCH_RE);
4848
if (levelMatch) {
4949
return levelMatch[1]
5050
.split(/[,\s]+/)
@@ -53,11 +53,11 @@ function extractValidValuesFromHelp(
5353
}
5454

5555
if (optionName === 'reporter') {
56-
const reporterMatch = searchLine.match(/--reporter\s+(\w+)/);
56+
const reporterMatch = searchLine.match(REPORTER_MATCH_RE);
5757
if (reporterMatch) {
5858
const reporterValues = new Set<string>();
5959
for (const helpLine of lines) {
60-
const matches = helpLine.matchAll(/--reporter\s+(\w+)/g);
60+
const matches = helpLine.matchAll(REPORTER_MATCH_RE);
6161
for (const match of matches) {
6262
reporterValues.add(match[1]);
6363
}

0 commit comments

Comments
 (0)