Skip to content

Commit 72c6445

Browse files
committed
add global command handling
1 parent 2c67d40 commit 72c6445

9 files changed

Lines changed: 248 additions & 79 deletions

File tree

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ xpm # Install dependencies
3535
xpm add react # Add a package
3636
xpm dev # Run a script
3737

38+
xpm add -g turbo # Installs global dependency
39+
3840
xpx prettier # Download and run from the registry
3941
```
4042

@@ -43,16 +45,28 @@ xpx prettier # Download and run from the registry
4345
Set the default package manager for new projects:
4446

4547
```bash
46-
xpm set-config default-package-manager pnpm
48+
xpm set-config default-package-manager <npm|yarn|pnpm|bun>
4749
```
4850

4951
The default is used when no lockfile or package.json `packageManager` field is found.
5052

5153
You can also set it via environment variable:
5254
```bash
53-
export XPM_DEFAULT_PM=bun
55+
export XPM_DEFAULT_PM=<npm|yarn|pnpm|bun>
56+
```
57+
58+
Set the package manager for -g commands:
59+
60+
```bash
61+
xpm set-config global-package-manager <npm|yarn|pnpm|bun>
62+
```
63+
64+
You can also set it via environment variable:
65+
```bash
66+
export XPM_GLOBAL_PM=<npm|yarn|pnpm|bun>
5467
```
5568

69+
5670
## License
5771

5872
MIT

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@assistant-ui/xpm",
3-
"version": "0.0.3",
3+
"version": "0.0.4",
44
"description": "Universal package manager wrapper that automatically detects and uses the right package manager",
55
"main": "dist/index.js",
66
"bin": {

src/command-constants.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// All install command aliases (from npm documentation)
2+
export const INSTALL_COMMANDS = [
3+
'install',
4+
'add',
5+
'i',
6+
'in',
7+
'ins',
8+
'inst',
9+
'insta',
10+
'instal',
11+
'isnt',
12+
'isnta',
13+
'isntal',
14+
'isntall'
15+
] as const;
16+
17+
// All uninstall/remove command aliases (from npm documentation)
18+
export const UNINSTALL_COMMANDS = [
19+
'uninstall',
20+
'unlink',
21+
'remove',
22+
'rm',
23+
'r',
24+
'un'
25+
] as const;
26+
27+
// All update command aliases (from npm documentation)
28+
export const UPDATE_COMMANDS = [
29+
'update',
30+
'up',
31+
'upgrade',
32+
'udpate'
33+
] as const;
34+
35+
// Combined list for commands that should skip auto-sync
36+
export const SKIP_SYNC_COMMANDS = [
37+
...INSTALL_COMMANDS,
38+
...UNINSTALL_COMMANDS,
39+
...UPDATE_COMMANDS
40+
] as const;
41+
42+
// Commands that support global flag (from npm documentation)
43+
export const GLOBAL_SUPPORT_COMMANDS = [
44+
...INSTALL_COMMANDS,
45+
...UNINSTALL_COMMANDS,
46+
...UPDATE_COMMANDS,
47+
'list',
48+
'ls',
49+
'll',
50+
'outdated',
51+
'bin',
52+
'root',
53+
'rebuild'
54+
] as const;
55+
56+
// Commands that should run at workspace root when no args provided
57+
export const WORKSPACE_ROOT_COMMANDS = new Set([
58+
...INSTALL_COMMANDS, // All install aliases
59+
...UPDATE_COMMANDS, // All update aliases
60+
'audit',
61+
'outdated'
62+
]);
63+
64+
// Known npm commands that shouldn't be treated as scripts
65+
export const NPM_BUILTIN_COMMANDS = new Set([
66+
'access', 'adduser', 'audit', 'bin', 'bugs', 'cache', 'ci', 'completion',
67+
'config', 'dedupe', 'deprecate', 'diff', 'dist-tag', 'docs', 'doctor',
68+
'edit', 'exec', 'explain', 'explore', 'fund', 'help', 'hook', 'init',
69+
'install-ci-test', 'install-test', 'link', 'll', 'login',
70+
'logout', 'ls', 'org', 'outdated', 'owner', 'pack', 'ping', 'pkg',
71+
'prefix', 'profile', 'prune', 'publish', 'query', 'rebuild', 'repo',
72+
'restart', 'root', 'run', 'run-script', 'search', 'set', 'shrinkwrap',
73+
'star', 'stars', 'start', 'stop', 'team', 'test', 'token',
74+
'unpublish', 'unstar', 'version', 'view', 'whoami',
75+
// Add all our command aliases
76+
...INSTALL_COMMANDS,
77+
...UNINSTALL_COMMANDS,
78+
...UPDATE_COMMANDS
79+
]);
80+
81+
export type InstallCommand = typeof INSTALL_COMMANDS[number];
82+
export type UninstallCommand = typeof UNINSTALL_COMMANDS[number];
83+
export type GlobalSupportCommand = typeof GLOBAL_SUPPORT_COMMANDS[number];

src/command-mapper.ts

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,49 @@
11
import { PackageManager } from './package-manager-config';
22
import { hasScript } from './package-json';
3+
import { INSTALL_COMMANDS, UNINSTALL_COMMANDS, UPDATE_COMMANDS, NPM_BUILTIN_COMMANDS } from './command-constants';
4+
5+
// Helper to create mappings for all aliases
6+
function createMappings(aliases: readonly string[], mappings: Partial<Record<PackageManager, [string, ...string[]]>>) {
7+
const result: Record<string, Partial<Record<PackageManager, [string, ...string[]]>>> = {};
8+
for (const alias of aliases) {
9+
result[alias] = mappings;
10+
}
11+
return result;
12+
}
13+
14+
// Command mappings - what differs between package managers
15+
const installMappings = createMappings(
16+
INSTALL_COMMANDS,
17+
{ yarn: ['add'], pnpm: ['add'], bun: ['add'] }
18+
);
19+
20+
const uninstallMappings = createMappings(
21+
UNINSTALL_COMMANDS,
22+
{ yarn: ['remove'], pnpm: ['remove'], bun: ['remove'] }
23+
);
24+
25+
const updateMappings = createMappings(
26+
UPDATE_COMMANDS,
27+
{ yarn: ['update'], bun: ['update'] }
28+
);
329

4-
// Simplified command mappings - only what differs between package managers
530
const commandMappings: Record<string, Partial<Record<PackageManager, [string, ...string[]]>>> = {
6-
// Install/add commands
7-
'install': { yarn: ['add'], pnpm: ['add'], bun: ['add'] },
8-
'i': { yarn: ['add'], pnpm: ['add'], bun: ['add'] },
9-
10-
// Remove commands
11-
'uninstall': { yarn: ['remove'], pnpm: ['remove'], bun: ['remove'] },
12-
'rm': { yarn: ['remove'], pnpm: ['remove'], bun: ['remove'] },
13-
14-
// Update commands
15-
'upgrade': { yarn: ['update'], bun: ['update'] },
16-
'up': { yarn: ['update'], bun: ['update'] },
17-
31+
...installMappings,
32+
...uninstallMappings,
33+
...updateMappings,
1834
// Execute commands
1935
'exec': { bun: ["run"] },
2036
};
2137

22-
// Known npm commands that shouldn't be treated as scripts
23-
const npmBuiltinCommands = new Set([
24-
'access', 'adduser', 'audit', 'bin', 'bugs', 'cache', 'ci', 'completion',
25-
'config', 'dedupe', 'deprecate', 'diff', 'dist-tag', 'docs', 'doctor',
26-
'edit', 'exec', 'explain', 'explore', 'fund', 'help', 'hook', 'init',
27-
'install', 'install-ci-test', 'install-test', 'link', 'll', 'login',
28-
'logout', 'ls', 'org', 'outdated', 'owner', 'pack', 'ping', 'pkg',
29-
'prefix', 'profile', 'prune', 'publish', 'query', 'rebuild', 'repo',
30-
'restart', 'root', 'run', 'run-script', 'search', 'set', 'shrinkwrap',
31-
'star', 'stars', 'start', 'stop', 'team', 'test', 'token', 'uninstall',
32-
'unpublish', 'unstar', 'update', 'version', 'view', 'whoami', 'i'
33-
]);
3438

3539
export function mapCommand(command: string, args: string[], packageManager: PackageManager, projectRoot?: string): { command: string; args: string[] } {
3640
// Special case: install without args always uses 'install'
37-
if ((command === 'install' || command === 'i') && args.length === 0) {
41+
if (INSTALL_COMMANDS.includes(command as any) && args.length === 0) {
3842
return { command: 'install', args: [] };
3943
}
4044

4145
// Only map dev flags for install/add commands
42-
const isInstallCommand = ['install', 'i', 'add'].includes(command);
46+
const isInstallCommand = INSTALL_COMMANDS.includes(command as any);
4347
const mappedArgs = isInstallCommand ? args.map(arg => {
4448
// Map all dev flags to package manager specific format
4549
if (arg === '-D' || arg === '--save-dev' || arg === '--dev') {
@@ -57,7 +61,7 @@ export function mapCommand(command: string, args: string[], packageManager: Pack
5761
}
5862

5963
// npm needs 'run' prefix for scripts not in built-in commands
60-
if (packageManager === 'npm' && !npmBuiltinCommands.has(command) && hasScript(command, projectRoot)) {
64+
if (packageManager === 'npm' && !NPM_BUILTIN_COMMANDS.has(command) && projectRoot && hasScript(command, projectRoot)) {
6165
return { command: 'run', args: [command, ...args] };
6266
}
6367

src/config.ts

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,49 +5,65 @@ import { PackageManager } from './package-manager-config';
55

66
const CONFIG_PATH = join(homedir(), '.xpmrc');
77

8-
export function getDefaultPackageManager(): PackageManager {
8+
function getConfig(): any {
9+
if (existsSync(CONFIG_PATH)) {
10+
try {
11+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
12+
} catch {}
13+
}
14+
return {};
15+
}
16+
17+
function saveConfig(config: any): void {
18+
const dir = dirname(CONFIG_PATH);
19+
if (!existsSync(dir)) {
20+
mkdirSync(dir, { recursive: true });
21+
}
22+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
23+
}
24+
25+
function getPackageManager(envVar: string, configKey: string): PackageManager {
926
// 1. Check environment variable
10-
const envPM = process.env.XPM_DEFAULT_PM;
27+
const envPM = process.env[envVar];
1128
if (envPM && isValidPackageManager(envPM)) {
1229
return envPM as PackageManager;
1330
}
1431

15-
// 2. Check config file in home directory
16-
if (existsSync(CONFIG_PATH)) {
17-
try {
18-
const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
19-
if (config.defaultPackageManager && isValidPackageManager(config.defaultPackageManager)) {
20-
return config.defaultPackageManager;
21-
}
22-
} catch {}
32+
// 2. Check config file
33+
const config = getConfig();
34+
if (config[configKey] && isValidPackageManager(config[configKey])) {
35+
return config[configKey];
2336
}
2437

2538
// 3. Fall back to npm
2639
return 'npm';
2740
}
2841

29-
export function setDefaultPackageManager(pm: string): void {
42+
export function getDefaultPackageManager(): PackageManager {
43+
return getPackageManager('XPM_DEFAULT_PM', 'defaultPackageManager');
44+
}
45+
46+
export function getGlobalPackageManager(): PackageManager {
47+
return getPackageManager('XPM_GLOBAL_PM', 'globalPackageManager');
48+
}
49+
50+
function setPackageManager(pm: string, configKey: string, displayName: string): void {
3051
if (!isValidPackageManager(pm)) {
3152
throw new Error(`Invalid package manager: ${pm}. Must be one of: npm, yarn, pnpm, bun`);
3253
}
3354

34-
let config: any = {};
35-
if (existsSync(CONFIG_PATH)) {
36-
try {
37-
config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
38-
} catch {}
39-
}
40-
41-
config.defaultPackageManager = pm;
42-
43-
// Ensure directory exists
44-
const dir = dirname(CONFIG_PATH);
45-
if (!existsSync(dir)) {
46-
mkdirSync(dir, { recursive: true });
47-
}
48-
49-
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
50-
console.log(`Default package manager set to: ${pm}`);
55+
const config = getConfig();
56+
config[configKey] = pm;
57+
saveConfig(config);
58+
console.log(`${displayName} set to: ${pm}`);
59+
}
60+
61+
export function setDefaultPackageManager(pm: string): void {
62+
setPackageManager(pm, 'defaultPackageManager', 'Default package manager');
63+
}
64+
65+
export function setGlobalPackageManager(pm: string): void {
66+
setPackageManager(pm, 'globalPackageManager', 'Global package manager');
5167
}
5268

5369
function isValidPackageManager(pm: string): boolean {

src/dependency-synchronizer.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
3-
import { execSync } from 'child_process';
3+
import { spawnSync } from 'child_process';
44
import { PackageManager, getConfig } from './package-manager-config';
55
import { readCache, writeCache, hashFile } from './lockfile-hash-cache';
66

@@ -45,7 +45,16 @@ export function synchronizeDependencies(options: SyncOptions): void {
4545
}
4646

4747
try {
48-
execSync(command, { stdio: 'inherit', encoding: 'utf8', cwd: executionRoot });
48+
const installCmd = ciMode ? config.ciCommand : config.installCommand;
49+
const args = installCmd.split(' ');
50+
const result = spawnSync(packageManager, args, { stdio: 'inherit', encoding: 'utf8', cwd: executionRoot });
51+
52+
if (result.error) {
53+
throw result.error;
54+
}
55+
if (result.status !== 0) {
56+
throw new Error(`Command failed with exit code ${result.status}`);
57+
}
4958

5059
const lockfilePath = path.join(executionRoot, getConfig(packageManager).lockfile);
5160
writeCache(executionRoot, {

src/detector.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import { PackageManager, PACKAGE_MANAGERS, SUPPORTED_PACKAGE_MANAGERS } from './package-manager-config';
44
import { getDefaultPackageManager } from './config';
5+
import { INSTALL_COMMANDS, WORKSPACE_ROOT_COMMANDS } from './command-constants';
56

67
export interface DetectionResult {
78
packageManager: PackageManager;
@@ -84,14 +85,12 @@ export function detectPackageManager(startDir = process.cwd()): DetectionResult
8485
}
8586

8687
export function shouldRunAtWorkspaceRoot(command: string, args: string[]): boolean {
87-
const workspaceRootCommands = new Set(['install', 'i', 'update', 'upgrade', 'audit', 'outdated']);
88-
89-
// Install/i with packages should run in current dir, without should run at root
90-
if ((command === 'install' || command === 'i')) {
88+
// Install commands with packages should run in current dir, without should run at root
89+
if (INSTALL_COMMANDS.includes(command as any)) {
9190
return args.length === 0;
9291
}
9392

94-
return workspaceRootCommands.has(command);
93+
return WORKSPACE_ROOT_COMMANDS.has(command);
9594
}
9695

9796
export function isWorkspaceRoot(dir: string): boolean {

0 commit comments

Comments
 (0)