@lionad/safe-npx 0.1.0 → 0.2.1

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 +229 -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.1",
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,150 @@
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);
107
+ } else if (arg.startsWith('--')) {
108
+ throw new Error(`Unknown flag: ${arg}. Run 'snpx --help' for available options.`);
55
109
  } else {
56
- restArgs.push(arg);
110
+ npxArgs.push(arg);
111
+ if (!pkgName && !sawFirstPositional) {
112
+ // Single-dash flags (e.g. -y) skip package detection;
113
+ // positional args undergo package name matching.
114
+ if (!arg.startsWith('-')) {
115
+ sawFirstPositional = true;
116
+ const latestMatch = arg.match(/^(@[^/]+\/[^@]+|[^@]+)@latest$/);
117
+ if (latestMatch) {
118
+ pkgSpec = arg;
119
+ pkgName = latestMatch[1];
120
+ } else {
121
+ const bareMatch = arg.match(/^(@[^/]+\/[^@]+|[^@]+)$/);
122
+ if (bareMatch) {
123
+ pkgSpec = arg;
124
+ pkgName = bareMatch[1];
125
+ }
126
+ }
127
+ }
128
+ }
57
129
  }
58
130
  }
59
131
 
60
- return { pkgSpec, pkgName, restArgs };
132
+ const restArgs = npxArgs.filter(a => a !== pkgSpec);
133
+ return { snpxFlags, pkgSpec, pkgName, restArgs, isLatest: !!(pkgSpec && pkgSpec.includes('@latest')) };
61
134
  }
62
135
 
63
136
  /**
64
- * Read cached version if valid
137
+ * Read cached version if valid.
138
+ * Cache TTL now follows the safety window (timeMs).
65
139
  */
