@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.
- package/README.md +34 -15
- package/package.json +1 -1
- package/snpx.js +229 -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,150 @@
|
|
|
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);
|
|
107
|
+
} else if (arg.startsWith('--')) {
|
|
108
|
+
throw new Error(`Unknown flag: ${arg}. Run 'snpx --help' for available options.`);
|
|
55
109
|
} else {
|
|
56
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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, {
|
|
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
|
-
*
|
|
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
|
|
107
|
-
if (!
|
|
223
|
+
const latestVersion = data['dist-tags']?.latest;
|
|
224
|
+
if (!latestVersion) throw new Error('No latest tag found');
|
|
108
225
|
|
|
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
|
|
226
|
+
const latestTimeStr = data.time?.[latestVersion];
|
|
227
|
+
const latestTime = latestTimeStr ? new Date(latestTimeStr).getTime() : null;
|
|
114
228
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
233
|
+
// If latest itself is old enough, use it directly.
|
|
234
|
+
if ((Date.now() - latestTime) >= timeMs) {
|
|
235
|
+
return latestVersion;
|
|
236
|
+
}
|
|
122
237
|
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
268
|
+
if (!latest) return { hasUpdate: false, currentVersion: '0.2.1', latestVersion: null };
|
|
141
269
|
|
|
142
|
-
const currentVersion = '0.1
|
|
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
|
|
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
|
|
315
|
+
const { snpxFlags, pkgSpec, pkgName, restArgs } = parseArgs(process.argv);
|
|
169
316
|
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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 <
|
|
204
|
-
console.error(`[snpx] Warning: Latest version is only ${Math.floor(age /
|
|
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
|
-
|
|
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(
|
|
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} (
|
|
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
|
-
|
|
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
|
}
|