@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.
- package/README.md +34 -15
- package/package.json +1 -1
- package/snpx.js +227 -79
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# safe-npx (snpx)
|
|
2
2
|
|
|
3
|
-
Safe npx wrapper
|
|
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**
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
|
74
|
+
- At most one registry query per package per window
|
|
56
75
|
|
|
57
76
|
## Acknowledgments
|
|
58
77
|
|
package/package.json
CHANGED
package/snpx.js
CHANGED
|
@@ -1,78 +1,148 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* safe-npx (snpx) -
|
|
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
|
|
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) -
|
|
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
|
-
--
|
|
28
|
-
--
|
|
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 --
|
|
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
|
|
39
|
-
* Returns
|
|
40
|
-
|
|
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
|
|
84
|
+
let sawFirstPositional = false;
|
|
47
85
|
|
|
48
86
|
for (let i = 0; i < args.length; i++) {
|
|
49
87
|
const arg = args[i];
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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, {
|
|
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
|
-
*
|
|
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
|
|
107
|
-
if (!
|
|
221
|
+
const latestVersion = data['dist-tags']?.latest;
|
|
222
|
+
if (!latestVersion) throw new Error('No latest tag found');
|
|
108
223
|
|
|
109
|
-
const
|
|
110
|
-
const
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
231
|
+
// If latest itself is old enough, use it directly.
|
|
232
|
+
if ((Date.now() - latestTime) >= timeMs) {
|
|
233
|
+
return latestVersion;
|
|
234
|
+
}
|
|
122
235
|
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
126
|
-
|
|
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
|
-
|
|
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.
|
|
266
|
+
if (!latest) return { hasUpdate: false, currentVersion: '0.2.0', latestVersion: null };
|
|
141
267
|
|
|
142
|
-
const currentVersion = '0.
|
|
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.
|
|
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
|
|
313
|
+
const { snpxFlags, pkgSpec, pkgName, restArgs } = parseArgs(process.argv);
|
|
169
314
|
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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 <
|
|
204
|
-
console.error(`[snpx] Warning: Latest version is only ${Math.floor(age /
|
|
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
|
-
|
|
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(
|
|
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} (
|
|
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
|
-
|
|
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
|
}
|