66
- export function getCachedVersion(pkgName) {
67
- const cacheFile = join(CACHE_DIR, `${pkgName.replace('/', '--')}.json`);
140
+ export function getCachedVersion(pkgName, ttlMs = DEFAULT_TIME_HOURS * MS_PER_HOUR) {
141
+ const cacheFile = join(CACHE_DIR, `${pkgName.replaceAll('/', '--')}.json`);
68
142
  if (!existsSync(cacheFile)) return null;
69
143
 
70
144
  try {
71
- const stat = statSync(cacheFile);
72
- const age = Date.now() - stat.mtimeMs;
73
- if (age > CACHE_TTL_MS) return null;
74
-
75
145
  const data = JSON.parse(readFileSync(cacheFile, 'utf8'));
146
+ const age = Date.now() - (data.resolvedAt || 0);
147
+ if (age > ttlMs) return null;
76
148
  return data.version;
77
149
  } catch {
78
150
  return null;
@@ -80,76 +152,132 @@ export function getCachedVersion(pkgName) {
80
152
  }
81
153
 
82
154
  /**
83
- * Write version to cache
155
+ * Write version to cache.
84
156
  */
85
157
  export function setCachedVersion(pkgName, version) {
86
158
  mkdirSync(CACHE_DIR, { recursive: true });
87
- const cacheFile = join(CACHE_DIR, `${pkgName.replace('/', '--')}.json`);
159
+ const cacheFile = join(CACHE_DIR, `${pkgName.replaceAll('/', '--')}.json`);
88
160
  writeFileSync(cacheFile, JSON.stringify({ version, resolvedAt: Date.now() }));
89
161
  }
90
162
 
91
163
  /**
92
- * Fetch package metadata from registry
164
+ * Fetch package metadata from registry.
93
165
  */
94
166
  export async function fetchPackageMetadata(pkgName) {
95
167
  const url = `${REGISTRY}/${encodeURIComponent(pkgName)}`;
96
- const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
168
+ const res = await fetch(url, {
169
+ headers: { accept: 'application/json' },
170
+ signal: AbortSignal.timeout(10000),
171
+ });
97
172
  if (!res.ok) throw new Error(`Failed to fetch ${pkgName}: ${res.status}`);
98
173
  return res.json();
99
174
  }
100
175
 
176
+ export function buildVersionList(data) {
177
+ return Object.entries(data.time || {})
178
+ .filter(([v]) => v !== 'created' && v !== 'modified')
179
+ .map(([v, t]) => ({ version: v, time: new Date(t).getTime() }))
180
+ .filter(v => !Number.isNaN(v.time))
181
+ .sort((a, b) => b.time - a.time);
182
+ }
183
+
184
+ export function findPatchFallback(versions, latestVersion) {
185
+ const idx = versions.findIndex(v => v.version === latestVersion);
186
+ if (idx === -1 || idx + 1 >= versions.length) return null;
187
+ return versions[idx + 1];
188
+ }
189
+
190
+ export function findMinorFallback(versions, latestParsed) {
191
+ for (const v of versions) {
192
+ const p = parseSemver(v.version);
193
+ if (p && p.major === latestParsed.major && p.minor < latestParsed.minor) {
194
+ return v;
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ export function findMajorFallback(versions, latestParsed) {
201
+ for (const v of versions) {
202
+ const p = parseSemver(v.version);
203
+ if (p && p.major < latestParsed.major) {
204
+ return v;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
101
210
  /**
102
- * Find latest-1 version that is at least 1 day old
211
+ * Resolve a safe version for the package.
212
+ *
213
+ * @param {string} pkgName
214
+ * @param {object} options
215
+ * @param {number} options.timeMs - Safety window in milliseconds (default: 24h)
216
+ * @param {string[]} options.strategy - Fallback order, e.g. ['patch','minor','major']
103
217
  */
104
- export async function resolveSafeVersion(pkgName) {
218
+ export async function resolveSafeVersion(pkgName, options = {}) {
219
+ const timeMs = options.timeMs ?? DEFAULT_TIME_HOURS * MS_PER_HOUR;
220
+ const strategy = options.strategy ?? DEFAULT_FALLBACK_STRATEGY.split(',').map(s => s.trim());
221
+
105
222
  const data = await fetchPackageMetadata(pkgName);
106
- const latest = data['dist-tags']?.latest;
107
- if (!latest) throw new Error('No latest tag found');
223
+ const latestVersion = data['dist-tags']?.latest;
224
+ if (!latestVersion) throw new Error('No latest tag found');
108
225
 
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
226
+ const latestTimeStr = data.time?.[latestVersion];
227
+ const latestTime = latestTimeStr ? new Date(latestTimeStr).getTime() : null;
114
228
 
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');
229
+ if (!latestTime) {
230
+ throw new Error(`Registry did not provide a publish time for ${pkgName}@${latestVersion}. Cannot verify safety window.`);
231
+ }
118
232
 
119
- // Get previous version (latest-1)
120
- const prev = versions[latestIdx + 1];
121
- if (!prev) throw new Error('No previous version available');
233
+ // If latest itself is old enough, use it directly.
234
+ if ((Date.now() - latestTime) >= timeMs) {
235
+ return latestVersion;
236
+ }
122
237
 
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`);
238
+ const versions = buildVersionList(data);
239
+ const latestParsed = parseSemver(latestVersion);
240
+ if (!latestParsed) throw new Error(`Unable to parse latest version ${latestVersion}`);
241
+
242
+ for (const strat of strategy) {
243
+ let candidate = null;
244
+ if (strat === 'patch') {
245
+ candidate = findPatchFallback(versions, latestVersion);
246
+ } else if (strat === 'minor') {
247
+ candidate = findMinorFallback(versions, latestParsed);
248
+ } else if (strat === 'major') {
249
+ candidate = findMajorFallback(versions, latestParsed);
250
+ }
251
+
252
+ if (candidate && (Date.now() - candidate.time) >= timeMs) {
253
+ return candidate.version;
254
+ }
127
255
  }
128
256
 
129
- return prev.version;
257
+ 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
258
  }
131
259
 
132
260
  /**
133
- * Check if snpx itself has an update available
261
+ * Check if snpx itself has an update available.
134
262
  * Returns { hasUpdate: boolean, currentVersion: string, latestVersion: string|null }
135
263
  */
136
264
  export async function checkSelfUpdate() {
137
265
  try {
138
266
  const data = await fetchPackageMetadata(PKG_NAME);
139
267
  const latest = data['dist-tags']?.latest;
140
- if (!latest) return { hasUpdate: false, currentVersion: '0.1.0', latestVersion: null };
268
+ if (!latest) return { hasUpdate: false, currentVersion: '0.2.1', latestVersion: null };
141
269
 
142
- const currentVersion = '0.1.0'; // Should match package.json
270
+ const currentVersion = '0.2.1'; // Should match package.json
143
271
  const hasUpdate = latest !== currentVersion;
144
272
 
145
273
  return { hasUpdate, currentVersion, latestVersion: latest };
146
274
  } catch {
147
- return { hasUpdate: false, currentVersion: '0.1.0', latestVersion: null };
275
+ return { hasUpdate: false, currentVersion: '0.2.1', latestVersion: null };
148
276
  }
149
277
  }
150
278
 
151
279
  /**
152
- * Run npx with proper exit code handling
280
+ * Run npx with proper exit code handling.
153
281
  */
154
282
  function runNpx(args) {
155
283
  return new Promise((resolve, reject) => {
@@ -161,24 +289,44 @@ function runNpx(args) {
161
289
  child.on('error', reject);
162
290
  });
163
291
  }
292
+
293
+ /**
294
+ * Build effective execution options from CLI flags and environment variables.
295
+ */
296
+ export function buildOptions(snpxFlags) {
297
+ const envTime = process.env.SNPX_TIME;
298
+ const envStrategy = process.env.SNPX_FALLBACK_STRATEGY;
299
+ const timeHours = snpxFlags.time ?? envTime ?? DEFAULT_TIME_HOURS;
300
+ const parsedHours = parseFloat(timeHours);
301
+ if (!Number.isFinite(parsedHours) || parsedHours < 0) {
302
+ console.error(`[snpx] Invalid --time value: ${timeHours}`);
303
+ process.exit(1);
304
+ }
305
+ const timeMs = parsedHours * MS_PER_HOUR;
306
+ const strategyStr = snpxFlags.fallbackStrategy ?? envStrategy ?? DEFAULT_FALLBACK_STRATEGY;
307
+ const strategy = strategyStr.split(',').map(s => s.trim()).filter(Boolean);
308
+ return { timeHours, timeMs, strategy };
309
+ }
310
+
164
311
  /**
165
312
  * Main entry
166
313
  */
167
314
  async function main() {
168
- const args = process.argv.slice(2);
315
+ const { snpxFlags, pkgSpec, pkgName, restArgs } = parseArgs(process.argv);
169
316
 
170
- // Handle help
171
- if (args.includes('-h') || args.includes('--help')) {
317
+ if (snpxFlags.help) {
172
318
  console.log(HELP_TEXT);
173
319
  return;
174
320
  }
175
321
 
176
- // Handle self-update check
177
- const selfUpdateIndex = args.findIndex(a => a === '--self-update');
178
- const unsafeSelfUpdateIndex = args.findIndex(a => a === '--unsafe-self-update');
322
+ const { timeHours, timeMs, strategy } = buildOptions(snpxFlags);
179
323
 
180
- if (selfUpdateIndex !== -1 || unsafeSelfUpdateIndex !== -1) {
181
- const unsafe = unsafeSelfUpdateIndex !== -1;
324
+ // Handle self-update check.
325
+ const selfUpdate = snpxFlags.selfUpdate;
326
+ const unsafeSelfUpdate = snpxFlags.unsafeSelfUpdate;
327
+
328
+ if (selfUpdate || unsafeSelfUpdate) {
329
+ const unsafe = unsafeSelfUpdate;
182
330
  console.error(`[snpx] Checking for updates${unsafe ? ' (unsafe mode)' : ''}...`);
183
331
 
184
332
  try {
@@ -194,14 +342,13 @@ async function main() {
194
342
  console.error('[snpx] Run: npm update -g @lionad/safe-npx');
195
343
 
196
344
  if (!unsafe) {
197
- // Safe mode: check if latest is 24h old
198
345
  const data = await fetchPackageMetadata(PKG_NAME);
199
346
  const times = data.time || {};
200
347
  const latestTime = times[latestVersion];
201
348
  if (latestTime) {
202
349
  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.`);
350
+ if (age < timeMs) {
351
+ console.error(`[snpx] Warning: Latest version is only ${Math.floor(age / MS_PER_HOUR)}h old. Waiting for ${timeHours}h safety window.`);
205
352
  console.error('[snpx] Use --unsafe-self-update to bypass (not recommended)');
206
353
  }
207
354
  }
@@ -216,24 +363,22 @@ async function main() {
216
363
  return;
217
364
  }
218
365
 
219
- // Normal package execution flow
220
- const { pkgSpec, pkgName, restArgs } = parseArgs(process.argv);
221
-
222
- // No @latest found, pass through directly
366
+ // Normal package execution flow.
367
+ // No package specifier found -> pass through to npx (but strip snpx flags).
223
368
  if (!pkgSpec || !pkgName) {
224
- await runNpx(args);
369
+ await runNpx(restArgs);
225
370
  return;
226
371
  }
227
372
 
228
- // Check cache first
229
- let version = getCachedVersion(pkgName);
373
+ // Check cache first.
374
+ let version = getCachedVersion(pkgName, timeMs);
230
375
 
231
376
  if (!version) {
232
377
  console.error(`[snpx] Resolving safe version for ${pkgName}...`);
233
378
  try {
234
- version = await resolveSafeVersion(pkgName);
379
+ version = await resolveSafeVersion(pkgName, { timeMs, strategy });
235
380
  setCachedVersion(pkgName, version);
236
- console.error(`[snpx] Using ${pkgName}@${version} (latest-1, cached for 24h)`);
381
+ console.error(`[snpx] Using ${pkgName}@${version} (strategy: ${strategy.join(',')}, window: ${timeHours}h)`);
237
382
  } catch (err) {
238
383
  console.error(`[snpx] Error: ${err.message}`);
239
384
  process.exit(1);
@@ -242,7 +387,12 @@ async function main() {
242
387
  console.error(`[snpx] Using cached ${pkgName}@${version}`);
243
388
  }
244
389
 
245
- // Replace @latest with @version and spawn npx
390
+ if (snpxFlags.showVersion) {
391
+ console.log(version);
392
+ return;
393
+ }
394
+
395
+ // Replace package specifier with pinned version and spawn npx.
246
396
  const npxArgs = [`${pkgName}@${version}`, ...restArgs];
247
397
  await runNpx(npxArgs);
248
398
  }