@lionad/safe-npx 0.2.1 → 0.2.3

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 +46 -35
  2. package/package.json +1 -1
  3. package/snpx.js +74 -50
package/README.md CHANGED
@@ -1,81 +1,92 @@
1
- # safe-npx (snpx)
1
+ <center>
2
+ <img src="./assets/logo.png" alt="safe-npx logo" height="300px"/>
3
+ <p style="margin-top: -4em;"><em><code>snpx -y pkg@latest</code> protects you from newly compromised packages</em></p>
4
+ <br>
5
+ <br>
6
+ </center>
2
7
 
3
- Safe npx wrapper with configurable time-based fallback strategy.
4
-
5
- ## Why
8
+ ## Why / 为什么需要
6
9
 
7
10
  `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
11
 
9
- ## Install
12
+ `npx -y pkg@latest` 会直接安装最新版本。如果该版本刚被供应链攻击(Supply Chain Attack)篡改,你会立即中招。**snpx** 会拦截 `@latest`(以及裸包名),根据发布时间和可配置的回退策略(Fallback Strategy)解析出一个安全版本。这为安全社区争取了发现和处置恶意发布的时间窗口。
13
+
14
+ ## Install / 安装
10
15
 
11
16
  ```bash
12
17
  npm install -g @lionad/safe-npx
13
18
  ```
14
19
 
15
- ## Usage
20
+ ## Usage / 用法
16
21
 
17
- Drop-in replacement for `npx`:
22
+ Drop-in replacement for `npx` — 直接替换 `npx` 即可:
18
23
 
19
24
  ```bash
20
25
  # Instead of npx -y create-react-app@latest my-app
26
+ # 替代 npx -y create-react-app@latest my-app
21
27
  snpx -y create-react-app@latest my-app
22
28
 
23
- # Works with scoped packages too
29
+ # Works with scoped packages too / 支持带 scope 的包
24
30
  snpx -y @vue/cli@latest create my-project
25
31
 
26
- # Bare package names are also intercepted
32
+ # Bare package names are also intercepted / 裸包名同样会被拦截
27
33
  snpx -y cowsay "Hello World"
34
+
35
+ # Flags after the package are passed through to the tool / 包名之后的参数会透传给被执行的工具
36
+ snpx cowsay@latest --version
28
37
  ```
29
38
 
30
- ## How it works
39
+ ## How it works / 工作原理
31
40
 
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 ...`
41
+ 1. Intercepts calls containing `@latest` and bare package names / 拦截包含 `@latest` 和裸包名的调用
42
+ 2. Queries npm registry for the package / 查询 npm registry 获取包信息
43
+ 3. If `latest` is older than the safety window (default 24h), uses `latest` / 如果 `latest` 发布时间超过安全窗口(默认 24 小时),直接使用
44
+ 4. Otherwise, falls back through the configured strategy / 否则,按配置的策略依次回退:
45
+ - `patch` = version published immediately before `latest` / 发布时间紧邻 `latest` 之前的版本
46
+ - `minor` = most recently published version of the previous minor line / 上一个 minor 线最近发布的版本
47
+ - `major` = most recently published version of the previous major line / 上一个 major 线最近发布的版本
48
+ 5. Verifies the fallback version is also older than the safety window / 验证回退版本也超过安全窗口
49
+ 6. Caches the resolved version for the duration of the safety window / 在安全窗口期间缓存解析结果
50
+ 7. Executes `npx pkg@resolved_version ...` / 执行 `npx pkg@resolved_version ...`
42
51
 
43
- ## Options
52
+ ## Options / 选项
44
53
 
45
54
  ```bash
46
- # Configure safety window (hours)
55
+ # Configure safety window (hours) / 配置安全窗口(小时)
47
56
  snpx --time 48 cowsay@latest
48
57
 
49
- # Configure fallback strategy (left-to-right precedence)
58
+ # Configure fallback strategy (left-to-right precedence) / 配置回退策略(从左到右优先)
50
59
  snpx --fallback-strategy patch,minor,major cowsay@latest
51
60
 
52
- # Print resolved version without executing
61
+ # Print resolved version without executing / 打印解析到的版本但不执行
53
62
  snpx --show-version cowsay@latest
54
63
 
55
- # Check for snpx updates (safe mode - respects 24h window)
64
+ # Check for snpx updates (safe mode - respects 24h window) / 检查 snpx 自身更新(安全模式,遵守 24 小时窗口)
56
65
  snpx --self-update
57
66
 
58
- # Bypass safety window for self-update check (not recommended)
67
+ # Bypass safety window for self-update check (not recommended) / 跳过安全窗口检查更新(不推荐)
59
68
  snpx --unsafe-self-update
60
69
 
61
- # Show help
70
+ # Show help / 显示帮助
62
71
  snpx --help
63
72
  ```
64
73
 
