Skip to content

Commit 2d1b8a9

Browse files
authored
fix: bun text lockfile support and add XDG config support (#1)
* feat: support XDG standard for config * update readme * fis: support bun text lockfiles and some minor refactoring * add todo to fix yarn global support
1 parent 72c6445 commit 2d1b8a9

8 files changed

Lines changed: 108 additions & 53 deletions

File tree

README.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,31 +42,43 @@ xpx prettier # Download and run from the registry
4242

4343
## Configuration
4444

45-
Set the default package manager for new projects:
45+
xpm defaults to npm for global installs. Configure this with a config file at `~/.config/xpm/config.json` or `~/.xpmrc`.
46+
47+
### Default Package Manager
48+
49+
Set the default package manager for new projects (when no lockfile or package.json `packageManager` field is found):
4650

4751
```bash
4852
xpm set-config default-package-manager <npm|yarn|pnpm|bun>
4953
```
5054

51-
The default is used when no lockfile or package.json `packageManager` field is found.
52-
53-
You can also set it via environment variable:
55+
Environment variable (takes precedence):
5456
```bash
5557
export XPM_DEFAULT_PM=<npm|yarn|pnpm|bun>
5658
```
5759

58-
Set the package manager for -g commands:
60+
### Global Package Manager
61+
62+
Set which package manager to use for global installs (`-g` flag):
5963

6064
```bash
6165
xpm set-config global-package-manager <npm|yarn|pnpm|bun>
6266
```
6367

64-
You can also set it via environment variable:
68+
Environment variable (takes precedence):
6569
```bash
6670
export XPM_GLOBAL_PM=<npm|yarn|pnpm|bun>
6771
```
6872

73+
Default: `npm`
74+
75+
### Configuration Priority
76+
77+
1. Environment variables (`XPM_DEFAULT_PM`, `XPM_GLOBAL_PM`)
78+
2. Config file (`~/.config/xpm/config.json` or `~/.xpmrc`)
79+
3. Default fallback (`npm`)
80+
6981

7082
## License
7183

72-
MIT
84+
MIT

src/config.ts

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,68 @@
11
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
22
import { homedir } from 'os';
33
import { join, dirname } from 'path';
4-
import { PackageManager } from './package-manager-config';
4+
import { PackageManager, SUPPORTED_PACKAGE_MANAGERS } from './package-manager-config';
55

6-
const CONFIG_PATH = join(homedir(), '.xpmrc');
6+
function isValidPackageManager(pm: string): pm is PackageManager {
7+
return SUPPORTED_PACKAGE_MANAGERS.includes(pm as any);
8+
}
9+
10+
const CONFIG_PATHS = [
11+
() => join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'xpm', 'config.json'),
12+
() => join(homedir(), '.xpmrc')
13+
];
14+
15+
function getConfigPath(): string {
16+
for (const pathFn of CONFIG_PATHS) {
17+
const path = pathFn();
18+
if (existsSync(path)) {
19+
return path;
20+
}
21+
}
22+
// Default to XDG config for new config files
23+
return CONFIG_PATHS[0]();
24+
}
25+
26+
interface Config {
27+
defaultPackageManager?: PackageManager;
28+
globalPackageManager?: PackageManager;
29+
}
730

