@lionad/safe-npx 0.1.0 → 0.2.0

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.
Files changed (3) hide show
  1. package/README.md +34 -15
  2. package/package.json +1 -1
  3. package/snpx.js +227 -79
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # safe-npx (snpx)
2
2
 
3
- Safe npx wrapper - lock to latest-1 version with 24h cache.
3
+ Safe npx wrapper with configurable time-based fallback strategy.
4
4
 
5
5
  ## Why
6
6
 
7
- `npx -y pkg@latest` installs the bleeding edge. If that version was just compromised in a supply chain attack, you get owned immediately. **snpx** installs the version *before* latest, and only if it's at least 24 hours old. This gives the security community time to catch malicious releases.
7
+ `npx -y pkg@latest` installs the bleeding edge. If that version was just compromised in a supply chain attack, you get owned immediately. **snpx** intercepts `@latest` (and bare package names) and resolves a safe version based on publish age and a configurable fallback strategy. This gives the security community time to catch malicious releases.
8
8
 
9
9
  ## Install
10
10
 
@@ -22,37 +22,56 @@ snpx -y create-react-app@latest my-app
22
22
 
23
23
  # Works with scoped packages too
24
24
  snpx -y @vue/cli@latest create my-project
25
+
26
+ # Bare package names are also intercepted
27
+ snpx -y cowsay "Hello World"
25
28
  ```
26
29
 
27
- ### Self-update check
30
+ ## How it works
31
+
32
+ 1. Intercepts calls containing `@latest` and bare package names
33
+ 2. Queries npm registry for the package
34
+ 3. If `latest` is older than the safety window (default 24h), uses `latest`
35
+ 4. Otherwise, falls back through the configured strategy:
36
+ - `patch` = version published immediately before `latest`
37
+ - `minor` = most recently published version of the previous minor line
38
+ - `major` = most recently published version of the previous major line
39
+ 5. Verifies the fallback version is also older than the safety window
40
+ 6. Caches the resolved version for the duration of the safety window
41
+ 7. Executes `npx pkg@resolved_version ...`
42
+
43
+ ## Options
28
44
 
29
45
  ```bash
46
+ # Configure safety window (hours)
47
+ snpx --time 48 cowsay@latest
48
+
49
+ # Configure fallback strategy (left-to-right precedence)
50
+ snpx --fallback-strategy patch,minor,major cowsay@latest
51
+
52
+ # Print resolved version without executing
53
+ snpx --show-version cowsay@latest
54
+
30
55
  # Check for snpx updates (safe mode - respects 24h window)
31
56
  snpx --self-update
32
57
 
33
- # Bypass safety window (not recommended)
58
+ # Bypass safety window for self-update check (not recommended)
34
59
  snpx --unsafe-self-update
35
60
 
36
61
  # Show help
37
62
  snpx --help
38
63
  ```
39
64
 
40
- ## How it works
41
-
42
- 1. Intercepts calls containing `@latest`
43
- 2. Queries npm registry for the package
44
- 3. Finds the version published immediately before `latest`
45
- 4. Verifies that version is at least 24 hours old
46
- 5. Caches the resolved version for 24 hours
47
- 6. Executes `npx pkg@resolved_version ...`
65
+ ## Environment Variables
48
66
 
49
- Calls without `@latest` are passed through directly to npx.
67
+ - `SNPX_TIME` Default for `--time`
68
+ - `SNPX_FALLBACK_STRATEGY` — Default for `--fallback-strategy`
50
69
 
51
70
  ## Cache
52
71
 
53
- Resolved versions are cached in `~/.cache/snpx/` for 24 hours. This means:
72
+ Resolved versions are cached in `~/.cache/snpx/` for the duration of the safety window (default 24 hours). This means:
54
73
  - Fast subsequent runs (no registry requests)
55
- - At most one registry query per package per day
74
+ - At most one registry query per package per window
56
75
 
57
76
  ## Acknowledgments
58
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lionad/safe-npx",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Safe npx wrapper - lock to latest-1 version with 24h cache",
5
5
  "type": "module",
6
6
  "bin": {
package/snpx.js CHANGED
@@ -1,78 +1,148 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * safe-npx (snpx) - Lock npx to latest-1 version with 24h cache
4
- * Inspired by safe-npm: https://github.com/kevinslin/safe-npm
3
+ * safe-npx (snpx) - Safe npx wrapper with configurable fallback strategy
5
4
  */
6
5
 
7
6
  import { spawn } from 'child_process';
8
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
9
8
  import { homedir } from 'os';
10
9
  import { join } from 'path';
11
10
 
12
11
  const CACHE_DIR = join(homedir(), '.cache', 'snpx');
13
- const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
14
- const MIN_AGE_MS = 24 * 60 * 60 * 1000; // 1 day
15
12
  const REGISTRY = 'https://registry.npmjs.org';
16
13
  const PKG_NAME = '@lionad/safe-npx';
14
+ const DEFAULT_TIME_HOURS = 24;
15
+ const DEFAULT_FALLBACK_STRATEGY = 'patch,minor,major';
16
+ const MS_PER_HOUR = 60 * 60 * 1000;
17
17
 
18
18
  const HELP_TEXT = `
