node-linker-pro
Version:
Keep node_modules out of cloud sync folders by linking them to a local cache. Cross-platform. Includes global npm setup command.
118 lines (98 loc) • 5.15 kB
JavaScript
import fsp from 'fs/promises';
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { spawn } from 'child_process';
import chalk from 'chalk';
const isWin = process.platform === 'win32';
// --- Helper Functions ---
function fail(msg) { console.error(msg); process.exit(1); }
async function exists(p) { try { await fsp.lstat(p); return true; } catch { return false; } }
async function isLinkTo(linkPath, targetPath) {
try {
if (!(await fsp.lstat(linkPath)).isSymbolicLink()) return false;
const resolvedLink = path.resolve(path.dirname(linkPath), await fsp.readlink(linkPath));
const resolvedTarget = path.resolve(targetPath);
return isWin ? resolvedLink.toLowerCase() === resolvedTarget.toLowerCase() : resolvedLink === resolvedTarget;
} catch { return false; }
}
async function findProjectRoot(startDir) {
let currentDir = startDir;
while (true) {
if (await exists(path.join(currentDir, 'package.json'))) return currentDir;
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) return null;
currentDir = parentDir;
}
}
function defaultStoreDir() {
if (isWin) {
const base = process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local");
return path.join(base, "node_modules_store");
}
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches", "node_modules_store");
return path.join(os.homedir(), ".cache", "node_modules_store");
}
// --- Main Logic ---
async function main() {
const projectDir = await findProjectRoot(process.cwd());
if (!projectDir) fail(chalk.red("❌ No `package.json` found."));
const nmLinkPath = path.join(projectDir, 'node_modules');
const pkgPath = path.join(projectDir, 'package.json');
const lockPath = path.join(projectDir, 'package-lock.json');
const storeRoot = defaultStoreDir();
const hash = crypto.createHash('sha1').update(projectDir).digest('hex');
const cacheProjDir = path.join(storeRoot, hash);
const cacheNodeModules = path.join(cacheProjDir, 'node_modules');
console.log(chalk.blue(`Project: ${projectDir}`));
console.log(chalk.blue(`Cache: ${cacheProjDir}`));
await fsp.mkdir(cacheNodeModules, { recursive: true });
if (!(await isLinkTo(nmLinkPath, cacheNodeModules))) {
console.log(chalk.yellow('Creating junction for node_modules...'));
await fsp.rm(nmLinkPath, { recursive: true, force: true }).catch(() => {});
await fsp.symlink(cacheNodeModules, nmLinkPath, 'junction');
if (!(await isLinkTo(nmLinkPath, cacheNodeModules))) fail(chalk.red('❌ Failed to create or verify the node_modules junction.'));
console.log(chalk.green('✅ Linked project node_modules to cache.'));
} else {
console.log(chalk.green('✅ Project node_modules is already linked correctly.'));
}
console.log(chalk.yellow('Syncing manifests to cache...'));
await fsp.copyFile(pkgPath, path.join(cacheProjDir, 'package.json'));
if (await exists(lockPath)) {
await fsp.copyFile(lockPath, path.join(cacheProjDir, 'package-lock.json'));
}
await runNpmInstallInCache(cacheProjDir);
console.log(chalk.bold.green('\n🎉 Success! Dependencies are installed in the external cache.'));
console.log(chalk.cyan(`--> To run your project, use 'npm run dev' as usual.`));
console.log(chalk.cyan(`--> To install/update dependencies in the future, just run 'nlp' again.`));
}
function runNpmInstallInCache(cacheProjDir) {
return new Promise((resolve, reject) => {
const hasLock = fs.existsSync(path.join(cacheProjDir, 'package-lock.json'));
const npmAction = hasLock ? 'ci' : 'install';
const npmInstallArgs = [npmAction, '--prefix', cacheProjDir, '--no-audit', '--no-fund'];
const command = isWin ? 'cmd.exe' : 'npm';
const args = isWin ? ['/c', 'npm', ...npmInstallArgs] : npmInstallArgs;
console.log(chalk.yellow(`\nRunning: ${chalk.cyan(`${isWin ? 'cmd /c npm' : 'npm'} ${npmInstallArgs.join(' ')}`)}\n`));
// THE FIX FOR HANGING:
// We use stdio: 'pipe' and manually drain the output buffers.
const p = spawn(command, args, { stdio: 'pipe' });
p.stdout.on('data', (data) => process.stdout.write(data));
p.stderr.on('data', (data) => process.stderr.write(data));
p.on('close', (code) => {
if (code === 0) {
console.log('\n'); // Final newline for clean output
resolve();
} else {
reject(new Error(`Installation failed with exit code ${code}.`));
}
});
p.on('error', (err) => {
reject(new Error(`Failed to start subprocess: ${err.message}`));
});
});
}
// --- Main Execution ---
main().catch(err => fail(chalk.red(`\n❌ Operation failed: ${err.message}`)));