jpm-pkg 1.0.3 → 1.0.5

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/src/commands/x.js CHANGED
@@ -1,136 +1,174 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
3
4
  const path = require('node:path');
4
5
  const { spawnSync } = require('node:child_process');
6
+ const BaseCommand = require('./base-command');
5
7
  const Resolver = require('../core/resolver');
6
8
  const Installer = require('../core/installer');
7
9
  const { tempDir, rimraf } = require('../utils/fs');
8
10
  const { Spinner } = require('../utils/progress');
9
- const config = require('../utils/config');
10
- const logger = require('../utils/logger');
11
11
 
12
12
  /**
13
- * jpm x <package>[@version] [args...]
14
- *
15
- * Equivalent to npx. Resolves, installs to a temp dir, and executes.
13
+ * ExecCommand handles the 'jpm x' and 'jpm exec' commands (equivalent to npx).
14
+ * It resolves a package, installs it to a temporary directory, and executes its binary.
16
15
  */
17
- module.exports = async function xCommand(args, flags) {
18
- if (!args.length) {
19
- logger.error('No package specified to execute.');
20
- logger.info('Usage: jpm x <package>[@version] [args...]');
21
- process.exit(1);
16
+ class ExecCommand extends BaseCommand {
17
+ constructor() {
18
+ super('x');
22
19
  }
23
20
 
24
- const pkgArg = args[0];
25
- const execArgs = args.slice(1);
26
-
27
- const lastAt = pkgArg.lastIndexOf('@');
28
- // Handle scoped packages correctly: @scope/pkg@version
29
- const hasVersion = lastAt > 0 && !pkgArg.startsWith('@') || (pkgArg.startsWith('@') && pkgArg.split('@').length > 2);
30
-
31
- let name, range;
32
- if (pkgArg.startsWith('@')) {
33
- const parts = pkgArg.split('@');
34
- name = '@' + parts[1];
35
- range = parts[2] || 'latest';
36
- } else {
37
- name = hasVersion ? pkgArg.slice(0, lastAt) : pkgArg;
38
- range = hasVersion ? pkgArg.slice(lastAt + 1) : 'latest';
39
- }
21
+ /**
22
+ * Executes the specified package binary.
23
+ *
24
+ * @param {string[]} args - Package name and arguments for the binary
25
+ * @param {Object} flags - CLI flags
26
+ * @returns {Promise<void>}
27
+ */
28
+ async run(args, flags) {
29
+ if (!args.length) {
30
+ this.logger.error('No package specified to execute.');
31
+ this.logger.info('Usage: jpm x <package>[@version] [args...]');
32
+ throw new Error('No package specified.');
33
+ }
40
34
 
41
- const resolveSpinner = new Spinner(`Resolving ${name}@${range}...`).start();
35
+ const pkgArg = args[0];
36
+ const execArgs = args.slice(1);
42
37
 
43
- try {
44
- // 1. Resolve
45
- const resolver = new Resolver();
46
- const resolvedMap = await resolver.resolve({ [name]: range }, {}, {}, (count) => {
47
- resolveSpinner.text = `Resolving... (${count} resolved)`;
48
- });
49
- resolveSpinner.succeed(`Resolved ${resolvedMap.size} packages`);
38
+ // Parse name and range from argument (handling scoped packages)
39
+ let name, range;
40
+ const lastAt = pkgArg.lastIndexOf('@');
41
+ const isScoped = pkgArg.startsWith('@');
42
+ const hasVersion = lastAt > 0 && !(isScoped && pkgArg.indexOf('@', 1) === -1);
50
43
 
51
- // 2. Install to temp
52
- const tmp = tempDir('jpm-x-');
53
- const installer = new Installer(tmp);
44
+ if (isScoped) {
45
+ const parts = pkgArg.split('@');
46
+ name = '@' + parts[1];
47
+ range = parts[2] || 'latest';
48
+ } else {
49
+ name = hasVersion ? pkgArg.slice(0, lastAt) : pkgArg;
50
+ range = hasVersion ? pkgArg.slice(lastAt + 1) : 'latest';
51
+ }
54
52
 
55
- logger.info(`Installing to temporary directory...`);
56
- await installer.installAll(resolvedMap, { flags });
53
+ const resolveSpinner = new Spinner(`Resolving ${name}@${range}...`).start();
57
54
 
58
- // 3. Find the binary
59
- const resolvedPkg = [...resolvedMap.values()].find(m => m.name === name);
60
- if (!resolvedPkg) throw new Error(`Failed to find resolved metadata for ${name}`);
55
+ let tmpDir;
56
+ try {
57
+ // 1. Resolve dependencies
58
+ const resolver = new Resolver();
59
+ const resolvedMap = await resolver.resolve({ [name]: range }, {}, {}, (count) => {
60
+ resolveSpinner.text = `Resolving... (${count} resolved)`;
61
+ });
62
+ resolveSpinner.succeed(`Resolved ${resolvedMap.size} packages`);
61
63
 
62
- const bins = resolvedPkg.bin;
63
- let binName;
64
+ // 2. Install to temporary directory
65
+ tmpDir = tempDir('jpm-x-');
66
+ const installer = new Installer(tmpDir);
64
67
 
65
- if (typeof bins === 'string') {
66
- binName = name.split('/').pop();
67
- } else if (typeof bins === 'object' && bins !== null) {
68
- const entries = Object.keys(bins);
69
- if (entries.length === 0) throw new Error(`Package ${name} has no binaries.`);
70
- binName = entries.find(k => k === name || k === name.split('/').pop()) || entries[0];
71
- } else {
72
- throw new Error(`Package ${name} does not define any binaries in package.json`);
73
- }
68
+ this.logger.info(`Installing to temporary directory...`);
69
+ await installer.installAll(resolvedMap, { flags });
74
70
 
75
- const binPath = path.join(tmp, 'node_modules', '.bin', binName + (process.platform === 'win32' ? '.cmd' : ''));
71
+ // 3. Locate and execute binary
72
+ const resolvedPkg = [...resolvedMap.values()].find(m => m.name === name);
73
+ if (!resolvedPkg) {
74
+ throw new Error(`Failed to find resolved metadata for ${name}`);
75
+ }
76
76
 
77
- // If the .cmd doesn't exist, try the raw JS file with node
78
- let finalBin = binPath;
79
- let finalArgs = execArgs;
80
- let useNode = false;
77
+ const binaryInfo = this._resolveBinary(name, resolvedPkg.bin);
78
+ const binaryPath = path.join(tmpDir, 'node_modules', '.bin', binaryInfo.name + (process.platform === 'win32' ? '.cmd' : ''));
81
79
 
82
- const fs = require('node:fs');
83
- if (!fs.existsSync(binPath)) {
84
- // Fallback to finding the JS file
85
- const relPath = typeof bins === 'string' ? bins : bins[binName];
86
- finalBin = path.join(tmp, 'node_modules', name, relPath);
87
- useNode = true;
88
- }
80
+ let finalExecutable = binaryPath;
81
+ let finalArgs = execArgs;
82
+ let useNode = false;
83
+
84
+ if (!fs.existsSync(binaryPath)) {
85
+ // Fallback: search for the JS file defined in package.json bin field
86
+ const relPath = typeof resolvedPkg.bin === 'string' ? resolvedPkg.bin : resolvedPkg.bin[binaryInfo.name];
87
+ finalExecutable = path.join(tmpDir, 'node_modules', name, relPath);
88
+ useNode = true;
89
+ }
90
+
91
+ this.logger.info(`Executing ${binaryInfo.name}...\n`);
92
+
93
+ const env = {
94
+ ...process.env,
95
+ PATH: `${path.join(tmpDir, 'node_modules', '.bin')}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}`,
96
+ };
89
97
 
90
- logger.info(`Executing ${binName}...\n`);
98
+ const result = this._spawnProcess(finalExecutable, finalArgs, useNode, env);
91
99
 
92
- const spawnCmd = useNode ? process.execPath : finalBin;
93
- const spawnArgs = useNode ? [finalBin, ...execArgs] : execArgs;
100
+ if (result.error) throw result.error;
94
101
 
95
- const env = {
96
- ...process.env,
97
- PATH: `${path.join(tmp, 'node_modules', '.bin')}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH}`,
98
- };
102
+ // Cleanup temp directory on success unless debugging
103
+ if (result.status === 0 && this.config.loglevel !== 'debug') {
104
+ rimraf(tmpDir);
105
+ }
99
106
 
100
- // On Windows, when shell: true is used, we need to be very careful with quoting
101
- // if the path to node or the binary contains spaces (like C:\Program Files\...)
107
+ process.exit(result.status ?? 0);
108
+
109
+ } catch (err) {
110
+ if (tmpDir && fs.existsSync(tmpDir) && this.config.loglevel !== 'debug') {
111
+ rimraf(tmpDir);
112
+ }
113
+ resolveSpinner.fail(`Error: ${err.message}`);
114
+ throw err;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Identifies the primary binary name from package metadata.
120
+ *
121
+ * @param {string} pkgName - Package name
122
+ * @param {string|Object} binField - The 'bin' field from package.json
123
+ * @returns {Object} Binary name and relative path
124
+ * @private
125
+ */
126
+ _resolveBinary(pkgName, binField) {
127
+ let name;
128
+ if (typeof binField === 'string') {
129
+ name = pkgName.split('/').pop();
130
+ return { name, path: binField };
131
+ } else if (typeof binField === 'object' && binField !== null) {
132
+ const entries = Object.keys(binField);
133
+ if (entries.length === 0) throw new Error(`Package ${pkgName} has no binaries.`);
134
+ name = entries.find(k => k === pkgName || k === pkgName.split('/').pop()) || entries[0];
135
+ return { name, path: binField[name] };
136
+ }
137
+ throw new Error(`Package ${pkgName} defines no binaries.`);
138
+ }
139
+
140
+ /**
141
+ * Spawns the execution process with cross-platform considerations.
142
+ *
143
+ * @param {string} bin - Binary/Executable path
144
+ * @param {string[]} args - Arguments
145
+ * @param {boolean} useNode - Whether to execute via the node binary
146
+ * @param {Object} env - Environment variables
147
+ * @returns {import('node:child_process').SpawnSyncReturns<Buffer>}
148
+ * @private
149
+ */
150
+ _spawnProcess(bin, args, useNode, env) {
102
151
  const isWin = process.platform === 'win32';
103
152
 
104
- let result;
105
153
  if (isWin && useNode) {
106
- // Special handling for Windows + Node to avoid quoting hell with shell: true
107
- result = spawnSync(`"${process.execPath}"`, [`"${finalBin}"`, ...execArgs.map(a => `"${a}"`)], {
154
+ // Windows specific: wrap in quotes and use verbatim arguments for reliability
155
+ return spawnSync(`"${process.execPath}"`, [`"${bin}"`, ...args.map(a => `"${a}"`)], {
108
156
  cwd: process.cwd(),
109
157
  stdio: 'inherit',
110
158
  env,
111
159
  shell: true,
112
160
  windowsVerbatimArguments: true
113
161
  });
114
- } else {
115
- result = spawnSync(useNode ? process.execPath : finalBin, useNode ? [finalBin, ...execArgs] : execArgs, {
116
- cwd: process.cwd(),
117
- stdio: 'inherit',
118
- env,
119
- shell: true
120
- });
121
162
  }
122
163
 
123
- if (result.error) throw result.error;
124
-
125
- // Cleanup on success if not in debug
126
- if (result.status === 0 && config.loglevel !== 'debug') {
127
- rimraf(tmp);
128
- }
164
+ return spawnSync(useNode ? process.execPath : bin, useNode ? [bin, ...args] : args, {
165
+ cwd: process.cwd(),
166
+ stdio: 'inherit',
167
+ env,
168
+ shell: true
169
+ });
170
+ }
171
+ }
129
172
 
130
- process.exit(result.status ?? 0);
173
+ module.exports = ExecCommand;
131
174
 
132
- } catch (err) {
133
- resolveSpinner.fail(`Error: ${err.message}`);
134
- process.exit(1);
135
- }
136
- };
package/src/core/cache.js CHANGED
@@ -2,116 +2,217 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
- const crypto = require('node:crypto');
6
5
  const config = require('../utils/config');
7
6
  const { mkdirp, rimraf } = require('../utils/fs');
8
7
  const logger = require('../utils/logger');
9
8
 
10
9
  /**
11
- * Disk cache at ~/.jpm/cache/<name>/<version>.tgz
12
- * Also stores metadata JSON alongside: <name>/<version>.json
10
+ * Cache class handles persistent disk caching of package tarballs and metadata.
11
+ * Packages are stored at `<cacheDir>/<name>/<version>.tgz`.
12
+ * Scoped packages handle '@' by replacing '/' with '__SCOPE__'.
13
13
  */
14
+ class Cache {
15
+ /**
16
+ * Creates an instance of the Cache.
17
+ * @param {Object} [options={}] - Configuration options for the cache
18
+ */
19
+ constructor(options = {}) {
20
+ this._config = options.config || config;
21
+ }
14
22
 
15
- function cacheRoot() {
16
- return config.cacheDir;
17
- }
18
-
19
- function tgzPath(name, version) {
20
- const safeName = name.replace('/', '__SCOPE__');
21
- return path.join(cacheRoot(), safeName, `${version}.tgz`);
22
- }
23
+ /**
24
+ * Returns the root directory for the cache.
25
+ * @returns {string} Absolute path to the cache directory
26
+ */
27
+ get cacheRoot() {
28
+ return this._config.cacheDir;
29
+ }
23
30
 
24
- function metaPath(name, version) {
25
- const safeName = name.replace('/', '__SCOPE__');
26
- return path.join(cacheRoot(), safeName, `${version}.json`);
27
- }
31
+ /**
32
+ * Resolves the absolute path for a package tarball in the cache.
33
+ *
34
+ * @param {string} name - The package name
35
+ * @param {string} version - The package version
36
+ * @returns {string} Absolute path to the .tgz file
37
+ * @private
38
+ */
39
+ _tgzPath(name, version) {
40
+ const safeName = name.replace('/', '__SCOPE__');
41
+ return path.join(this.cacheRoot, safeName, `${version}.tgz`);
42
+ }
28
43
 
29
- async function get(name, version) {
30
- const p = tgzPath(name, version);
31
- if (fs.existsSync(p)) {
32
- logger.verbose(`cache hit ${name}@${version}`);
33
- return p;
44
+ /**
45
+ * Resolves the absolute path for a package metadata JSON in the cache.
46
+ *
47
+ * @param {string} name - The package name
48
+ * @param {string} version - The package version
49
+ * @returns {string} Absolute path to the .json file
50
+ * @private
51
+ */
52
+ _metaPath(name, version) {
53
+ const safeName = name.replace('/', '__SCOPE__');
54
+ return path.join(this.cacheRoot, safeName, `${version}.json`);
34
55
  }
35
- return null;
36
- }
37
56
 
38
- async function set(name, version, srcTgz) {
39
- const dest = tgzPath(name, version);
40
- mkdirp(path.dirname(dest));
41
- fs.copyFileSync(srcTgz, dest);
42
- logger.verbose(`cache store ${name}@${version}`);
43
- }
57
+ /**
58
+ * Retrieves a package tarball from the cache if it exists.
59
+ *
60
+ * @param {string} name - Package name
61
+ * @param {string} version - Package version
62
+ * @returns {Promise<string|null>} Path to the cached tarball, or null if not found
63
+ */
64
+ async get(name, version) {
65
+ const p = this._tgzPath(name, version);
66
+ if (fs.existsSync(p)) {
67
+ logger.verbose(`cache hit ${name}@${version}`);
68
+ return p;
69
+ }
70
+ return null;
71
+ }
44
72
 
45
- async function setMeta(name, version, meta) {
46
- const p = metaPath(name, version);
47
- mkdirp(path.dirname(p));
48
- fs.writeFileSync(p, JSON.stringify(meta, null, 2), 'utf8');
49
- }
73
+ /**
74
+ * Stores a package tarball in the cache.
75
+ *
76
+ * @param {string} name - Package name
77
+ * @param {string} version - Package version
78
+ * @param {string} srcTgz - Path to the source tarball to be copied
79
+ * @returns {Promise<void>}
80
+ */
81
+ async set(name, version, srcTgz) {
82
+ const dest = this._tgzPath(name, version);
83
+ mkdirp(path.dirname(dest));
84
+ fs.copyFileSync(srcTgz, dest);
85
+ logger.verbose(`cache store ${name}@${version}`);
86
+ }
50
87
 
51
- async function getMeta(name, version) {
52
- const p = metaPath(name, version);
53
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
54
- catch { return null; }
55
- }
88
+ /**
89
+ * Stores package metadata in the cache.
90
+ *
91
+ * @param {string} name - Package name
92
+ * @param {string} version - Package version
93
+ * @param {Object} meta - Metadata object to be serialized
94
+ * @returns {Promise<void>}
95
+ */
96
+ async setMeta(name, version, meta) {
97
+ const p = this._metaPath(name, version);
98
+ mkdirp(path.dirname(p));
99
+ fs.writeFileSync(p, JSON.stringify(meta, null, 2), 'utf8');
100
+ }
56
101
 
57
- function has(name, version) {
58
- return fs.existsSync(tgzPath(name, version));
59
- }
102
+ /**
103
+ * Retrieves package metadata from the cache.
104
+ *
105
+ * @param {string} name - Package name
106
+ * @param {string} version - Package version
107
+ * @returns {Promise<Object|null>} Decoded metadata object, or null if not found/invalid
108
+ */
109
+ async getMeta(name, version) {
110
+ const p = this._metaPath(name, version);
111
+ try {
112
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
60
117
 
61
- function clear(name, version) {
62
- if (name && version) {
63
- rimraf(tgzPath(name, version));
64
- rimraf(metaPath(name, version));
65
- } else if (name) {
66
- const safeName = name.replace('/', '__SCOPE__');
67
- rimraf(path.join(cacheRoot(), safeName));
68
- } else {
69
- // Clear entire cache
70
- rimraf(cacheRoot());
71
- logger.success('Cache cleared');
118
+ /**
119
+ * Checks if a package version exists in the cache.
120
+ *
121
+ * @param {string} name - Package name
122
+ * @param {string} version - Package version
123
+ * @returns {boolean} True if the tarball exists in cache
124
+ */
125
+ has(name, version) {
126
+ return fs.existsSync(this._tgzPath(name, version));
72
127
  }
73
- }
74
128
 
75
- function stats() {
76
- const root = cacheRoot();
77
- if (!fs.existsSync(root)) return { packages: 0, size: 0 };
78
-
79
- let packages = 0;
80
- let size = 0;
81
-
82
- function walk(dir) {
83
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
84
- const full = path.join(dir, entry.name);
85
- if (entry.isDirectory()) {
86
- walk(full);
87
- } else {
88
- const s = fs.statSync(full);
89
- size += s.size;
90
- if (entry.name.endsWith('.tgz')) packages++;
91
- }
129
+ /**
130
+ * Clears specific items or the entire cache.
131
+ *
132
+ * @param {string} [name] - Optional package name to clear
133
+ * @param {string} [version] - Optional version to clear (requires name)
134
+ */
135
+ clear(name, version) {
136
+ if (name && version) {
137
+ rimraf(this._tgzPath(name, version));
138
+ rimraf(this._metaPath(name, version));
139
+ } else if (name) {
140
+ const safeName = name.replace('/', '__SCOPE__');
141
+ rimraf(path.join(this.cacheRoot, safeName));
142
+ } else {
143
+ // Clear entire cache
144
+ rimraf(this.cacheRoot);
145
+ logger.success('Cache cleared');
92
146
  }
93
147
  }
94
148
 
95
- try { walk(root); } catch { }
96
- return { packages, size, root };
97
- }
149
+ /**
150
+ * Calculates cache statistics (total packages and disk usage).
151
+ *
152
+ * @returns {Object} { packages: number, size: number, root: string }
153
+ */
154
+ stats() {
155
+ const root = this.cacheRoot;
156
+ if (!fs.existsSync(root)) return { packages: 0, size: 0, root };
157
+
158
+ let packages = 0;
159
+ let size = 0;
160
+
161
+ const walk = (dir) => {
162
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
163
+ const full = path.join(dir, entry.name);
164
+ if (entry.isDirectory()) {
165
+ walk(full);
166
+ } else {
167
+ const s = fs.statSync(full);
168
+ size += s.size;
169
+ if (entry.name.endsWith('.tgz')) packages++;
170
+ }
171
+ }
172
+ };
98
173
 
99
- function list() {
100
- const root = cacheRoot();
101
- if (!fs.existsSync(root)) return [];
102
- const result = [];
103
- for (const scopeOrName of fs.readdirSync(root)) {
104
- const dir = path.join(root, scopeOrName);
105
- if (!fs.statSync(dir).isDirectory()) continue;
106
- for (const file of fs.readdirSync(dir)) {
107
- if (file.endsWith('.tgz')) {
108
- const version = file.replace('.tgz', '');
109
- const name = scopeOrName.replace('__SCOPE__', '/');
110
- result.push({ name, version });
174
+ try {
175
+ walk(root);
176
+ } catch { }
177
+ return { packages, size, root };
178
+ }
179
+
180
+ /**
181
+ * Lists all packages and versions currently in the cache.
182
+ *
183
+ * @returns {{ name: string, version: string }[]} Array of package descriptors
184
+ */
185
+ list() {
186
+ const root = this.cacheRoot;
187
+ if (!fs.existsSync(root)) return [];
188
+ const result = [];
189
+ for (const scopeOrName of fs.readdirSync(root)) {
190
+ const dir = path.join(root, scopeOrName);
191
+ if (!fs.statSync(dir).isDirectory()) continue;
192
+ for (const file of fs.readdirSync(dir)) {
193
+ if (file.endsWith('.tgz')) {
194
+ const version = file.replace('.tgz', '');
195
+ const name = scopeOrName.replace('__SCOPE__', '/');
196
+ result.push({ name, version });
197
+ }
111
198
  }
112
199
  }
200
+ return result;
113
201
  }
114
- return result;
115
202
  }
116
203
 
117
- module.exports = { get, set, getMeta, setMeta, has, clear, stats, list };
204
+ // Singleton instance for backward compatibility
205
+ const defaultCache = new Cache();
206
+
207
+ module.exports = {
208
+ Cache,
209
+ get: defaultCache.get.bind(defaultCache),
210
+ set: defaultCache.set.bind(defaultCache),
211
+ getMeta: defaultCache.getMeta.bind(defaultCache),
212
+ setMeta: defaultCache.setMeta.bind(defaultCache),
213
+ has: defaultCache.has.bind(defaultCache),
214
+ clear: defaultCache.clear.bind(defaultCache),
215
+ stats: defaultCache.stats.bind(defaultCache),
216
+ list: defaultCache.list.bind(defaultCache),
217
+ };
218
+