19
- safe-npx (snpx) - Lock npx to latest-1 version with 24h cache
19
+ safe-npx (snpx) - Safe npx wrapper with configurable fallback strategy
20
20
 
21
21
  Usage:
22
22
  snpx [options] <package>@latest [args...]
23
+ snpx [options] <package> [args...]
23
24
  snpx [options] <command>
24
25
 
25
26
  Options:
26
27
  -h, --help Show this help message
27
- --self-update Check for snpx updates (safe mode, default)
28
- --unsafe-self-update Allow immediate snpx updates without 24h delay
28
+ --time <hours> Safety window in hours (default: 24)
29
+ --fallback-strategy <str> Comma-separated fallback order.
30
+ Default: patch,minor,major
31
+ Left-to-right: first matching safe version wins.
32
+ patch = version immediately before latest
33
+ minor = most recently published version of previous minor line
34
+ major = most recently published version of previous major line
35
+ --show-version Print resolved version and exit (no execution)
36
+ --self-update Check for snpx updates (safe mode, default 24h)
37
+ --unsafe-self-update Allow immediate snpx updates without safety window
38
+
39
+ Environment Variables:
40
+ SNPX_TIME Default for --time
41
+ SNPX_FALLBACK_STRATEGY Default for --fallback-strategy
29
42
 
30
43
  Examples:
31
44
  snpx -y cowsay@latest "Hello World"