8-
function getConfig(): any {
9-
if (existsSync(CONFIG_PATH)) {
31+
function getConfig(): Config {
32+
const configPath = getConfigPath();
33+
if (existsSync(configPath)) {
1034
try {
11-
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
12-
} catch {}
35+
return JSON.parse(readFileSync(configPath, 'utf-8'));
36+
} catch {
37+
console.warn(`Failed to parse config at ${configPath}`);
38+
}
1339
}
1440
return {};
1541
}
1642

17-
function saveConfig(config: any): void {
18-
const dir = dirname(CONFIG_PATH);
43+
function saveConfig(config: Config): void {
44+
const configPath = getConfigPath();
45+
const dir = dirname(configPath);
1946
if (!existsSync(dir)) {
2047
mkdirSync(dir, { recursive: true });
2148
}
22-
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
49+
writeFileSync(configPath, JSON.stringify(config, null, 2));
2350
}
2451

25-
function getPackageManager(envVar: string, configKey: string): PackageManager {
26-
// 1. Check environment variable
52+
function getPackageManager(envVar: string, configKey: keyof Config): PackageManager {
53+
// 1. Environment variable (highest priority)
2754
const envPM = process.env[envVar];
2855
if (envPM && isValidPackageManager(envPM)) {
2956
return envPM as PackageManager;
3057
}
3158

32-
// 2. Check config file
59+
// 2. Config file
3360
const config = getConfig();
3461
if (config[configKey] && isValidPackageManager(config[configKey])) {
3562
return config[configKey];
3663
}
3764

38-
// 3. Fall back to npm
65+
// 3. Default fallback
3966
return 'npm';
4067
}
4168

@@ -47,13 +74,13 @@ export function getGlobalPackageManager(): PackageManager {
4774
return getPackageManager('XPM_GLOBAL_PM', 'globalPackageManager');
4875
}
4976

50-
function setPackageManager(pm: string, configKey: string, displayName: string): void {
77+
function setPackageManager(pm: string, configKey: keyof Config, displayName: string): void {
5178
if (!isValidPackageManager(pm)) {
52-
throw new Error(`Invalid package manager: ${pm}. Must be one of: npm, yarn, pnpm, bun`);
79+
throw new Error(`Invalid package manager: ${pm}. Must be one of: ${SUPPORTED_PACKAGE_MANAGERS.join(', ')}`);
5380
}
5481

5582
const config = getConfig();
56-
config[configKey] = pm;
83+
config[configKey] = pm as PackageManager;
5784
saveConfig(config);
5885
console.log(`${displayName} set to: ${pm}`);
5986
}
@@ -66,6 +93,3 @@ export function setGlobalPackageManager(pm: string): void {
6693
setPackageManager(pm, 'globalPackageManager', 'Global package manager');
6794
}
6895

69-
function isValidPackageManager(pm: string): boolean {
70-
return ['npm', 'yarn', 'pnpm', 'bun'].includes(pm);
71-
}

src/dependency-synchronizer.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as fs from 'fs';
22
import * as path from 'path';
33
import { spawnSync } from 'child_process';
4-
import { PackageManager, getConfig } from './package-manager-config';
4+
import { PackageManager, getPMConfig } from './package-manager-config';
5+
import { findExistingLockfile } from './detector';
56
import { readCache, writeCache, hashFile } from './lockfile-hash-cache';
67

78
export interface SyncOptions {
@@ -15,15 +16,16 @@ export interface SyncOptions {
1516

1617
function needsInstall(packageManager: PackageManager, projectRoot: string, workspaceRoot?: string): boolean {
1718
const checkRoot = workspaceRoot || projectRoot;
18-
const lockfilePath = path.join(checkRoot, getConfig(packageManager).lockfile);
19-
20-
if (!fs.existsSync(lockfilePath)) return false;
21-
19+
const lockfilePath = findExistingLockfile(packageManager, checkRoot);
20+
const hasNodeModules = fs.existsSync(path.join(checkRoot, 'node_modules'));
21+
22+
if (!lockfilePath) return !hasNodeModules;
23+
2224
const currentHash = hashFile(lockfilePath);
2325
const cache = readCache(checkRoot);
24-
25-
return cache.lockfileHash !== currentHash ||
26-
!fs.existsSync(path.join(checkRoot, 'node_modules'));
26+
const hasLockfileChanged = cache.lockfileHash !== currentHash;
27+
28+
return hasLockfileChanged || !hasNodeModules;
2729
}
2830

2931
export function synchronizeDependencies(options: SyncOptions): void {
@@ -32,7 +34,7 @@ export function synchronizeDependencies(options: SyncOptions): void {
3234

3335
if (!force && !needsInstall(packageManager, projectRoot, workspaceRoot) && !ciMode) return;
3436

35-
const config = getConfig(packageManager);
37+
const config = getPMConfig(packageManager);
3638
const command = `${packageManager} ${ciMode ? config.ciCommand : config.installCommand}`;
3739

3840
if (dryRun) {
@@ -56,7 +58,8 @@ export function synchronizeDependencies(options: SyncOptions): void {
5658
throw new Error(`Command failed with exit code ${result.status}`);
5759
}
5860

59-
const lockfilePath = path.join(executionRoot, getConfig(packageManager).lockfile);
61+
const lockfilePath = findExistingLockfile(packageManager, executionRoot);
62+
if (!lockfilePath) return;
6063
writeCache(executionRoot, {
6164
lockfileHash: hashFile(lockfilePath) || undefined,
6265
lastSync: new Date().toISOString()
@@ -69,4 +72,4 @@ export function synchronizeDependencies(options: SyncOptions): void {
6972

7073
export function checkDependencies(packageManager: PackageManager, projectRoot: string, workspaceRoot?: string): boolean {
7174
return needsInstall(packageManager, projectRoot, workspaceRoot);
72-
}
75+
}

src/detector.ts

Lines changed: 16 additions & 7 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 { PackageManager, PACKAGE_MANAGERS, SUPPORTED_PACKAGE_MANAGERS } from './package-manager-config';
3+
import { PackageManager, SUPPORTED_PACKAGE_MANAGERS, getPMConfig } from './package-manager-config';
44
import { getDefaultPackageManager } from './config';
55
import { INSTALL_COMMANDS, WORKSPACE_ROOT_COMMANDS } from './command-constants';
66

@@ -31,9 +31,8 @@ export function detectPackageManager(startDir = process.cwd()): DetectionResult
3131
dir = packageJsonDir;
3232

3333
while (dir !== path.dirname(dir)) {
34-
const hasLockfile = ['bun', 'pnpm', 'yarn', 'npm'].some(mgr =>
35-
fs.existsSync(path.join(dir, PACKAGE_MANAGERS[mgr as PackageManager].lockfile))
36-
);
34+
const hasLockfile = SUPPORTED_PACKAGE_MANAGERS
35+
.some(mgr => !!findExistingLockfile(mgr, dir));
3736

3837
if (hasLockfile) {
3938
lockfileDir = dir;
@@ -63,8 +62,8 @@ export function detectPackageManager(startDir = process.cwd()): DetectionResult
6362

6463
// Check for lockfiles if no corepack config
6564
if (!detectedPM) {
66-
for (const manager of ['bun', 'pnpm', 'yarn', 'npm'] as PackageManager[]) {
67-
if (fs.existsSync(path.join(detectionRoot, PACKAGE_MANAGERS[manager].lockfile))) {
65+
for (const manager of SUPPORTED_PACKAGE_MANAGERS) {
66+
if (findExistingLockfile(manager, detectionRoot)) {
6867
detectedPM = manager;
6968
break;
7069
}
@@ -84,6 +83,16 @@ export function detectPackageManager(startDir = process.cwd()): DetectionResult
8483
};
8584
}
8685

86+
export function findExistingLockfile(pm: PackageManager, dir: string): string | undefined {
87+
const lf = getPMConfig(pm).lockfile;
88+
const candidates = Array.isArray(lf) ? lf : [lf];
89+
for (const name of candidates) {
90+
const p = path.join(dir, name);
91+
if (fs.existsSync(p)) return p;
92+
}
93+
return undefined;
94+
}
95+
8796
export function shouldRunAtWorkspaceRoot(command: string, args: string[]): boolean {
8897
// Install commands with packages should run in current dir, without should run at root
8998
if (INSTALL_COMMANDS.includes(command as any)) {
@@ -102,4 +111,4 @@ export function isWorkspaceRoot(dir: string): boolean {
102111
} catch {
103112
return false;
104113
}
105-
}
114+
}

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
export { XPM } from './xpm';
22
export { detectPackageManager } from './detector';
33
export { synchronizeDependencies, checkDependencies } from './dependency-synchronizer';
4-
export { PACKAGE_MANAGERS, getConfig } from './package-manager-config';
4+
export { PACKAGE_MANAGERS, getPMConfig } from './package-manager-config';
55
export { readCache, writeCache, hashFile } from './lockfile-hash-cache';
66
export type { PackageManager } from './package-manager-config';
77
export type { CacheData } from './lockfile-hash-cache';
8-
export type { SyncOptions } from './dependency-synchronizer';
8+
export type { SyncOptions } from './dependency-synchronizer';

src/package-json.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFileSync, existsSync } from 'fs';
1+
import { readFileSync } from 'fs';
22
import { join } from 'path';
33

44
const loadPackageJson = (dir = process.cwd()): any => {
@@ -8,4 +8,4 @@ const loadPackageJson = (dir = process.cwd()): any => {
88
};
99

1010
export const hasScript = (name: string, root?: string): boolean =>
11-
loadPackageJson(root)?.scripts?.[name] !== undefined;
11+
loadPackageJson(root)?.scripts?.[name] !== undefined;

src/package-manager-config.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
22

3-
// Only store what we actually use
4-
type PMConfig = { lockfile: string; installCommand: string; ciCommand: string };
3+
type PMConfig = {
4+
lockfile: string | string[];
5+
installCommand: string;
6+
ciCommand: string;
7+
};
58

69
export const PACKAGE_MANAGERS: Record<PackageManager, PMConfig> = {
710
npm: { lockfile: 'package-lock.json', installCommand: 'install', ciCommand: 'ci' },
811
yarn: { lockfile: 'yarn.lock', installCommand: 'install', ciCommand: 'install --frozen-lockfile' },
912
pnpm: { lockfile: 'pnpm-lock.yaml', installCommand: 'install', ciCommand: 'install --frozen-lockfile' },
10-
bun: { lockfile: 'bun.lockb', installCommand: 'install', ciCommand: 'install --frozen-lockfile' }
13+
bun: { lockfile: ['bun.lock', 'bun.lockb'], installCommand: 'install', ciCommand: 'install --frozen-lockfile' }
1114
};
1215

1316
export const SUPPORTED_PACKAGE_MANAGERS = Object.keys(PACKAGE_MANAGERS) as PackageManager[];
14-
export const getConfig = (pm: PackageManager) => PACKAGE_MANAGERS[pm];
17+
export const getPMConfig = (pm: PackageManager) => PACKAGE_MANAGERS[pm];

src/xpm.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ Detects: npm/yarn/pnpm/bun`);
100100
const hasGlobalFlag = this.args.includes('-g') || this.args.includes('--global');
101101

102102
if (hasGlobalFlag && command && GLOBAL_SUPPORT_COMMANDS.includes(command as any)) {
103+
// TODO(yarn-global): Yarn differs for global installs.
104+
// - Yarn v1 uses: `yarn global <subcmd> ...` (no -g flag)
105+
// - Yarn v2+ removed persistent global installs; prefer error + guidance or fallback
106+
// Consider: detect Yarn major via `yarn -v` and special-case here.
103107
// Handle global installs/uninstalls
104108
const globalPackageManager = getGlobalPackageManager();
105109
const filteredArgs = this.args.filter(arg => arg !== '-g' && arg !== '--global');
@@ -159,4 +163,4 @@ Detects: npm/yarn/pnpm/bun`);
159163
process.exit(1);
160164
}
161165
}
162-
}
166+
}

0 commit comments

Comments
 (0)