jpm-pkg 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/jpm.js +6 -2
- package/package.json +2 -4
- package/src/commands/audit.js +38 -6
- package/src/commands/create.js +57 -0
- package/src/commands/doctor.js +92 -0
- package/src/commands/install.js +20 -84
- package/src/commands/link.js +101 -0
- package/src/commands/rebuild.js +115 -0
- package/src/commands/update.js +9 -8
- package/src/commands/why.js +100 -0
- package/src/commands/x.js +19 -2
- package/src/core/cache.js +37 -0
- package/src/core/engine.js +122 -0
- package/src/core/installer.js +39 -9
- package/src/core/registry.js +22 -14
- package/src/utils/http.js +43 -29
package/bin/jpm.js
CHANGED
|
@@ -175,9 +175,13 @@ const COMMANDS = {
|
|
|
175
175
|
audit: () => require('../src/commands/audit'),
|
|
176
176
|
run: () => require('../src/commands/run'),
|
|
177
177
|
init: () => require('../src/commands/init'),
|
|
178
|
-
create: () => require('../src/commands/
|
|
179
|
-
|
|
178
|
+
create: () => require('../src/commands/create'),
|
|
179
|
+
info: () => require('../src/commands/info'),
|
|
180
180
|
view: () => require('../src/commands/info'),
|
|
181
|
+
doctor: () => require('../src/commands/doctor'),
|
|
182
|
+
why: () => require('../src/commands/why'),
|
|
183
|
+
rebuild: () => require('../src/commands/rebuild'),
|
|
184
|
+
link: () => require('../src/commands/link'),
|
|
181
185
|
list: () => require('../src/commands/list'),
|
|
182
186
|
ls: () => require('../src/commands/list'),
|
|
183
187
|
exec: () => require('../src/commands/x'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jpm-pkg",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Joint Package Manager — Joint, universal, advanced, and blazing fast package manager for Node.js and Bun.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -34,9 +34,7 @@
|
|
|
34
34
|
"url": "https://github.com/SulmaneDev/jpm/issues"
|
|
35
35
|
},
|
|
36
36
|
"homepage": "https://github.com/SulmaneDev/jpm#readme",
|
|
37
|
-
"dependencies": {
|
|
38
|
-
"tar": "^6.2.0"
|
|
39
|
-
},
|
|
37
|
+
"dependencies": {},
|
|
40
38
|
"devDependencies": {
|
|
41
39
|
"jest": "^29.7.0"
|
|
42
40
|
},
|
package/src/commands/audit.js
CHANGED
|
@@ -19,7 +19,7 @@ class AuditCommand extends BaseCommand {
|
|
|
19
19
|
* Executes the security audit.
|
|
20
20
|
*
|
|
21
21
|
* @param {string[]} args - Optional arguments
|
|
22
|
-
* @param {Object} flags - CLI flags (e.g., --level)
|
|
22
|
+
* @param {Object} flags - CLI flags (e.g., --level, --fix)
|
|
23
23
|
* @returns {Promise<void>}
|
|
24
24
|
*/
|
|
25
25
|
async run(args, flags) {
|
|
@@ -34,10 +34,12 @@ class AuditCommand extends BaseCommand {
|
|
|
34
34
|
// Handle scoped packages (@org/pkg)
|
|
35
35
|
if (entry.name.startsWith('@') && entry.isDirectory()) {
|
|
36
36
|
const scopeDir = path.join(nodeModules, entry.name);
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
try {
|
|
38
|
+
for (const scoped of fs.readdirSync(scopeDir, { withFileTypes: true })) {
|
|
39
|
+
const pkgJsonPath = path.join(scopeDir, scoped.name, 'package.json');
|
|
40
|
+
this._tryAddPackage(pkgJsonPath, installed);
|
|
41
|
+
}
|
|
42
|
+
} catch (e) { /* skip */ }
|
|
41
43
|
} else if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
42
44
|
const pkgJsonPath = path.join(nodeModules, entry.name, 'package.json');
|
|
43
45
|
this._tryAddPackage(pkgJsonPath, installed);
|
|
@@ -55,7 +57,37 @@ class AuditCommand extends BaseCommand {
|
|
|
55
57
|
|
|
56
58
|
auditSec.formatAuditResults({ vulnerabilities, stats, total, error });
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
// Handle --fix
|
|
61
|
+
if (vulnerabilities.length > 0 && flags.fix) {
|
|
62
|
+
const fixable = vulnerabilities.filter(v => v.fixedIn && v.package !== 'undefined');
|
|
63
|
+
if (fixable.length > 0) {
|
|
64
|
+
this.logger.log('');
|
|
65
|
+
this.logger.info(`Attempting to fix ${fixable.length} vulnerabilities...`);
|
|
66
|
+
|
|
67
|
+
const Engine = require('../core/engine');
|
|
68
|
+
const engine = new Engine(cwd);
|
|
69
|
+
|
|
70
|
+
// Deduplicate and prepare package specs
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const toInstall = [];
|
|
73
|
+
for (const v of fixable) {
|
|
74
|
+
if (seen.has(v.package)) continue;
|
|
75
|
+
seen.add(v.package);
|
|
76
|
+
toInstall.push({ name: v.package, version: v.fixedIn });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await engine.install(toInstall, { flags });
|
|
81
|
+
this.logger.success(`Applied security patches for ${toInstall.length} packages.`);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
this.logger.error(`Failed to apply fixes: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
this.logger.info('No automated fixes available for these vulnerabilities.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (total > 0 && !flags.fix) {
|
|
59
91
|
process.exitCode = 1;
|
|
60
92
|
}
|
|
61
93
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BaseCommand = require('./base-command');
|
|
4
|
+
const ExecCommand = require('./x');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CreateCommand handles project scaffolding.
|
|
8
|
+
* It's a specialized wrapper around 'jpm x' (exec) that defaults to 'create-' prefixed packages.
|
|
9
|
+
* For example: 'jpm create vite' will execute 'create-vite'.
|
|
10
|
+
*/
|
|
11
|
+
class CreateCommand extends BaseCommand {
|
|
12
|
+
constructor() {
|
|
13
|
+
super('create');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Executes the scaffolding process.
|
|
18
|
+
*
|
|
19
|
+
* @param {string[]} args - Template name and optional generator arguments
|
|
20
|
+
* @param {Object} flags - CLI flags
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
async run(args, flags) {
|
|
24
|
+
if (!args.length) {
|
|
25
|
+
this.logger.error('Usage: jpm create <template> [options]');
|
|
26
|
+
this.logger.log('Example: jpm create vite');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const template = args[0];
|
|
31
|
+
// Convention: 'create-xxx' packages are used for scaffolding
|
|
32
|
+
const pkgName = template.includes('/') || template.startsWith('create-')
|
|
33
|
+
? template
|
|
34
|
+
: `create-${template}`;
|
|
35
|
+
|
|
36
|
+
const remainingArgs = args.slice(1);
|
|
37
|
+
|
|
38
|
+
this.logger.info(`Scaffolding project using ${this.logger.c.cyan(pkgName)}...`);
|
|
39
|
+
|
|
40
|
+
/** @type {ExecCommand} */
|
|
41
|
+
const exec = new ExecCommand();
|
|
42
|
+
|
|
43
|
+
// Inherit config and logger from this command
|
|
44
|
+
exec.config = this.config;
|
|
45
|
+
exec.logger = this.logger;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// jpm create vite app-name -- --template react
|
|
49
|
+
// We pass the package name and all following arguments to ExecCommand
|
|
50
|
+
await exec.run([pkgName, ...remainingArgs], flags);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
this.logger.error(`Scaffolding failed: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = CreateCommand;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const BaseCommand = require('./base-command');
|
|
7
|
+
const http = require('../utils/http');
|
|
8
|
+
const registry = require('../core/registry');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* DoctorCommand handles the 'jpm doctor' command.
|
|
12
|
+
* It performs various health checks on the environment and project.
|
|
13
|
+
*/
|
|
14
|
+
class DoctorCommand extends BaseCommand {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('doctor');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Executes the health checks.
|
|
21
|
+
*
|
|
22
|
+
* @param {string[]} args - CLI arguments (unused)
|
|
23
|
+
* @param {Object} flags - CLI flags
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
async run(args, flags) {
|
|
27
|
+
this.logger.section('JPM Doctor — Environment Health Check');
|
|
28
|
+
|
|
29
|
+
const c = this.logger.c;
|
|
30
|
+
let issues = 0;
|
|
31
|
+
|
|
32
|
+
// 1. Check Node.js Environment
|
|
33
|
+
const nodeVersion = process.version;
|
|
34
|
+
const platform = process.platform;
|
|
35
|
+
this.logger.log(`${c.cyan('Node.js')} ${nodeVersion} (${platform})`);
|
|
36
|
+
|
|
37
|
+
const major = parseInt(nodeVersion.slice(1).split('.')[0], 10);
|
|
38
|
+
if (major < 18) {
|
|
39
|
+
this.logger.warn(` ! JPM recommends Node.js v18 or higher (found ${nodeVersion})`);
|
|
40
|
+
issues++;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Check Registry Connectivity
|
|
44
|
+
const registryUrl = this.config.get('registry');
|
|
45
|
+
this.logger.log(`${c.cyan('Registry')} ${registryUrl}`);
|
|
46
|
+
try {
|
|
47
|
+
const start = Date.now();
|
|
48
|
+
await http.getJSON(`${registryUrl.replace(/\/$/, '')}/ping`);
|
|
49
|
+
const latency = Date.now() - start;
|
|
50
|
+
this.logger.log(` ${c.green('✓')} Connected (${latency}ms)`);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
this.logger.error(` ${c.red('✖')} Could not connect to registry: ${err.message}`);
|
|
53
|
+
issues++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 3. Check Cache Directory
|
|
57
|
+
const cacheDir = this.config.cacheDir;
|
|
58
|
+
this.logger.log(`${c.cyan('Cache Dir')} ${cacheDir}`);
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(cacheDir)) {
|
|
61
|
+
this.logger.log(' - Directory does not exist (will be created on first use)');
|
|
62
|
+
} else {
|
|
63
|
+
fs.accessSync(cacheDir, fs.constants.R_OK | fs.constants.W_OK);
|
|
64
|
+
const stats = fs.statSync(cacheDir);
|
|
65
|
+
this.logger.log(` ${c.green('✓')} Writable (${stats.mode.toString(8)})`);
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
this.logger.error(` ${c.red('✖')} Cache directory is not accessible: ${err.message}`);
|
|
69
|
+
issues++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 4. Check Local Project
|
|
73
|
+
const projectRoot = this.config.get('prefix') || process.cwd();
|
|
74
|
+
const pkgFile = path.join(projectRoot, 'package.json');
|
|
75
|
+
this.logger.log(`${c.cyan('Project')} ${projectRoot}`);
|
|
76
|
+
if (fs.existsSync(pkgFile)) {
|
|
77
|
+
this.logger.log(` ${c.green('✓')} package.json found`);
|
|
78
|
+
} else {
|
|
79
|
+
this.logger.warn(' ! No package.json found in current directory');
|
|
80
|
+
// Not necessarily an issue for a global check, but worth noting
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.logger.log('');
|
|
84
|
+
if (issues === 0) {
|
|
85
|
+
this.logger.success('Your environment is healthy! No issues found.');
|
|
86
|
+
} else {
|
|
87
|
+
this.logger.warn(`Found ${issues} potential issue(s). Check the logs above.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = DoctorCommand;
|
package/src/commands/install.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const BaseCommand = require('./base-command');
|
|
4
|
-
const Resolver = require('../core/resolver');
|
|
5
|
-
const Installer = require('../core/installer');
|
|
6
|
-
const Lockfile = require('../core/lockfile');
|
|
7
|
-
const PackageJSON = require('../core/package-json');
|
|
8
4
|
const { Spinner } = require('../utils/progress');
|
|
9
5
|
|
|
10
6
|
/**
|
|
@@ -25,8 +21,9 @@ class InstallCommand extends BaseCommand {
|
|
|
25
21
|
*/
|
|
26
22
|
async run(args, flags) {
|
|
27
23
|
const cwd = process.cwd();
|
|
28
|
-
const
|
|
29
|
-
const
|
|
24
|
+
const Engine = require('../core/engine');
|
|
25
|
+
const engine = new Engine(cwd);
|
|
26
|
+
|
|
30
27
|
const isDev = flags.D || flags['save-dev'];
|
|
31
28
|
const isExact = flags.E || flags['save-exact'] || this.config.saveExact;
|
|
32
29
|
const noSave = flags['no-save'];
|
|
@@ -45,95 +42,34 @@ class InstallCommand extends BaseCommand {
|
|
|
45
42
|
toInstall.push({ name, version });
|
|
46
43
|
}
|
|
47
44
|
} else {
|
|
48
|
-
// General install from package.json
|
|
49
|
-
|
|
45
|
+
// General install from package.json/lockfile
|
|
46
|
+
if (engine.lockfile.exists()) {
|
|
47
|
+
await engine.installFromLock(flags);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
50
51
|
const allDeps = {
|
|
51
|
-
...pkgJson.dependencies,
|
|
52
|
-
...(flags.production ? {} : pkgJson.devDependencies),
|
|
52
|
+
...engine.pkgJson.dependencies,
|
|
53
|
+
...(flags.production ? {} : engine.pkgJson.devDependencies),
|
|
53
54
|
};
|
|
54
|
-
spinner.succeed(`Found ${Object.keys(allDeps).length} dependencies`);
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.logger.info('Using lock file for deterministic install');
|
|
59
|
-
const lockData = lockfile.allPackages();
|
|
60
|
-
const installer = new Installer(cwd);
|
|
61
|
-
const bar = new Spinner('Installing from lock file...');
|
|
62
|
-
bar.start();
|
|
63
|
-
const fakeMap = new Map(lockData.map(p => [`${p.name}@${p.version}`, p]));
|
|
64
|
-
await installer.installAll(fakeMap, { dryRun, flags });
|
|
65
|
-
bar.succeed(`Installed ${lockData.length} packages`);
|
|
56
|
+
if (!Object.keys(allDeps).length) {
|
|
57
|
+
this.logger.info('Nothing to install.');
|
|
66
58
|
return;
|
|
67
59
|
}
|
|
68
60
|
|
|
69
61
|
toInstall = Object.entries(allDeps).map(([name, version]) => ({ name, version }));
|
|
70
62
|
}
|
|
71
63
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const resolver = new Resolver();
|
|
80
|
-
|
|
81
|
-
const deps = {};
|
|
82
|
-
const devDeps = {};
|
|
83
|
-
for (const { name, version } of toInstall) {
|
|
84
|
-
if (isDev) devDeps[name] = version;
|
|
85
|
-
else deps[name] = version;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let resolvedMap;
|
|
89
|
-
try {
|
|
90
|
-
resolvedMap = await resolver.resolve(deps, devDeps, {}, (count, total) => {
|
|
91
|
-
resolveSpinner.text = `Resolving packages... (${count} resolved)`;
|
|
92
|
-
});
|
|
93
|
-
resolveSpinner.succeed(`Resolved ${resolvedMap.size} packages (including transitive deps)`);
|
|
94
|
-
} catch (err) {
|
|
95
|
-
resolveSpinner.fail(`Resolution failed: ${err.message}`);
|
|
96
|
-
throw err; // Let caller handle exit
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Warn about circular deps
|
|
100
|
-
const circular = resolver.findCircular();
|
|
101
|
-
if (circular.length) {
|
|
102
|
-
this.logger.warn(`Circular dependencies detected:\n ${circular.join('\n ')}`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ── Install ────────────────────────────────────────────────────────────────
|
|
106
|
-
this.logger.info(`Installing ${resolvedMap.size} packages...`);
|
|
107
|
-
const installer = new Installer(cwd);
|
|
108
|
-
try {
|
|
109
|
-
await installer.installAll(resolvedMap, { dryRun, flags });
|
|
110
|
-
} catch (err) {
|
|
111
|
-
this.logger.error(`Install failed: ${err.message}`);
|
|
112
|
-
throw err;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// ── Update package.json & lock file ─────────────────────────────────────────
|
|
116
|
-
if (!noSave && !dryRun && args.length) {
|
|
117
|
-
for (const { name } of toInstall) {
|
|
118
|
-
const resolved = [...resolvedMap.values()].find(m => m.name === name);
|
|
119
|
-
if (!resolved) continue;
|
|
120
|
-
pkgJson.addDependency(name, resolved.version, { dev: !!isDev, exact: isExact });
|
|
121
|
-
}
|
|
122
|
-
pkgJson.save();
|
|
123
|
-
this.logger.verbose('Updated package.json');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!dryRun) {
|
|
127
|
-
lockfile.update(resolvedMap).save();
|
|
128
|
-
this.logger.verbose('Updated jpm-lock.json');
|
|
129
|
-
}
|
|
64
|
+
// ── Core Installation Flow ────────────────────────────────────────────────
|
|
65
|
+
const resolvedMap = await engine.install(toInstall, {
|
|
66
|
+
dev: !!isDev,
|
|
67
|
+
exact: !!isExact,
|
|
68
|
+
noSave: !!noSave,
|
|
69
|
+
flags
|
|
70
|
+
});
|
|
130
71
|
|
|
131
72
|
// ── Summary ────────────────────────────────────────────────────────────────
|
|
132
|
-
const dupes = resolver.deduplicate();
|
|
133
|
-
if (dupes.length) {
|
|
134
|
-
this.logger.verbose(`Deduplication: ${dupes.length} packages have multiple versions`);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
73
|
const directly = toInstall.map(({ name }) => {
|
|
138
74
|
const r = [...resolvedMap.values()].find(m => m.name === name);
|
|
139
75
|
return r ? `${r.name}@${r.version}` : name;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const BaseCommand = require('./base-command');
|
|
7
|
+
const { mkdirp, symlink } = require('../utils/fs');
|
|
8
|
+
const PackageJSON = require('../core/package-json');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* LinkCommand handles 'jpm link' and 'jpm link <package>'.
|
|
12
|
+
* Facilitates local package development by creating symbolic links between projects.
|
|
13
|
+
* Similar to 'npm link'.
|
|
14
|
+
*/
|
|
15
|
+
class LinkCommand extends BaseCommand {
|
|
16
|
+
constructor() {
|
|
17
|
+
super('link');
|
|
18
|
+
/** @type {string} */
|
|
19
|
+
this.globalLinkDir = path.join(os.homedir(), '.jpm', 'links');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Executes the linking process.
|
|
24
|
+
*
|
|
25
|
+
* @param {string[]} args - Optional package name to link into current project
|
|
26
|
+
* @param {Object} flags - CLI flags
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
async run(args, flags) {
|
|
30
|
+
const cwd = process.cwd();
|
|
31
|
+
|
|
32
|
+
if (args.length === 0) {
|
|
33
|
+
// Step 1: Run 'jpm link' in the package directory to register it globally
|
|
34
|
+
await this._linkGlobal(cwd);
|
|
35
|
+
} else {
|
|
36
|
+
// Step 2: Run 'jpm link <pkg>' in the consumer project to use the linked package
|
|
37
|
+
for (const pkgName of args) {
|
|
38
|
+
await this._linkToProject(pkgName, cwd);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Registers a local package directory in the global JPM links folder.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} pkgDir - Absolute path to the package directory
|
|
47
|
+
* @returns {Promise<void>}
|
|
48
|
+
* @private
|
|
49
|
+
*/
|
|
50
|
+
async _linkGlobal(pkgDir) {
|
|
51
|
+
let pkg;
|
|
52
|
+
try {
|
|
53
|
+
pkg = PackageJSON.fromDir(pkgDir);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
this.logger.error(`Could not read package.json in ${pkgDir}`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
mkdirp(this.globalLinkDir);
|
|
60
|
+
const dest = path.join(this.globalLinkDir, pkg.name);
|
|
61
|
+
|
|
62
|
+
this.logger.info(`Registering ${this.logger.c.cyan(pkg.name)} globally...`);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
symlink(pkgDir, dest);
|
|
66
|
+
this.logger.success(`${this.logger.c.bold(pkg.name)} linked globally to ${pkgDir}`);
|
|
67
|
+
this.logger.log(`Run ${this.logger.c.gray(`jpm link ${pkg.name}`)} in another project to use it.`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this.logger.error(`Failed to create global link: ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Links a globally registered package into a project's node_modules.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} pkgName - Name of the package to link
|
|
77
|
+
* @param {string} projectDir - Absolute path to the consumer project
|
|
78
|
+
* @returns {Promise<void>}
|
|
79
|
+
* @private
|
|
80
|
+
*/
|
|
81
|
+
async _linkToProject(pkgName, projectDir) {
|
|
82
|
+
const globalSrc = path.join(this.globalLinkDir, pkgName);
|
|
83
|
+
if (!fs.existsSync(globalSrc)) {
|
|
84
|
+
this.logger.error(`Package "${pkgName}" is not linked globally.`);
|
|
85
|
+
this.logger.log(`Run ${this.logger.c.gray('jpm link')} inside the ${pkgName} directory first.`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const projectNM = path.join(projectDir, 'node_modules', pkgName);
|
|
90
|
+
this.logger.info(`Linking ${this.logger.c.cyan(pkgName)} into project...`);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
symlink(globalSrc, projectNM);
|
|
94
|
+
this.logger.success(`${this.logger.c.bold(pkgName)} linked to ${this.logger.c.gray(projectNM)}`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
this.logger.error(`Failed to link ${pkgName}: ${err.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = LinkCommand;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
const BaseCommand = require('./base-command');
|
|
7
|
+
const { Spinner } = require('../utils/progress');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RebuildCommand handles the 'jpm rebuild' command.
|
|
11
|
+
* It re-runs lifecycle scripts (preinstall, install, postinstall) for installed packages.
|
|
12
|
+
*/
|
|
13
|
+
class RebuildCommand extends BaseCommand {
|
|
14
|
+
constructor() {
|
|
15
|
+
super('rebuild');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Executes the rebuild process across node_modules.
|
|
20
|
+
*
|
|
21
|
+
* @param {string[]} args - Optional package names to limit the rebuild to
|
|
22
|
+
* @param {Object} flags - CLI flags
|
|
23
|
+
* @returns {Promise<void>}
|
|
24
|
+
*/
|
|
25
|
+
async run(args, flags) {
|
|
26
|
+
const cwd = process.cwd();
|
|
27
|
+
const nmDir = path.join(cwd, 'node_modules');
|
|
28
|
+
|
|
29
|
+
if (!fs.existsSync(nmDir)) {
|
|
30
|
+
this.logger.error('node_modules directory not found. Run jpm install first.');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const targets = args.length ? new Set(args) : null;
|
|
35
|
+
const scanSpinner = new Spinner('Scanning node_modules for scripts...').start();
|
|
36
|
+
|
|
37
|
+
const pkgsWithScripts = [];
|
|
38
|
+
const entries = fs.readdirSync(nmDir, { withFileTypes: true });
|
|
39
|
+
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
// Skip non-package entries (like .bin)
|
|
42
|
+
if (entry.name.startsWith('.')) continue;
|
|
43
|
+
|
|
44
|
+
if (entry.name.startsWith('@')) {
|
|
45
|
+
// Handle scoped packages
|
|
46
|
+
const scopeDir = path.join(nmDir, entry.name);
|
|
47
|
+
try {
|
|
48
|
+
const scopedEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
|
|
49
|
+
for (const scopedEntry of scopedEntries) {
|
|
50
|
+
this._checkPkg(path.join(scopeDir, scopedEntry.name), pkgsWithScripts, targets);
|
|
51
|
+
}
|
|
52
|
+
} catch (e) { /* ignore read errors */ }
|
|
53
|
+
} else {
|
|
54
|
+
this._checkPkg(path.join(nmDir, entry.name), pkgsWithScripts, targets);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
scanSpinner.succeed(`Found ${pkgsWithScripts.length} package(s) with lifecycle scripts.`);
|
|
59
|
+
|
|
60
|
+
if (pkgsWithScripts.length === 0) {
|
|
61
|
+
this.logger.info('No lifecycle scripts found to run.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const { name, dir, scripts } of pkgsWithScripts) {
|
|
66
|
+
// Lifecycle scripts run in specific order: preinstall -> install -> postinstall
|
|
67
|
+
for (const hook of ['preinstall', 'install', 'postinstall']) {
|
|
68
|
+
if (scripts[hook]) {
|
|
69
|
+
this.logger.section(`▶ ${this.logger.c.cyan(name)} — ${hook}`);
|
|
70
|
+
const result = spawnSync(scripts[hook], {
|
|
71
|
+
cwd: dir,
|
|
72
|
+
shell: true,
|
|
73
|
+
stdio: 'inherit',
|
|
74
|
+
env: { ...process.env, PATH: `${path.join(nmDir, '.bin')}${path.delimiter}${process.env.PATH}` }
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (result.status !== 0) {
|
|
78
|
+
this.logger.warn(`Script "${hook}" in ${name} failed with exit code ${result.status}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.logger.success('Rebuild complete!');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Checks a directory for a package.json containing lifecycle scripts.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} dir - Directory to check
|
|
91
|
+
* @param {Array} list - Array to push matching package info into
|
|
92
|
+
* @param {Set|null} targets - Optional set of package names to filter by
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
_checkPkg(dir, list, targets) {
|
|
96
|
+
const pkgFile = path.join(dir, 'package.json');
|
|
97
|
+
if (!fs.existsSync(pkgFile)) return;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
|
|
101
|
+
if (targets && !targets.has(pkg.name)) return;
|
|
102
|
+
|
|
103
|
+
const scripts = pkg.scripts || {};
|
|
104
|
+
const hasScripts = scripts.install || scripts.preinstall || scripts.postinstall;
|
|
105
|
+
|
|
106
|
+
if (hasScripts) {
|
|
107
|
+
list.push({ name: pkg.name, dir, scripts });
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
// skip invalid or unreadable package.json
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = RebuildCommand;
|
package/src/commands/update.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const BaseCommand = require('./base-command');
|
|
4
|
-
const PackageJSON = require('../core/package-json');
|
|
5
4
|
const registry = require('../core/registry');
|
|
6
5
|
const semver = require('../utils/semver');
|
|
7
6
|
const { Spinner } = require('../utils/progress');
|
|
@@ -24,8 +23,10 @@ class UpdateCommand extends BaseCommand {
|
|
|
24
23
|
*/
|
|
25
24
|
async run(args, flags) {
|
|
26
25
|
const cwd = process.cwd();
|
|
27
|
-
const
|
|
28
|
-
const
|
|
26
|
+
const Engine = require('../core/engine');
|
|
27
|
+
const engine = new Engine(cwd);
|
|
28
|
+
|
|
29
|
+
const allDeps = engine.pkgJson.allDeps();
|
|
29
30
|
|
|
30
31
|
const targets = args.length
|
|
31
32
|
? args.reduce((acc, n) => { if (allDeps[n]) acc[n] = allDeps[n]; return acc; }, {})
|
|
@@ -91,12 +92,12 @@ class UpdateCommand extends BaseCommand {
|
|
|
91
92
|
|
|
92
93
|
this.logger.info(`Updating ${outdatedPkgs.length} package(s)...`);
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
const pkgArgs = outdatedPkgs.map(p => ({
|
|
96
|
+
name: p.Package,
|
|
97
|
+
version: p.Latest
|
|
98
|
+
}));
|
|
97
99
|
|
|
98
|
-
|
|
99
|
-
await installInstance.run(pkgArgs, flags);
|
|
100
|
+
await engine.install(pkgArgs, { flags });
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const BaseCommand = require('./base-command');
|
|
4
|
+
const Lockfile = require('../core/lockfile');
|
|
5
|
+
const PackageJSON = require('../core/package-json');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* WhyCommand handles the 'jpm why <pkg>' command.
|
|
9
|
+
* It traces dependency paths to explain why a package is installed.
|
|
10
|
+
*/
|
|
11
|
+
class WhyCommand extends BaseCommand {
|
|
12
|
+
constructor() {
|
|
13
|
+
super('why');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Executes the dependency tracing.
|
|
18
|
+
*
|
|
19
|
+
* @param {string[]} args - Package name to trace
|
|
20
|
+
* @param {Object} flags - CLI flags
|
|
21
|
+
* @returns {Promise<void>}
|
|
22
|
+
*/
|
|
23
|
+
async run(args, flags) {
|
|
24
|
+
if (!args.length) {
|
|
25
|
+
this.logger.error('Usage: jpm why <package>');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const target = args[0];
|
|
30
|
+
const cwd = process.cwd();
|
|
31
|
+
const lockfile = new Lockfile(cwd);
|
|
32
|
+
const pkgJson = PackageJSON.fromDir(cwd);
|
|
33
|
+
|
|
34
|
+
if (!lockfile.exists()) {
|
|
35
|
+
this.logger.error('No lockfile found. Run jpm install first.');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lockData = lockfile.allPackages();
|
|
40
|
+
// Index by name for easier lookup
|
|
41
|
+
const pkgMap = new Map();
|
|
42
|
+
for (const p of lockData) {
|
|
43
|
+
pkgMap.set(p.name, p);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const topLevel = pkgJson.allDeps();
|
|
47
|
+
const paths = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Recursively searches for the target package in the dependency tree.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} currentName - Current package being inspected
|
|
54
|
+
* @param {string[]} currentPath - Breadcrumb path to the current package
|
|
55
|
+
*/
|
|
56
|
+
const findPaths = (currentName, currentPath = []) => {
|
|
57
|
+
const pathKey = [...currentPath, currentName].join('>');
|
|
58
|
+
if (seen.has(pathKey)) return;
|
|
59
|
+
seen.add(pathKey);
|
|
60
|
+
|
|
61
|
+
if (currentName === target) {
|
|
62
|
+
paths.push([...currentPath, target]);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pkg = pkgMap.get(currentName);
|
|
67
|
+
if (!pkg || !pkg.dependencies) return;
|
|
68
|
+
|
|
69
|
+
for (const dep of Object.keys(pkg.dependencies)) {
|
|
70
|
+
// Generic cycle protection: don't revisit same package in current path
|
|
71
|
+
if (currentPath.includes(dep)) continue;
|
|
72
|
+
findPaths(dep, [...currentPath, currentName]);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
for (const root of Object.keys(topLevel)) {
|
|
77
|
+
findPaths(root, ['(project)']);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (paths.length === 0) {
|
|
81
|
+
this.logger.info(`Package "${this.logger.c.bold(target)}" is not required by any installed package.`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const c = this.logger.c;
|
|
86
|
+
this.logger.section(`Found ${paths.length} path(s) to ${c.bold(target)}:`);
|
|
87
|
+
|
|
88
|
+
for (const path of paths) {
|
|
89
|
+
const formatted = path.map((p, i) => {
|
|
90
|
+
if (p === '(project)') return c.gray(p);
|
|
91
|
+
if (p === target) return c.yellow(c.bold(p));
|
|
92
|
+
return c.cyan(p);
|
|
93
|
+
}).join(c.gray(' → '));
|
|
94
|
+
|
|
95
|
+
this.logger.log(` ${formatted}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = WhyCommand;
|
package/src/commands/x.js
CHANGED
|
@@ -101,20 +101,37 @@ class ExecCommand extends BaseCommand {
|
|
|
101
101
|
|
|
102
102
|
// Cleanup temp directory on success unless debugging
|
|
103
103
|
if (result.status === 0 && this.config.loglevel !== 'debug') {
|
|
104
|
-
|
|
104
|
+
this._cleanupTemp(tmpDir);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
process.exit(result.status ?? 0);
|
|
108
108
|
|
|
109
109
|
} catch (err) {
|
|
110
110
|
if (tmpDir && fs.existsSync(tmpDir) && this.config.loglevel !== 'debug') {
|
|
111
|
-
|
|
111
|
+
this._cleanupTemp(tmpDir);
|
|
112
112
|
}
|
|
113
113
|
resolveSpinner.fail(`Error: ${err.message}`);
|
|
114
114
|
throw err;
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Safely cleans up a temporary directory, handling Windows junctions and symlinks.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} tmpDir - Path to the temporary directory
|
|
122
|
+
* @private
|
|
123
|
+
*/
|
|
124
|
+
_cleanupTemp(tmpDir) {
|
|
125
|
+
try {
|
|
126
|
+
// On Windows, junctions inside node_modules can sometimes block rimraf
|
|
127
|
+
// if not handled carefully. Our internal rimraf handles recursive delete,
|
|
128
|
+
// but we add an extra safety check here.
|
|
129
|
+
rimraf(tmpDir);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
this.logger.debug(`Minor: Failed to cleanup temp directory ${tmpDir}: ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
118
135
|
/**
|
|
119
136
|
* Identifies the primary binary name from package metadata.
|
|
120
137
|
*
|
package/src/core/cache.js
CHANGED
|
@@ -54,6 +54,28 @@ class Cache {
|
|
|
54
54
|
return path.join(this.cacheRoot, safeName, `${version}.json`);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Initializes a SQLite database for metadata caching if running in Bun.
|
|
59
|
+
*
|
|
60
|
+
* @returns {Object|null} The SQLite database instance or null
|
|
61
|
+
* @private
|
|
62
|
+
*/
|
|
63
|
+
_getSQLite() {
|
|
64
|
+
if (typeof Bun === 'undefined') return null;
|
|
65
|
+
if (this._db) return this._db;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const { Database } = require('bun:sqlite');
|
|
69
|
+
const dbPath = path.join(this.cacheRoot, 'cache.sqlite');
|
|
70
|
+
mkdirp(this.cacheRoot);
|
|
71
|
+
this._db = new Database(dbPath);
|
|
72
|
+
this._db.run('CREATE TABLE IF NOT EXISTS metadata (id TEXT PRIMARY KEY, data TEXT)');
|
|
73
|
+
return this._db;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
/**
|
|
58
80
|
* Retrieves a package tarball from the cache if it exists.
|
|
59
81
|
*
|
|
@@ -94,6 +116,13 @@ class Cache {
|
|
|
94
116
|
* @returns {Promise<void>}
|
|
95
117
|
*/
|
|
96
118
|
async setMeta(name, version, meta) {
|
|
119
|
+
const db = this._getSQLite();
|
|
120
|
+
if (db) {
|
|
121
|
+
try {
|
|
122
|
+
db.run('INSERT OR REPLACE INTO metadata (id, data) VALUES (?, ?)', [`${name}@${version}`, JSON.stringify(meta)]);
|
|
123
|
+
} catch (e) { /* fallback */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
97
126
|
const p = this._metaPath(name, version);
|
|
98
127
|
mkdirp(path.dirname(p));
|
|
99
128
|
fs.writeFileSync(p, JSON.stringify(meta, null, 2), 'utf8');
|
|
@@ -107,6 +136,14 @@ class Cache {
|
|
|
107
136
|
* @returns {Promise<Object|null>} Decoded metadata object, or null if not found/invalid
|
|
108
137
|
*/
|
|
109
138
|
async getMeta(name, version) {
|
|
139
|
+
const db = this._getSQLite();
|
|
140
|
+
if (db) {
|
|
141
|
+
try {
|
|
142
|
+
const row = db.query('SELECT data FROM metadata WHERE id = ?').get(`${name}@${version}`);
|
|
143
|
+
if (row) return JSON.parse(row.data);
|
|
144
|
+
} catch (e) { /* fallback */ }
|
|
145
|
+
}
|
|
146
|
+
|
|
110
147
|
const p = this._metaPath(name, version);
|
|
111
148
|
try {
|
|
112
149
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const Resolver = require('./resolver');
|
|
5
|
+
const Installer = require('./installer');
|
|
6
|
+
const Lockfile = require('./lockfile');
|
|
7
|
+
const PackageJSON = require('./package-json');
|
|
8
|
+
const logger = require('../utils/logger');
|
|
9
|
+
const { Spinner } = require('../utils/progress');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Engine class coordinates the higher-level package management operations.
|
|
13
|
+
* It ties together resolution, installation, and persistence logic.
|
|
14
|
+
*/
|
|
15
|
+
class Engine {
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} projectRoot - The absolute path to the project root directory
|
|
18
|
+
*/
|
|
19
|
+
constructor(projectRoot) {
|
|
20
|
+
/** @type {string} */
|
|
21
|
+
this.projectRoot = projectRoot;
|
|
22
|
+
/** @type {PackageJSON} */
|
|
23
|
+
this.pkgJson = PackageJSON.fromDir(projectRoot);
|
|
24
|
+
/** @type {Lockfile} */
|
|
25
|
+
this.lockfile = new Lockfile(projectRoot);
|
|
26
|
+
/** @type {Installer} */
|
|
27
|
+
this.installer = new Installer(projectRoot);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Executes a full installation flow for a set of packages.
|
|
32
|
+
*
|
|
33
|
+
* @param {Array<{name: string, version: string}>} packages - Packages to install
|
|
34
|
+
* @param {Object} options - Installation options
|
|
35
|
+
* @param {boolean} [options.dev=false] - Whether to install as devDependencies
|
|
36
|
+
* @param {boolean} [options.exact=false] - Whether to save as exact versions
|
|
37
|
+
* @param {boolean} [options.noSave=false] - Whether to skip updating package.json
|
|
38
|
+
* @param {Object} [options.flags={}] - CLI flags
|
|
39
|
+
* @returns {Promise<Map<string, Object>>} The resolved package map
|
|
40
|
+
*/
|
|
41
|
+
async install(packages, options = {}) {
|
|
42
|
+
const { dev = false, exact = false, noSave = false, flags = {} } = options;
|
|
43
|
+
|
|
44
|
+
// 1. Resolve
|
|
45
|
+
const resolveSpinner = new Spinner(`Resolving ${packages.length} package(s)...`).start();
|
|
46
|
+
const resolver = new Resolver();
|
|
47
|
+
|
|
48
|
+
const deps = {};
|
|
49
|
+
const devDeps = {};
|
|
50
|
+
for (const { name, version } of packages) {
|
|
51
|
+
if (dev) devDeps[name] = version;
|
|
52
|
+
else deps[name] = version;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let resolvedMap;
|
|
56
|
+
try {
|
|
57
|
+
resolvedMap = await resolver.resolve(deps, devDeps, {}, (count) => {
|
|
58
|
+
resolveSpinner.text = `Resolving packages... (${count} resolved)`;
|
|
59
|
+
});
|
|
60
|
+
resolveSpinner.succeed(`Resolved ${resolvedMap.size} packages`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
resolveSpinner.fail(`Resolution failed: ${err.message}`);
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Audit for cycles
|
|
67
|
+
const circular = resolver.findCircular();
|
|
68
|
+
if (circular.length) {
|
|
69
|
+
logger.warn(`Circular dependencies detected:\n ${circular.join('\n ')}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Install
|
|
73
|
+
logger.info(`Installing ${resolvedMap.size} packages...`);
|
|
74
|
+
try {
|
|
75
|
+
await this.installer.installAll(resolvedMap, { dryRun: flags['dry-run'], flags });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logger.error(`Install failed: ${err.message}`);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. Persist Changes
|
|
82
|
+
if (!noSave && !flags['dry-run'] && packages.length) {
|
|
83
|
+
for (const { name } of packages) {
|
|
84
|
+
const resolved = [...resolvedMap.values()].find(m => m.name === name);
|
|
85
|
+
if (!resolved) continue;
|
|
86
|
+
this.pkgJson.addDependency(name, resolved.version, { dev, exact });
|
|
87
|
+
}
|
|
88
|
+
this.pkgJson.save();
|
|
89
|
+
logger.verbose('Updated package.json');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!flags['dry-run']) {
|
|
93
|
+
this.lockfile.update(resolvedMap).save();
|
|
94
|
+
logger.verbose('Updated jpm-lock.json');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return resolvedMap;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Performs a deterministic install from the lockfile.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} [flags={}] - CLI flags
|
|
104
|
+
* @returns {Promise<void>}
|
|
105
|
+
*/
|
|
106
|
+
async installFromLock(flags = {}) {
|
|
107
|
+
if (!this.lockfile.exists()) {
|
|
108
|
+
throw new Error('No lockfile found. Run jpm install first.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
logger.info('Using lock file for deterministic install');
|
|
112
|
+
const lockData = this.lockfile.allPackages();
|
|
113
|
+
const spinner = new Spinner('Installing from lock file...').start();
|
|
114
|
+
|
|
115
|
+
const fakeMap = new Map(lockData.map(p => [`${p.name}@${p.version}`, p]));
|
|
116
|
+
await this.installer.installAll(fakeMap, { dryRun: flags['dry-run'], flags });
|
|
117
|
+
|
|
118
|
+
spinner.succeed(`Installed ${lockData.length} packages`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = Engine;
|
package/src/core/installer.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
|
-
const tar = require('tar');
|
|
7
6
|
const registry = require('./registry');
|
|
8
7
|
const cache = require('./cache');
|
|
9
8
|
const { mkdirp, rimraf, tempDir, symlink } = require('../utils/fs');
|
|
@@ -170,28 +169,56 @@ class Installer {
|
|
|
170
169
|
}
|
|
171
170
|
|
|
172
171
|
/**
|
|
173
|
-
* Extracts a tarball
|
|
172
|
+
* Extracts a tarball's contents into a target directory.
|
|
173
|
+
* Prioritizes system 'tar' for speed and to minimize dependency overhead.
|
|
174
174
|
*
|
|
175
|
-
* @param {string} tgzPath -
|
|
175
|
+
* @param {string} tgzPath - Absolute path to the source .tgz file
|
|
176
176
|
* @param {string} destDir - Target directory for extraction
|
|
177
|
-
* @param {string} name - Package name for
|
|
178
|
-
* @param {string} version - Package version for
|
|
177
|
+
* @param {string} name - Package name for error context
|
|
178
|
+
* @param {string} version - Package version for error context
|
|
179
|
+
* @returns {Promise<void>}
|
|
179
180
|
* @protected
|
|
180
181
|
*/
|
|
181
182
|
async _extract(tgzPath, destDir, name, version) {
|
|
182
183
|
rimraf(destDir);
|
|
183
184
|
mkdirp(destDir);
|
|
184
185
|
|
|
185
|
-
|
|
186
|
+
try {
|
|
187
|
+
// Attempt to use system 'tar' (available on Linux, macOS, and Win 10+)
|
|
188
|
+
const { spawnSync } = require('node:child_process');
|
|
189
|
+
const result = spawnSync('tar', [
|
|
190
|
+
'-xzf', tgzPath,
|
|
191
|
+
'-C', destDir,
|
|
192
|
+
'--strip-components=1'
|
|
193
|
+
], { shell: true, stdio: 'pipe' });
|
|
194
|
+
|
|
195
|
+
if (result.status === 0) {
|
|
196
|
+
logger.verbose(`extracted ${name}@${version} via system tar`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
logger.debug(`System tar failed (code ${result.status}): ${result.stderr.toString()}`);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
logger.debug(`System tar not found or failed: ${err.message}`);
|
|
202
|
+
}
|
|
186
203
|
|
|
204
|
+
// Fallback to JS-based 'tar' package
|
|
205
|
+
logger.verbose(`falling back to 'tar' package for ${name}@${version}`);
|
|
206
|
+
let tar;
|
|
207
|
+
try {
|
|
208
|
+
tar = require('tar');
|
|
209
|
+
} catch (err) {
|
|
210
|
+
throw new Error(`Extraction failed: system 'tar' not available and 'tar' package not installed. ${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const absoluteDest = path.resolve(destDir);
|
|
187
214
|
await tar.extract({
|
|
188
215
|
file: tgzPath,
|
|
189
216
|
cwd: destDir,
|
|
190
217
|
strip: 1,
|
|
191
|
-
filter: (p
|
|
218
|
+
filter: (p) => {
|
|
192
219
|
const fullPath = path.resolve(destDir, p);
|
|
193
220
|
if (!fullPath.startsWith(absoluteDest)) {
|
|
194
|
-
logger.error(`Zip Slip security violation blocked
|
|
221
|
+
logger.error(`Zip Slip security violation blocked in ${name}@${version}: ${p}`);
|
|
195
222
|
return false;
|
|
196
223
|
}
|
|
197
224
|
return true;
|
|
@@ -260,6 +287,7 @@ class Installer {
|
|
|
260
287
|
* Creates symbolic links for binary executables defined in package metadata.
|
|
261
288
|
*
|
|
262
289
|
* @param {Object[]} packages - Array of resolved package metadata
|
|
290
|
+
* @returns {Promise<void>}
|
|
263
291
|
* @protected
|
|
264
292
|
*/
|
|
265
293
|
async _linkBins(packages) {
|
|
@@ -285,7 +313,9 @@ class Installer {
|
|
|
285
313
|
try {
|
|
286
314
|
symlink(src, dest);
|
|
287
315
|
fs.chmodSync(src, 0o755);
|
|
288
|
-
} catch {
|
|
316
|
+
} catch (err) {
|
|
317
|
+
logger.warn(`Failed to link binary "${binName}" for ${installName}: ${err.message}`);
|
|
318
|
+
}
|
|
289
319
|
}
|
|
290
320
|
}
|
|
291
321
|
}
|
package/src/core/registry.js
CHANGED
|
@@ -149,6 +149,7 @@ class Registry {
|
|
|
149
149
|
*
|
|
150
150
|
* @param {Object.<string, string[]>} requires - Map of package names to lists of required versions
|
|
151
151
|
* @returns {Promise<Object>} Audit report containing vulnerabilities, advisories, and metadata
|
|
152
|
+
* @throws {Error} If the registry request fails or returns an invalid response
|
|
152
153
|
*/
|
|
153
154
|
async fetchAdvisories(requires) {
|
|
154
155
|
const url = `https://registry.npmjs.org/-/npm/v1/security/audits/quick`;
|
|
@@ -167,22 +168,29 @@ class Registry {
|
|
|
167
168
|
dependencies: packages,
|
|
168
169
|
});
|
|
169
170
|
|
|
170
|
-
const res = await request(url, {
|
|
171
|
-
method: 'POST',
|
|
172
|
-
headers: {
|
|
173
|
-
'Content-Type': 'application/json',
|
|
174
|
-
'Content-Length': Buffer.byteLength(body),
|
|
175
|
-
},
|
|
176
|
-
body,
|
|
177
|
-
timeout: 30_000,
|
|
178
|
-
retries: 2,
|
|
179
|
-
strict: true, // Security: Always use HTTPS for audits
|
|
180
|
-
});
|
|
181
|
-
|
|
182
171
|
try {
|
|
172
|
+
const res = await request(url, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
'Content-Length': Buffer.byteLength(body),
|
|
177
|
+
},
|
|
178
|
+
body,
|
|
179
|
+
timeout: 30_000,
|
|
180
|
+
retries: 2,
|
|
181
|
+
strict: true, // Security: Always use HTTPS for audits
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (res.status < 200 || res.status >= 300) {
|
|
185
|
+
throw new Error(`Registry audit failed with status ${res.status}: ${res.body.slice(0, 200)}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
183
188
|
return JSON.parse(res.body);
|
|
184
|
-
} catch {
|
|
185
|
-
|
|
189
|
+
} catch (err) {
|
|
190
|
+
logger.error(`Security audit request failed: ${err.message}`);
|
|
191
|
+
// Return empty structure to allow the process to continue if non-critical,
|
|
192
|
+
// but log the error clearly.
|
|
193
|
+
return { advisories: {}, metadata: {}, error: err.message };
|
|
186
194
|
}
|
|
187
195
|
}
|
|
188
196
|
}
|
package/src/utils/http.js
CHANGED
|
@@ -38,10 +38,39 @@ function checkBreaker() {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* options: { method, headers, timeout, retries, retryDelay, stream }
|
|
42
|
-
* Returns: { status, headers, body } or a raw IncomingMessage if stream:true
|
|
41
|
+
* options: { method, headers, timeout, retries, retryDelay, stream, body, strict }
|
|
42
|
+
* Returns: { status, headers, body } or a raw IncomingMessage if stream:true.
|
|
43
|
+
* Uses Bun.fetch if running in a Bun environment for optimized performance.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} url - Target URL
|
|
46
|
+
* @param {Object} [options={}] - Request options
|
|
47
|
+
* @returns {Promise<Object|import('node:http').IncomingMessage>}
|
|
43
48
|
*/
|
|
44
|
-
function request(url, options = {}) {
|
|
49
|
+
async function request(url, options = {}) {
|
|
50
|
+
// ── Native Bun Optimization ──────────────────────────────────────────────
|
|
51
|
+
if (typeof Bun !== 'undefined' && !options.stream) {
|
|
52
|
+
try {
|
|
53
|
+
const res = await Bun.fetch(url, {
|
|
54
|
+
method: options.method || 'GET',
|
|
55
|
+
headers: {
|
|
56
|
+
'User-Agent': USER_AGENT,
|
|
57
|
+
'Accept-Encoding': 'gzip, deflate',
|
|
58
|
+
'Accept': 'application/json',
|
|
59
|
+
...(options.headers || {}),
|
|
60
|
+
},
|
|
61
|
+
body: options.body,
|
|
62
|
+
redirect: 'follow',
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
status: res.status,
|
|
66
|
+
headers: Object.fromEntries(res.headers.entries()),
|
|
67
|
+
body: await res.text(),
|
|
68
|
+
};
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logger.debug(`Bun.fetch failed, falling back to node:http: ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
const {
|
|
46
75
|
method = 'GET',
|
|
47
76
|
headers = {},
|
|
@@ -78,6 +107,12 @@ function request(url, options = {}) {
|
|
|
78
107
|
},
|
|
79
108
|
};
|
|
80
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Executes a single request attempt with retries and circuit breaker logic.
|
|
112
|
+
*
|
|
113
|
+
* @param {number} attemptsLeft - Remaining retry attempts
|
|
114
|
+
* @returns {Promise<Object|import('node:http').IncomingMessage>}
|
|
115
|
+
*/
|
|
81
116
|
function attempt(attemptsLeft) {
|
|
82
117
|
return new Promise((resolve, reject) => {
|
|
83
118
|
const req = lib.request(reqOptions, (res) => {
|
|
@@ -158,7 +193,9 @@ function request(url, options = {}) {
|
|
|
158
193
|
* @returns {Promise<Object>} Parsed JSON object
|
|
159
194
|
*/
|
|
160
195
|
async function getJSON(url, opts = {}) {
|
|
161
|
-
const
|
|
196
|
+
const res = await request(url, { ...opts, headers: { Accept: 'application/json', ...(opts.headers || {}) } });
|
|
197
|
+
const { status, body } = res;
|
|
198
|
+
|
|
162
199
|
if (status < 200 || status >= 300) {
|
|
163
200
|
const err = new Error(`HTTP ${status}: ${url}`);
|
|
164
201
|
err.status = status;
|
|
@@ -166,7 +203,7 @@ async function getJSON(url, opts = {}) {
|
|
|
166
203
|
throw err;
|
|
167
204
|
}
|
|
168
205
|
try {
|
|
169
|
-
return JSON.parse(body);
|
|
206
|
+
return typeof body === 'object' ? body : JSON.parse(body);
|
|
170
207
|
} catch (e) {
|
|
171
208
|
throw new Error(`Invalid JSON from ${url}: ${e.message}\nBody snippet: ${body.slice(0, 200)}`);
|
|
172
209
|
}
|
|
@@ -206,27 +243,4 @@ async function download(url, destStream, opts = {}) {
|
|
|
206
243
|
});
|
|
207
244
|
}
|
|
208
245
|
|
|
209
|
-
|
|
210
|
-
* Retrieves a list of all packages currently stored in the local cache.
|
|
211
|
-
*
|
|
212
|
-
* @returns {{ name: string, version: string }[]}
|
|
213
|
-
*/
|
|
214
|
-
function list() {
|
|
215
|
-
const root = cacheRoot();
|
|
216
|
-
if (!fs.existsSync(root)) return [];
|
|
217
|
-
const result = [];
|
|
218
|
-
for (const scopeOrName of fs.readdirSync(root)) {
|
|
219
|
-
const dir = path.join(root, scopeOrName);
|
|
220
|
-
if (!fs.statSync(dir).isDirectory()) continue;
|
|
221
|
-
for (const file of fs.readdirSync(dir)) {
|
|
222
|
-
if (file.endsWith('.tgz')) {
|
|
223
|
-
const version = file.replace('.tgz', '');
|
|
224
|
-
const name = scopeOrName.replace('__SCOPE__', '/');
|
|
225
|
-
result.push({ name, version });
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return result;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
module.exports = { request, getJSON, download, list };
|
|
246
|
+
module.exports = { request, getJSON, download };
|