32
- snpx --self-update
33
-
34
- Note: Only calls containing @latest are intercepted. Other commands pass through to npx directly.
45
+ snpx --time 48 --fallback-strategy patch,minor cowsay@latest
46
+ snpx --show-version cowsay@latest
35
47
  `.trim();
36
48
 
37
49
  /**
38
- * Parse package specifier from argv
39
- * Returns { pkgSpec: string|null, pkgName: string|null, restArgs: string[] }
40
- * Only intercepts specs containing '@latest'
50
+ * Parse a simple semver string into components.
51
+ * Returns null for invalid or complex prerelease strings.
52
+ */
53
+ export function parseSemver(version) {
54
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/);
55
+ if (!match) return null;
56
+ return {
57
+ major: parseInt(match[1], 10),
58
+ minor: parseInt(match[2], 10),
59
+ patch: parseInt(match[3], 10),
60
+ prerelease: match[4] || null,
61
+ build: match[5] || null,
62
+ raw: version
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Parse CLI arguments.
68
+ * Separates snpx-specific flags from npx passthrough arguments.
69
+ * Recognizes both @latest specifiers and bare package names.
41
70
  */
42
71
  export function parseArgs(argv) {
43
72
  const args = argv.slice(2);
73
+ const snpxFlags = {
74
+ help: false,
75
+ showVersion: false,
76
+ selfUpdate: false,
77
+ unsafeSelfUpdate: false,
78
+ time: null,
79
+ fallbackStrategy: null,
80
+ };
81
+ const npxArgs = [];
44
82
  let pkgSpec = null;
45
83
  let pkgName = null;
46
- let restArgs = [];
84
+ let sawFirstPositional = false;
47
85
 
48
86
  for (let i = 0; i < args.length; i++) {
49
87
  const arg = args[i];
50
- // Match package@latest pattern (including scoped @scope/pkg@latest)
51
- const match = arg.match(/^(@[^/]+\/[^@]+|[^@]+)@latest$/);
52
- if (match && !pkgSpec) {
53
- pkgSpec = arg;
54
- pkgName = match[1];
88
+
89
+ if (arg === '-h' || arg === '--help') {
90
+ snpxFlags.help = true;
91
+ } else if (arg === '--show-version') {
92
+ snpxFlags.showVersion = true;
93
+ } else if (arg === '--self-update') {
94
+ snpxFlags.selfUpdate = true;
95
+ } else if (arg === '--unsafe-self-update') {
96
+ snpxFlags.unsafeSelfUpdate = true;
97
+ } else if (arg === '--time') {
98
+ if (i + 1 >= args.length) throw new Error('Missing value for --time');
99
+ snpxFlags.time = args[++i];
100
+ } else if (arg.startsWith('--time=')) {
101
+ snpxFlags.time = arg.slice('--time='.length);
102
+ } else if (arg === '--fallback-strategy') {
103
+ if (i + 1 >= args.length) throw new Error('Missing value for --fallback-strategy');
104
+ snpxFlags.fallbackStrategy = args[++i];
105
+ } else if (arg.startsWith('--fallback-strategy=')) {
106
+ snpxFlags.fallbackStrategy = arg.slice('--fallback-strategy='.length);
55
107
  } else {
56
- restArgs.push(arg);
108
+ npxArgs.push(arg);
109
+ if (!pkgName && !sawFirstPositional) {
110
+ if (arg.startsWith('-')) {
111
+ // npx flag; skip for package detection
112
+ } else {
113
+ sawFirstPositional = true;
114
+ const latestMatch = arg.match(/^(@[^/]+\/[^@]+|[^@]+)@latest$/);
115
+ if (latestMatch) {
116
+ pkgSpec = arg;
117
+ pkgName = latestMatch[1];
118
+ } else {
119
+ const bareMatch = arg.match(/^(@[^/]+\/[^@]+|[^@]+)$/);
120
+ if (bareMatch) {
121
+ pkgSpec = arg;
122
+ pkgName = bareMatch[1];
123
+ }
124
+ }
125
+ }
126
+ }
57
127
  }
58
128
  }
59
129
 
60
- return { pkgSpec, pkgName, restArgs };
130
+ const restArgs = npxArgs.filter(a => a !== pkgSpec);
131
+ return { snpxFlags, pkgSpec, pkgName, restArgs, isLatest: !!(pkgSpec && pkgSpec.includes('@latest')) };
61
132
  }
62
133
 
63
134
  /**
64
- * Read cached version if valid
135
+ * Read cached version if valid.
136
+ * Cache TTL now follows the safety window (timeMs).
65
137
  */
66
- export function getCachedVersion(pkgName) {
67
- const cacheFile = join(CACHE_DIR, `${pkgName.replace('/', '--')}.json`);
138
+ export function getCachedVersion(pkgName, ttlMs = DEFAULT_TIME_HOURS * MS_PER_HOUR) {
139
+ const cacheFile = join(CACHE_DIR, `${pkgName.replaceAll('/', '--')}.json`);
68
140
  if (!existsSync(cacheFile)) return null;
69
141
 
70
142
  try {
71
- const stat = statSync(cacheFile);
72
- const age = Date.now() - stat.mtimeMs;
73
- if (age > CACHE_TTL_MS) return null;
74
-
75
143
  const data = JSON.parse(readFileSync(cacheFile, 'utf8'));
144
+ const age = Date.now() - (data.resolvedAt || 0);
145
+ if (age > ttlMs) return null;
76
146
  return data.version;
77
147
  } catch {
78
148
  return null;
@@ -80,76 +150,132 @@ export function getCachedVersion(pkgName) {
80
150
  }
81
151
 
82
152
  /**
83
- * Write version to cache
153
+ * Write version to cache.
84
154
  */
85
155
  export function setCachedVersion(pkgName, version) {
86
156
  mkdirSync(CACHE_DIR, { recursive: true });
87
- const cacheFile = join(CACHE_DIR, `${pkgName.replace('/', '--')}.json`);
157
+ const cacheFile = join(CACHE_DIR, `${pkgName.replaceAll('/', '--')}.json`);
88
158
  writeFileSync(cacheFile, JSON.stringify({ version, resolvedAt: Date.now() }));
89
159
  }
90
160
 
91
161
  /**
92
- * Fetch package metadata from registry
162
+ * Fetch package metadata from registry.
93
163
  */
94
164
  export async function fetchPackageMetadata(pkgName) {
95
165
  const url = `${REGISTRY}/${encodeURIComponent(pkgName)}`;
96
- const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
166
+ const res = await fetch(url, {
167
+ headers: { accept: 'application/json' },
168
+ signal: AbortSignal.timeout(10000),
169
+ });
97
170
  if (!res.ok) throw new Error(`Failed to fetch ${pkgName}: ${res.status}`);
98
171
  return res.json();
99
172
  }
100
173
 
174
+ export function buildVersionList(data) {
175
+ return Object.entries(data.time || {})
176
+ .filter(([v]) => v !== 'created' && v !== 'modified')
177
+ .map(([v, t]) => ({ version: v, time: new Date(t).getTime() }))
178
+ .filter(v => !Number.isNaN(v.time))
179
+ .sort((a, b) => b.time - a.time);
180
+ }
181
+
182
+ export function findPatchFallback(versions, latestVersion) {
183
+ const idx = versions.findIndex(v => v.version === latestVersion);
184
+ if (idx === -1 || idx + 1 >= versions.length) return null;
185
+ return versions[idx + 1];
186
+ }
187
+
188
+ export function findMinorFallback(versions, latestParsed) {
189
+ for (const v of versions) {
190
+ const p = parseSemver(v.version);
191
+ if (p && p.major === latestParsed.major && p.minor < latestParsed.minor) {
192
+ return v;
193
+ }
194
+ }
195
+ return null;
196
+ }
197
+
198
+ export function findMajorFallback(versions, latestParsed) {
199
+ for (const v of versions) {
200
+ const p = parseSemver(v.version);
201
+ if (p && p.major < latestParsed.major) {
202
+ return v;
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+
101
208
  /**
102
- * Find latest-1 version that is at least 1 day old
209
+ * Resolve a safe version for the package.
210
+ *
211
+ * @param {string} pkgName
212
+ * @param {object} options
213
+ * @param {number} options.timeMs - Safety window in milliseconds (default: 24h)
214
+ * @param {string[]} options.strategy - Fallback order, e.g. ['patch','minor','major']
103
215
  */
104
- export async function resolveSafeVersion(pkgName) {
216
+ export async function resolveSafeVersion(pkgName, options = {}) {
217
+ const timeMs = options.timeMs ?? DEFAULT_TIME_HOURS * MS_PER_HOUR;
218
+ const strategy = options.strategy ?? DEFAULT_FALLBACK_STRATEGY.split(',').map(s => s.trim());
219
+
105
220
  const data = await fetchPackageMetadata(pkgName);
106
- const latest = data['dist-tags']?.latest;
107
- if (!latest) throw new Error('No latest tag found');
221
+ const latestVersion = data['dist-tags']?.latest;
222
+ if (!latestVersion) throw new Error('No latest tag found');
108
223
 
109
- const times = data.time || {};
110
- const versions = Object.entries(times)
111
- .filter(([v]) => v !== 'created' && v !== 'modified')
112
- .map(([v, t]) => ({ version: v, time: new Date(t).getTime() }))
113
- .sort((a, b) => b.time - a.time); // Descending by time
224
+ const latestTimeStr = data.time?.[latestVersion];
225
+ const latestTime = latestTimeStr ? new Date(latestTimeStr).getTime() : null;
114
226
 
115
- // Find index of latest
116
- const latestIdx = versions.findIndex(v => v.version === latest);
117
- if (latestIdx === -1) throw new Error('Latest version not found in time data');
227
+ if (!latestTime) {
228
+ throw new Error(`Registry did not provide a publish time for ${pkgName}@${latestVersion}. Cannot verify safety window.`);
229
+ }
118
230
 
119
- // Get previous version (latest-1)
120
- const prev = versions[latestIdx + 1];
121
- if (!prev) throw new Error('No previous version available');
231
+ // If latest itself is old enough, use it directly.
232
+ if ((Date.now() - latestTime) >= timeMs) {
233
+ return latestVersion;
234
+ }
122
235
 
123
- // Check age
124
- const age = Date.now() - prev.time;
125
- if (age < MIN_AGE_MS) {
126
- throw new Error(`Previous version ${prev.version} is only ${Math.floor(age / 3600000)}h old, need 24h`);
236
+ const versions = buildVersionList(data);
237
+ const latestParsed = parseSemver(latestVersion);
238
+ if (!latestParsed) throw new Error(`Unable to parse latest version ${latestVersion}`);
239
+
240
+ for (const strat of strategy) {
241
+ let candidate = null;
242
+ if (strat === 'patch') {
243
+ candidate = findPatchFallback(versions, latestVersion);
244
+ } else if (strat === 'minor') {
245
+ candidate = findMinorFallback(versions, latestParsed);
246
+ } else if (strat === 'major') {
247
+ candidate = findMajorFallback(versions, latestParsed);
248
+ }
249
+
250
+ if (candidate && (Date.now() - candidate.time) >= timeMs) {
251
+ return candidate.version;
252
+ }
127
253
  }
128
254
 
129
- return prev.version;
255
+ throw new Error(`Could not find a safe version for ${pkgName} within strategy [${strategy.join(',')}] and time window ${Math.floor(timeMs / MS_PER_HOUR)}h`);
130
256
  }
131
257
 
132
258
  /**
133
- * Check if snpx itself has an update available
259
+ * Check if snpx itself has an update available.
134
260
  * Returns { hasUpdate: boolean, currentVersion: string, latestVersion: string|null }
135
261
  */
136
262
  export async function checkSelfUpdate() {
137
263
  try {
138
264
  const data = await fetchPackageMetadata(PKG_NAME);
139
265
  const latest = data['dist-tags']?.latest;
140
- if (!latest) return { hasUpdate: false, currentVersion: '0.1.0', latestVersion: null };
266
+ if (!latest) return { hasUpdate: false, currentVersion: '0.2.0', latestVersion: null };
141
267
 
142
- const currentVersion = '0.1.0'; // Should match package.json
268
+ const currentVersion = '0.2.0'; // Should match package.json
143
269
  const hasUpdate = latest !== currentVersion;
144
270
 
145
271
  return { hasUpdate, currentVersion, latestVersion: latest };
146
272
  } catch {
147
- return { hasUpdate: false, currentVersion: '0.1.0', latestVersion: null };
273
+ return { hasUpdate: false, currentVersion: '0.2.0', latestVersion: null };
148
274
  }
149
275
  }
150
276
 
151
277
  /**
152
- * Run npx with proper exit code handling
278
+ * Run npx with proper exit code handling.
153
279
  */
154
280
  function runNpx(args) {
155
281
  return new Promise((resolve, reject) => {
@@ -161,24 +287,44 @@ function runNpx(args) {
161
287
  child.on('error', reject);
162
288
  });
163
289
  }
290
+
291
+ /**
292
+ * Build effective execution options from CLI flags and environment variables.
293
+ */
294
+ export function buildOptions(snpxFlags) {
295
+ const envTime = process.env.SNPX_TIME;
296
+ const envStrategy = process.env.SNPX_FALLBACK_STRATEGY;
297
+ const timeHours = snpxFlags.time ?? envTime ?? DEFAULT_TIME_HOURS;
298
+ const parsedHours = parseFloat(timeHours);
299
+ if (!Number.isFinite(parsedHours) || parsedHours < 0) {
300
+ console.error(`[snpx] Invalid --time value: ${timeHours}`);
301
+ process.exit(1);
302
+ }
303
+ const timeMs = parsedHours * MS_PER_HOUR;
304
+ const strategyStr = snpxFlags.fallbackStrategy ?? envStrategy ?? DEFAULT_FALLBACK_STRATEGY;
305
+ const strategy = strategyStr.split(',').map(s => s.trim()).filter(Boolean);
306
+ return { timeHours, timeMs, strategy };
307
+ }
308
+
164
309
  /**
165
310
  * Main entry
166
311
  */
167
312
  async function main() {
168
- const args = process.argv.slice(2);
313
+ const { snpxFlags, pkgSpec, pkgName, restArgs } = parseArgs(process.argv);
169
314
 
170
- // Handle help
171
- if (args.includes('-h') || args.includes('--help')) {
315
+ if (snpxFlags.help) {
172
316
  console.log(HELP_TEXT);
173
317
  return;
174
318
  }
175
319
 
176
- // Handle self-update check
177
- const selfUpdateIndex = args.findIndex(a => a === '--self-update');
178
- const unsafeSelfUpdateIndex = args.findIndex(a => a === '--unsafe-self-update');
320
+ const { timeHours, timeMs, strategy } = buildOptions(snpxFlags);
179
321
 
180
- if (selfUpdateIndex !== -1 || unsafeSelfUpdateIndex !== -1) {
181
- const unsafe = unsafeSelfUpdateIndex !== -1;
322
+ // Handle self-update check.
323
+ const selfUpdate = snpxFlags.selfUpdate;
324
+ const unsafeSelfUpdate = snpxFlags.unsafeSelfUpdate;
325
+
326
+ if (selfUpdate || unsafeSelfUpdate) {
327
+ const unsafe = unsafeSelfUpdate;
182
328
  console.error(`[snpx] Checking for updates${unsafe ? ' (unsafe mode)' : ''}...`);
183
329
 
184
330
  try {
@@ -194,14 +340,13 @@ async function main() {
194
340
  console.error('[snpx] Run: npm update -g @lionad/safe-npx');
195
341
 
196
342
  if (!unsafe) {
197
- // Safe mode: check if latest is 24h old
198
343
  const data = await fetchPackageMetadata(PKG_NAME);
199
344
  const times = data.time || {};
200
345
  const latestTime = times[latestVersion];
201
346
  if (latestTime) {
202
347
  const age = Date.now() - new Date(latestTime).getTime();
203
- if (age < MIN_AGE_MS) {
204
- console.error(`[snpx] Warning: Latest version is only ${Math.floor(age / 3600000)}h old. Waiting for 24h safety window.`);
348
+ if (age < timeMs) {
349
+ console.error(`[snpx] Warning: Latest version is only ${Math.floor(age / MS_PER_HOUR)}h old. Waiting for ${timeHours}h safety window.`);
205
350
  console.error('[snpx] Use --unsafe-self-update to bypass (not recommended)');
206
351
  }
207
352
  }
@@ -216,24 +361,22 @@ async function main() {
216
361
  return;
217
362
  }
218
363
 
219
- // Normal package execution flow
220
- const { pkgSpec, pkgName, restArgs } = parseArgs(process.argv);
221
-
222
- // No @latest found, pass through directly
364
+ // Normal package execution flow.
365
+ // No package specifier found -> pass through to npx (but strip snpx flags).
223
366
  if (!pkgSpec || !pkgName) {
224
- await runNpx(args);
367
+ await runNpx(restArgs);
225
368
  return;
226
369
  }
227
370
 
228
- // Check cache first
229
- let version = getCachedVersion(pkgName);
371
+ // Check cache first.
372
+ let version = getCachedVersion(pkgName, timeMs);
230
373
 
231
374
  if (!version) {
232
375
  console.error(`[snpx] Resolving safe version for ${pkgName}...`);
233
376
  try {
234
- version = await resolveSafeVersion(pkgName);
377
+ version = await resolveSafeVersion(pkgName, { timeMs, strategy });
235
378
  setCachedVersion(pkgName, version);
236
- console.error(`[snpx] Using ${pkgName}@${version} (latest-1, cached for 24h)`);
379
+ console.error(`[snpx] Using ${pkgName}@${version} (strategy: ${strategy.join(',')}, window: ${timeHours}h)`);
237
380
  } catch (err) {
238
381
  console.error(`[snpx] Error: ${err.message}`);
239
382
  process.exit(1);
@@ -242,7 +385,12 @@ async function main() {
242
385
  console.error(`[snpx] Using cached ${pkgName}@${version}`);
243
386
  }
244
387
 
245
- // Replace @latest with @version and spawn npx
388
+ if (snpxFlags.showVersion) {
389
+ console.log(version);
390
+ return;
391
+ }
392
+
393
+ // Replace package specifier with pinned version and spawn npx.
246
394
  const npxArgs = [`${pkgName}@${version}`, ...restArgs];
247
395
  await runNpx(npxArgs);
248
396
  }