65
- ## Environment Variables
74
+ ## Environment Variables / 环境变量
66
75
 
67
- - `SNPX_TIME` — Default for `--time`
68
- - `SNPX_FALLBACK_STRATEGY` — Default for `--fallback-strategy`
76
+ - `SNPX_TIME` — Default for `--time` / `--time` 的默认值
77
+ - `SNPX_FALLBACK_STRATEGY` — Default for `--fallback-strategy` / `--fallback-strategy` 的默认值
69
78
 
70
- ## Cache
79
+ ## Cache / 缓存
71
80
 
72
81
  Resolved versions are cached in `~/.cache/snpx/` for the duration of the safety window (default 24 hours). This means:
73
- - Fast subsequent runs (no registry requests)
74
- - At most one registry query per package per window
82
+ - Fast subsequent runs (no registry requests) / 后续运行更快(无需请求 registry)
83
+ - At most one registry query per package per window / 每个安全窗口内每个包最多一次 registry 查询
84
+
85
+ ## Acknowledgments / 致谢
75
86
 
76
- ## Acknowledgments
87
+ Inspired by [safe-npm](https://github.com/kevinslin/safe-npm) by Kevin Lin.
77
88
 
78
- Inspired by [safe-npm](https://github.com/kevinslin/safe-npm) by Kevin Lin. The core idea of using package age as a supply chain security signal comes from that project.
89
+ 灵感来自 Kevin Lin 的 [safe-npm](https://github.com/kevinslin/safe-npm)
79
90
 
80
91
  ## License
81
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lionad/safe-npx",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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
@@ -15,35 +15,45 @@ const DEFAULT_TIME_HOURS = 24;
15
15
  const DEFAULT_FALLBACK_STRATEGY = 'patch,minor,major';
16
16
  const MS_PER_HOUR = 60 * 60 * 1000;
17
17
 
18
+ const _tty = process.stdout.isTTY && !process.env.NO_COLOR;
19
+ const _c = {
20
+ bold: (s) => _tty ? `\x1b[1m${s}\x1b[22m` : s,
21
+ dim: (s) => _tty ? `\x1b[2m${s}\x1b[22m` : s,
22
+ green: (s) => _tty ? `\x1b[32m${s}\x1b[39m` : s,
23
+ cyan: (s) => _tty ? `\x1b[36m${s}\x1b[39m` : s,
24
+ yellow: (s) => _tty ? `\x1b[33m${s}\x1b[39m` : s,
25
+ };
26
+
18
27
  const HELP_TEXT = `
19
- safe-npx (snpx) - Safe npx wrapper with configurable fallback strategy
28
+ ${_c.bold(_c.cyan('safe-npx (snpx)'))} Safe npx wrapper with configurable fallback strategy
20
29
 
21
- Usage:
22
- snpx [options] <package>@latest [args...]
23
- snpx [options] <package> [args...]
24
- snpx [options] <command>
30
+ ${_c.bold('Usage:')}
31
+ ${_c.green('snpx')} [options] <package>@latest [args...]
32
+ ${_c.green('snpx')} [options] <package> [args...]
33
+ ${_c.green('snpx')} [options] <command>
25
34
 
26
- Options:
27
- -h, --help Show this help message
28
- --time <hours> Safety window in hours (default: 24)
29
- --fallback-strategy <str> Comma-separated fallback order.
35
+ ${_c.bold('Options:')}
36
+ ${_c.yellow('-h, --help')} Show this help message
37
+ ${_c.yellow('--time')} ${_c.dim('<hours>')} Safety window in hours (default: 24)
38
+ ${_c.yellow('--fallback-strategy')} ${_c.dim('<str>')} Comma-separated fallback order.
30
39
  Default: patch,minor,major
31
40
  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
42
-
43
- Examples:
44
- snpx -y cowsay@latest "Hello World"
45
- snpx --time 48 --fallback-strategy patch,minor cowsay@latest
46
- snpx --show-version cowsay@latest
41
+ ${_c.dim('patch')} = version immediately before latest
42
+ ${_c.dim('minor')} = most recently published version of previous minor line
43
+ ${_c.dim('major')} = most recently published version of previous major line
44
+ ${_c.yellow('--show-version')} Print resolved version and exit (no execution)
45
+ ${_c.yellow('--self-update')} Check for snpx updates (safe mode, default 24h)
46
+ ${_c.yellow('--unsafe-self-update')} Allow immediate snpx updates without safety window
47
+
48
+ ${_c.bold('Environment Variables:')}
49
+ ${_c.green('SNPX_TIME')} Default for --time
50
+ ${_c.green('SNPX_FALLBACK_STRATEGY')} Default for --fallback-strategy
51
+
52
+ ${_c.bold('Examples:')}
53
+ ${_c.green('snpx')} -y cowsay@latest "Hello World"
54
+ ${_c.green('snpx')} --time 48 --fallback-strategy patch,minor cowsay@latest
55
+ ${_c.green('snpx')} --show-version cowsay@latest
56
+ ${_c.green('snpx')} cowsay@latest --version ${_c.dim('# passes --version to cowsay')}
47
57
  `.trim();
48
58
 
49
59
  /**
@@ -64,9 +74,14 @@ export function parseSemver(version) {
64
74
  }
65
75
 
66
76
  /**
67
- * Parse CLI arguments.
68
- * Separates snpx-specific flags from npx passthrough arguments.
69
- * Recognizes both @latest specifiers and bare package names.
77
+ * Parse CLI arguments using two-phase parsing:
78
+ *
79
+ * Phase 1 (before package): Only snpx flags accepted.
80
+ * Unknown --flags → error. Single-dash flags (-y) → npx passthrough.
81
+ * Phase 2 (after package): Everything is passthrough to the executed tool.
82
+ *
83
+ * snpx [snpx-flags] <package> [tool-args...]
84
+ * snpx [snpx-flags] (--help, --self-update, etc.)
70
85
  */
71
86
  export function parseArgs(argv) {
72
87
  const args = argv.slice(2);
@@ -78,14 +93,21 @@ export function parseArgs(argv) {
78
93
  time: null,
79
94
  fallbackStrategy: null,
80
95
  };
81
- const npxArgs = [];
82
96
  let pkgSpec = null;
83
97
  let pkgName = null;
84
- let sawFirstPositional = false;
98
+ const restArgs = [];
99
+ let foundPackage = false;
85
100
 
86
101
  for (let i = 0; i < args.length; i++) {
87
102
  const arg = args[i];
88
103
 
104
+ // Phase 2: after package name, everything is passthrough
105
+ if (foundPackage) {
106
+ restArgs.push(arg);
107
+ continue;
108
+ }
109
+
110
+ // Phase 1: before package, only known snpx flags accepted
89
111
  if (arg === '-h' || arg === '--help') {
90
112
  snpxFlags.help = true;
91
113
  } else if (arg === '--show-version') {
@@ -106,30 +128,32 @@ export function parseArgs(argv) {
106
128
  snpxFlags.fallbackStrategy = arg.slice('--fallback-strategy='.length);
107
129
  } else if (arg.startsWith('--')) {
108
130
  throw new Error(`Unknown flag: ${arg}. Run 'snpx --help' for available options.`);
131
+ } else if (arg.startsWith('-')) {
132
+ // Single-dash npx flags (e.g. -y, -p) are always passthrough
133
+ restArgs.push(arg);
109
134
  } else {
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
- }
135
+ // Positional: check if it's a package name
136
+ const latestMatch = arg.match(/^(@[^/]+\/[^@]+|[^@]+)@latest$/);
137
+ if (latestMatch) {
138
+ pkgSpec = arg;
139
+ pkgName = latestMatch[1];
140
+ foundPackage = true;
141
+ } else {
142
+ const bareMatch = arg.match(/^(@[^/]+\/[^@]+|[^@]+)$/);
143
+ if (bareMatch) {
144
+ pkgSpec = arg;
145
+ pkgName = bareMatch[1];
146
+ foundPackage = true;
147
+ } else {
148
+ // Not a recognized package spec (e.g. pkg@1.0.0).
149
+ // Stop phase 1; treat this and everything after as npx passthrough.
150
+ foundPackage = true;
151
+ restArgs.push(arg);
127
152
  }
128
153
  }
129
154
  }
130
155
  }
131
156
 
132
- const restArgs = npxArgs.filter(a => a !== pkgSpec);
133
157
  return { snpxFlags, pkgSpec, pkgName, restArgs, isLatest: !!(pkgSpec && pkgSpec.includes('@latest')) };
134
158
  }
135
159
 
@@ -265,14 +289,14 @@ export async function checkSelfUpdate() {
265
289
  try {
266
290
  const data = await fetchPackageMetadata(PKG_NAME);
267
291
  const latest = data['dist-tags']?.latest;
268
- if (!latest) return { hasUpdate: false, currentVersion: '0.2.1', latestVersion: null };
292
+ if (!latest) return { hasUpdate: false, currentVersion: '0.2.3', latestVersion: null };
269
293
 
270
- const currentVersion = '0.2.1'; // Should match package.json
294
+ const currentVersion = '0.2.3'; // Should match package.json
271
295
  const hasUpdate = latest !== currentVersion;
272
296
 
273
297
  return { hasUpdate, currentVersion, latestVersion: latest };
274
298
  } catch {
275
- return { hasUpdate: false, currentVersion: '0.2.1', latestVersion: null };
299
+ return { hasUpdate: false, currentVersion: '0.2.3', latestVersion: null };
276
300
  }
277
301
  }
278
302