@wwkit/opm 1.0.18 → 1.0.20

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.
@@ -51,8 +51,8 @@ function isUrl(addr) {
51
51
 
52
52
  // HTTP 状态码 → 可达性判断:2xx/3xx 视为可达,allowlist 中的状态码
53
53
  // 虽非成功响应但证明服务器已响应(如 404 表示主机/代理存活,
54
- // 429 表示临时限流,服务器实际存活)
55
- const REACHABLE_ALLOWLIST = [404, 429]
54
+ // 405 表示 git 仓库不允许 GET 但服务器存活,429 表示临时限流)
55
+ const REACHABLE_ALLOWLIST = [404, 405, 429]
56
56
 
57
57
  function httpReachable(httpCode) {
58
58
  return (httpCode >= 200 && httpCode < 400) || REACHABLE_ALLOWLIST.includes(httpCode)
@@ -91,7 +91,9 @@ function checkByPing(host, count) {
91
91
 
92
92
  let child
93
93
  try {
94
- child = spawn('ping', args, { stdio: ['pipe', 'pipe', 'pipe'], shell: true })
94
+ // 直接传 args(不经 shell 拼接):ping.exe 为真实可执行文件,
95
+ // Windows 下 shell:true 会走 cmd.exe,存在参数被二次解析的风险
96
+ child = spawn('ping', args, { stdio: ['pipe', 'pipe', 'pipe'] })
95
97
  } catch {
96
98
  resolve({
97
99
  reachable: false, sent: count, received: 0, loss: count,
@@ -159,6 +161,47 @@ function checkByPing(host, count) {
159
161
  })
160
162
  }
161
163
 
164
+ /**
165
+ * 解析 curl writeout 输出(-w '%{http_code} %{time_total}')
166
+ *
167
+ * 兜底:writeout 输出缺失/被破坏(httpCode 0)时,退出码 0 仍代表成功收到
168
+ * HTTP 响应(curl 未加 -f,404/429 等也算正常完成),不应误判 FAIL。
169
+ * 修复背景:spawn 经 shell 拼接时,%{} 与空格被 shell/cmd 破坏导致 stdout
170
+ * 为空,镜像实际可达却全部报 HTTP 0。
171
+ *
172
+ * @param {string} stdout - curl stdout
173
+ * @param {number} code - curl 进程退出码
174
+ * @returns {{ httpCode: number, timeTotal: number, writeoutBroken: boolean, proxyAuthRequired: boolean, reachable: boolean, rttMs: string, raw: string }}
175
+ */
176
+ function parseCurlWriteout(stdout, code) {
177
+ const parts = String(stdout || '').trim().split(/\s+/)
178
+ const httpCode = parseInt(parts[0], 10) || 0
179
+ const timeTotal = parseFloat(parts[1]) || 0
180
+ const writeoutBroken = httpCode === 0 && code === 0
181
+ const proxyAuthRequired = httpCode === 407
182
+ const reachable = writeoutBroken || httpReachable(httpCode)
183
+ const rttMs = reachable && timeTotal > 0 ? String(Math.round(timeTotal * 1000)) : ''
184
+ const raw = writeoutBroken
185
+ ? 'OK (curl exit 0; writeout unavailable)'
186
+ : `HTTP ${httpCode} (${timeTotal}s)`
187
+ return { httpCode, timeTotal, writeoutBroken, proxyAuthRequired, reachable, rttMs, raw }
188
+ }
189
+
190
+ /**
191
+ * 构造 curl 探活参数(导出:win32 命令行往返测试直接锁定同一份生产参数,
192
+ * 避免测试与真实 args 漂移;Windows 上含空白的参数由 libuv 双引号包裹,
193
+ * cmd 不参与,%{} 与空格原样到达 curl.exe)
194
+ * @param {string} url - 完整 URL(含协议)
195
+ * @param {string} [proxy] - 代理地址,设置时追加 -x <proxy>
196
+ * @returns {string[]}
197
+ */
198
+ function buildCurlArgs(url, proxy) {
199
+ const args = ['-k', '-L', '-s', '-o', NULL_DEV, '-w', '%{http_code} %{time_total}', '--connect-timeout', '2', '--max-time', '5']
200
+ if (proxy) args.push('-x', proxy)
201
+ args.push(url)
202
+ return args
203
+ }
204
+
162
205
  /**
163
206
  * 用 curl 检查 URL 可访问性(HTTP,跳过 SSL 校验,跟随跳转)
164
207
  * @param {string} url - 完整 URL
@@ -167,12 +210,13 @@ function checkByPing(host, count) {
167
210
  */
168
211
  function checkByCurl(url, proxy) {
169
212
  return new Promise((resolve) => {
170
- const args = ['-k', '-L', '-s', '-o', NULL_DEV, '-w', '%{http_code} %{time_total}', '--connect-timeout', '2', '--max-time', '5']
171
- if (proxy) args.push('-x', proxy)
172
- args.push(url)
213
+ const args = buildCurlArgs(url, proxy)
173
214
  let child
174
215
  try {
175
- child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'], shell: true })
216
+ // 直接传 args(不经 shell 拼接):-w 格式串含 %{} 与空格,
217
+ // shell 模式会按空白拆参并交由 cmd/sh 做变量展开,导致 stdout 无法解析
218
+ // (Windows cmd 下实测输出空 → HTTP 0 (0s),镜像实际可达也被误判 FAIL)
219
+ child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] })
176
220
  } catch {
177
221
  resolve({
178
222
  reachable: false, sent: 1, received: 0, loss: 1,
@@ -185,23 +229,18 @@ function checkByCurl(url, proxy) {
185
229
  child.stdout.on('data', (d) => { stdout += d })
186
230
  child.stderr.on('data', (d) => { stderr += d })
187
231
  child.on('close', (code) => {
188
- const parts = stdout.trim().split(/\s+/)
189
- const httpCode = parseInt(parts[0], 10) || 0
190
- const timeTotal = parseFloat(parts[1]) || 0
191
- const proxyAuthRequired = httpCode === 407
192
- const reachable = httpReachable(httpCode)
193
- const rttMs = reachable ? String(Math.round(timeTotal * 1000)) : ''
232
+ const r = parseCurlWriteout(stdout, code)
194
233
  resolve({
195
- reachable,
196
- proxyAuthRequired,
234
+ reachable: r.reachable,
235
+ proxyAuthRequired: r.proxyAuthRequired,
197
236
  sent: 1,
198
- received: reachable ? 1 : 0,
199
- loss: reachable ? 0 : 1,
200
- lossPercent: reachable ? 0 : 100,
201
- rttMin: rttMs,
202
- rttAvg: rttMs,
203
- rttMax: rttMs,
204
- raw: `HTTP ${httpCode} (${timeTotal}s)`,
237
+ received: r.reachable ? 1 : 0,
238
+ loss: r.reachable ? 0 : 1,
239
+ lossPercent: r.reachable ? 0 : 100,
240
+ rttMin: r.rttMs,
241
+ rttAvg: r.rttMs,
242
+ rttMax: r.rttMs,
243
+ raw: r.raw,
205
244
  })
206
245
  })
207
246
  child.on('error', () => {
@@ -228,7 +267,7 @@ function checkByWget(url, proxy) {
228
267
  args.push(url)
229
268
  let child
230
269
  try {
231
- child = spawn('wget', args, { stdio: ['pipe', 'pipe', 'pipe'], shell: true })
270
+ child = spawn('wget', args, { stdio: ['pipe', 'pipe', 'pipe'] })
232
271
  } catch {
233
272
  resolve({
234
273
  reachable: false, sent: 1, received: 0, loss: 1,
@@ -302,7 +341,7 @@ async function checkReachability(address, tool, count, proxy) {
302
341
  return { reachable: false, sent: 0, received: 0, loss: 0, lossPercent: 0, rttMin: '', rttAvg: '', rttMax: '', raw: 'No tool available' }
303
342
  }
304
343
 
305
- export { checkReachability, extractHostPort, httpReachable }
344
+ export { checkReachability, extractHostPort, httpReachable, parseCurlWriteout, buildCurlArgs }
306
345
 
307
346
  export class PingGroup {
308
347
  constructor() {
@@ -311,7 +350,7 @@ export class PingGroup {
311
350
  }
312
351
 
313
352
  async run(argv) {
314
- const parsed = parseFlags(argv)
353
+ const parsed = parseFlags(argv, { booleans: ['detail'] })
315
354
  const [target] = parsed.positional
316
355
 
317
356
  if (!target || target === '-h' || target === '--help' || target === 'help') {
@@ -327,7 +366,7 @@ export class PingGroup {
327
366
 
328
367
  const count = parseInt(parsed.flags.c || parsed.flags.count || '4', 10)
329
368
  const n = (isNaN(count) || count < 1) ? 4 : count
330
- const detail = !!(parsed.flags.d || parsed.flags.detail)
369
+ const detail = !!(parsed.flags.detail)
331
370
 
332
371
  let proxy = parsed.flags.proxy || parsed.flags.p || ''
333
372
  if (proxy === 'true') proxy = ''
@@ -342,7 +381,9 @@ export class PingGroup {
342
381
  }
343
382
 
344
383
  if (target === 'source') {
345
- return this._pingSource()
384
+ console.error('[opm] Example check commands have moved into `opm ping help`.')
385
+ this.printHelp()
386
+ return
346
387
  }
347
388
 
348
389
  if (proxy) {
@@ -434,58 +475,6 @@ export class PingGroup {
434
475
  output(detail ? items : items.map(compact))
435
476
  }
436
477
 
437
- async _pingSource() {
438
- const url = getActiveRegistry('npm')
439
- const activeProxy = getActiveProxy()
440
- const tool = shell.getNetProbeTool()
441
-
442
- if (!tool) {
443
- console.error('[opm] No network check tool available (ping/curl/wget).')
444
- return
445
- }
446
-
447
- const cleanProxy = activeProxy.replace(/^(https?:\/\/)[^@]*@/, '$1')
448
- const authProxy = cleanProxy.replace(/^(https?:\/\/)/, '$1user:password@')
449
-
450
- if (tool === 'curl') {
451
- console.log(`# tool: ${tool}
452
- # Windows (PowerShell)
453
- curl.exe -k --max-time 10 ${url}
454
- curl.exe -k --max-time 10 --proxy ${cleanProxy} ${url}
455
- curl.exe -k --max-time 10 --proxy ${authProxy} ${url}
456
-
457
- # Linux / macOS
458
- curl -k --max-time 10 ${url}
459
- curl -k --max-time 10 --proxy ${cleanProxy} ${url}
460
- curl -k --max-time 10 --proxy ${authProxy} ${url}`)
461
- return
462
- }
463
-
464
- if (tool === 'wget') {
465
- console.log(`# tool: ${tool}
466
- # Windows (PowerShell)
467
- wget.exe -q --spider --timeout=10 ${url}
468
- wget.exe -q --spider --timeout=10 -e use_proxy=yes -e https_proxy=${cleanProxy} ${url}
469
- wget.exe -q --spider --timeout=10 -e use_proxy=yes -e https_proxy=${authProxy} ${url}
470
-
471
- # Linux / macOS
472
- wget -q --spider --timeout=10 ${url}
473
- wget -q --spider --timeout=10 -e use_proxy=yes -e https_proxy=${cleanProxy} ${url}
474
- wget -q --spider --timeout=10 -e use_proxy=yes -e https_proxy=${authProxy} ${url}`)
475
- return
476
- }
477
-
478
- if (tool === 'ping') {
479
- const { host } = extractHostPort(url)
480
- console.log(`# tool: ${tool}
481
- # Windows
482
- ping -n 4 -w 10000 ${host}
483
-
484
- # Linux / macOS
485
- ping -c 4 -W 10 ${host}`)
486
- }
487
- }
488
-
489
478
  printHelp() {
490
479
  console.log(`
491
480
  Usage: opm ping <host|proxy|registry|source> [options]
@@ -494,13 +483,13 @@ Actions:
494
483
  ping <host> Check a host, IP, or URL for accessibility
495
484
  ping proxy Check the active proxy address (from config)
496
485
  ping registry Check all tools' active registry addresses in parallel
497
- ping source Print example system commands (npm active registry)
486
+ ping source Show this help (example system commands listed below)
498
487
 
499
488
  Options:
500
489
  -c, --count <n> Number of packets to send (ping only; default: 4)
501
490
  -p, --proxy <url> Proxy address for the check (overrides config proxy.active;
502
491
  routes curl/wget requests through the proxy and pre-validates usability)
503
- -d, --detail Output full JSON details (default: compact [{tool, address, proxy, reachable}])
492
+ --detail Output full JSON details (default: compact [{tool, address, proxy, reachable}])
504
493
  -h, --help Show this help
505
494
 
506
495
  Tool detection: curl > wget > ping (auto-detected)
@@ -514,11 +503,20 @@ Examples:
514
503
  opm ping https://mirrors.tools.huawei.com/npm/
515
504
  opm ping proxy
516
505
  opm ping registry
517
- opm ping source
518
506
  opm ping 8.8.8.8 -c 2
519
- opm ping proxy -d
520
- opm ping registry -d
521
- opm ping sample -d
507
+ opm ping proxy --detail
508
+ opm ping registry --detail
509
+ opm ping sample --detail
510
+
511
+ Manual check commands (for reference; run manually in your shell):
512
+ # curl (npm active registry, proxy variants)
513
+ curl -k --max-time 10 https://registry.npmjs.org/
514
+ curl -k --max-time 10 --proxy http://proxy:port https://registry.npmjs.org/
515
+ curl -k --max-time 10 --proxy http://user:password@proxy:port https://registry.npmjs.org/
516
+ # wget
517
+ wget -q --spider --timeout=10 https://registry.npmjs.org/
518
+ # ping
519
+ ping -c 4 -W 10 registry.npmjs.org
522
520
  `)
523
521
  }
524
522
  }
@@ -52,7 +52,7 @@ export class ProcGroup {
52
52
  return
53
53
  }
54
54
 
55
- const parsed = parseFlags(rest)
55
+ const parsed = parseFlags(rest, { booleans: ['f', 'force', 'exact'] })
56
56
 
57
57
  switch (action) {
58
58
  case 'info':
@@ -91,8 +91,8 @@ export class ProcGroup {
91
91
  }
92
92
 
93
93
  async _search(parsed) {
94
- const name = parsed.flags.n || parsed.flags.name || ''
95
- const port = parsed.flags.p || parsed.flags.port || ''
94
+ const name = parsed.flags.name || ''
95
+ const port = parsed.flags.port || ''
96
96
  const exact = parsed.flags.exact === 'true'
97
97
 
98
98
  if (!name && !port) {
@@ -106,15 +106,15 @@ export class ProcGroup {
106
106
  }
107
107
 
108
108
  async _port(parsed) {
109
- const port = parsed.flags.p || parsed.flags.port || ''
109
+ const port = parsed.flags.port || ''
110
110
  const result = await listPorts(port || undefined)
111
111
  output(result)
112
112
  }
113
113
 
114
114
  async _kill(parsed) {
115
115
  const id = parsed.flags.i || parsed.flags.id || ''
116
- const port = parsed.flags.p || parsed.flags.port || ''
117
- const name = parsed.flags.n || parsed.flags.name || ''
116
+ const port = parsed.flags.port || ''
117
+ const name = parsed.flags.name || ''
118
118
  const force = parsed.flags.f === 'true' || parsed.flags.force === 'true'
119
119
  const yes = parsed.flags.y === 'true' || parsed.flags.yes === 'true'
120
120
  const signal = force ? 'SIGKILL' : 'SIGTERM'
@@ -203,9 +203,9 @@ ${actionLines}
203
203
 
204
204
  Options:
205
205
  -n, --count <n> Number of processes (list, default: 10)
206
- -n, --name <name> Process name pattern (search/kill, fuzzy by default)
206
+ --name <name> Process name pattern (search/kill, fuzzy by default)
207
207
  -b, --by <mem|cpu> Sort by memory or CPU (list, default: mem)
208
- -p, --port <port> Port number (search/kill/port)
208
+ --port <port> Port number (search/kill/port)
209
209
  -i, --id <pid> Process ID (kill)
210
210
  --exact Exact match (search, default: fuzzy)
211
211
  -f, --force Use SIGKILL instead of SIGTERM (kill)
@@ -216,14 +216,14 @@ Examples:
216
216
  opm proc info
217
217
  opm proc list
218
218
  opm proc list -n 20 -b cpu
219
- opm proc search -n node
220
- opm proc search -n node -p 3000
221
- opm proc search -n node --exact
219
+ opm proc search --name node
220
+ opm proc search --name node --port 3000
221
+ opm proc search --name node --exact
222
222
  opm proc port
223
- opm proc port -p 8080
223
+ opm proc port --port 8080
224
224
  opm proc kill -i 1234
225
- opm proc kill -p 8080 -f -y
226
- opm proc kill -n node -y
225
+ opm proc kill --port 8080 -f -y
226
+ opm proc kill --name node -y
227
227
  opm proc tree
228
228
  opm proc tree 1234
229
229
  opm proc detail 1234
@@ -47,7 +47,7 @@ export class ToolManagerGroup {
47
47
  }
48
48
 
49
49
  async run(argv) {
50
- const parsed = parseFlags(argv)
50
+ const parsed = parseFlags(argv, { booleans: ['force'] })
51
51
  const [action, ...rest] = parsed.positional
52
52
 
53
53
  if (!action || action === '-h' || action === '--help' || action === 'help') {
@@ -198,25 +198,21 @@ const out = execSync(this.cfg.versionCmd(), { encoding: 'utf8', shell: shell.she
198
198
  }
199
199
 
200
200
  /**
201
- * 列出工具已安装的版本
201
+ * 列出工具已安装的版本(数组格式输出)
202
202
  * @returns {void}
203
203
  * @private
204
204
  */
205
205
  _list() {
206
- if (!this.cfg.isInstalled()) {
207
- output({ name: this.name, installed: false, hint: `Run \`opm ${this.name} install\` first` })
208
- return
209
- }
210
- if (!this.cfg.listCmd) {
211
- output({ name: this.name, count: 0, versions: [] })
206
+ if (!this.cfg.isInstalled() || !this.cfg.listCmd) {
207
+ output([])
212
208
  return
213
209
  }
214
210
  try {
215
211
  const out = execSync(this.cfg.listCmd(), { encoding: 'utf8', shell: shell.shellForExec(this.cfg.needsBash) })
216
212
  const versions = this.cfg.parseList ? this.cfg.parseList(out) : []
217
- output({ name: this.name, count: versions.length, versions })
213
+ output(versions)
218
214
  } catch {
219
- output({ name: this.name, count: 0, versions: [] })
215
+ output([])
220
216
  }
221
217
  }
222
218
 
@@ -1,49 +1,7 @@
1
1
  /**
2
- * 共享命令行参数解析器
2
+ * opm 参数解析器 — 从 @wwkit/shared re-export
3
3
  *
4
- * 支持以下格式的参数:
5
- * --flag value 长标志(空格分隔)
6
- * --flag=value 长标志(等号形式)
7
- * -f value 短标志(空格分隔)
8
- * -f=value 短标志(等号形式)
9
- * positional 位置参数(不以 - 开头)
10
- *
11
- * @param {string[]} argv - 原始参数数组
12
- * @returns {{ flags: Record<string, string>, positional: string[] }}
4
+ * parseFlags 已提升到 @wwkit/shared(packages/shared/src/args.js),
5
+ * 此文件保留 re-export 以维持 opm 内部 import 路径不变(向后兼容)。
13
6
  */
14
- export function parseFlags(argv) {
15
- const flags = {}
16
- const positional = []
17
-
18
- for (let i = 0; i < argv.length; i++) {
19
- const arg = argv[i]
20
-
21
- if (arg.startsWith('--') && arg.includes('=')) {
22
- const idx = arg.indexOf('=')
23
- flags[arg.slice(2, idx)] = arg.slice(idx + 1)
24
- } else if (arg.startsWith('--')) {
25
- const next = argv[i + 1]
26
- if (next !== undefined && !next.startsWith('-')) {
27
- flags[arg.slice(2)] = next
28
- i++
29
- } else {
30
- flags[arg.slice(2)] = 'true'
31
- }
32
- } else if (arg.startsWith('-') && arg.includes('=')) {
33
- const idx = arg.indexOf('=')
34
- flags[arg.slice(1, idx)] = arg.slice(idx + 1)
35
- } else if (arg.startsWith('-') && arg.length > 1) {
36
- const next = argv[i + 1]
37
- if (next !== undefined && !next.startsWith('-')) {
38
- flags[arg.slice(1)] = next
39
- i++
40
- } else {
41
- flags[arg.slice(1)] = 'true'
42
- }
43
- } else {
44
- positional.push(arg)
45
- }
46
- }
47
-
48
- return { flags, positional }
49
- }
7
+ export { parseFlags, DEFAULT_BOOLEAN_FLAGS } from '@wwkit/shared'
package/src/cli/index.js CHANGED
@@ -70,6 +70,18 @@ const GROUPS = {
70
70
  env: new EnvGroup(),
71
71
  }
72
72
 
73
+ // share 快捷命令:s<action> → share <action>(前缀 s,业界惯例同 git gco/kubectl kgp)
74
+ const SHARE_SHORTCUTS = {
75
+ supload: 'upload',
76
+ szip: 'zip',
77
+ sget: 'get',
78
+ sunzip: 'unzip',
79
+ ssync: 'sync',
80
+ sopen: 'open',
81
+ scopy: 'copy',
82
+ spaste: 'paste',
83
+ }
84
+
73
85
  class CLI {
74
86
  async run(argv) {
75
87
  const [groupName, ...rest] = argv
@@ -101,6 +113,12 @@ if (groupName === 'version' || groupName === '-v' || groupName === '--version')
101
113
  return
102
114
  }
103
115
 
116
+ // share 快捷命令 → share 组对应 action
117
+ const shortcutAction = SHARE_SHORTCUTS[groupName]
118
+ if (shortcutAction) {
119
+ return GROUPS.share.run([shortcutAction, ...rest])
120
+ }
121
+
104
122
  const group = GROUPS[groupName]
105
123
  if (!group) {
106
124
  console.error(`Unknown command: ${groupName}`)
@@ -126,13 +144,16 @@ Usage: opm <command> [args] [options]
126
144
 
127
145
  Commands:
128
146
  ${groupLines}
147
+ supload/szip/sget Share shortcuts: upload / zip / get
148
+ sunzip/ssync/sopen Share shortcuts: unzip / sync / open
149
+ scopy/spaste Share shortcuts: copy / paste (clipboard)
129
150
  version Show opm version
130
151
  upgrade Upgrade opm itself (npm install -g @wwkit/opm@latest)
131
152
  doc Build docs HTML and open in browser
132
153
 
133
154
  Options:
134
- -g, --global Global operation (npm: global node_modules; pip: system Python)
135
- -p, --proxy <url> Proxy address (search/info/versions/install/upgrade)
155
+ -g, --global Global operation (package managers only: npm global node_modules; pip system Python)
156
+ -p, --proxy <url> Proxy address (network commands: search/info/versions/install/upgrade)
136
157
  -h, --help Show help
137
158
 
138
159
  Run "opm <command> help" for command details.
@@ -143,6 +164,7 @@ Examples:
143
164
  opm config Show common config (proxy + registries)
144
165
  opm opencode clear Clean up opencode sessions before today
145
166
  opm opencode clear --days 7 Keep sessions from the last 7 days
167
+ opm opencode clear --days 0.5 Keep sessions from the last 12 hours
146
168
  `)
147
169
  }
148
170
  }
@@ -211,7 +211,8 @@ export class HarnessGroup {
211
211
 
212
212
  async _clear(rest) {
213
213
  const parsed = parseFlags(rest)
214
- if (parsed.flags.help === 'true') {
214
+ // -h 统一表示 help(各 harness clear 实现不再占用 -h 作为业务参数)
215
+ if (parsed.flags.help === 'true' || parsed.flags.h === 'true') {
215
216
  this.printHelp()
216
217
  return
217
218
  }
@@ -9,10 +9,10 @@
9
9
  * 5. 删除过期会话(event_sequence + session)并 VACUUM
10
10
  * 6. 清空日志、报告清理结果
11
11
  *
12
- * 保留策略参数(三选一,缺省 -d 1 即只保留今天):
13
- * -d/--days <n> 保留最近 n 天(对齐本地零点,含今天)
14
- * -h/--hours <n> 保留最近 n 小时(滚动窗口)
15
- * -n/--num <n> 保留最近 n 个会话(按 time_created 倒序取最新 n 个)
12
+ * 保留策略参数(二选一,互斥;缺省 --days 1 即只保留今天):
13
+ * --days <n> 保留最近 n 天(整数对齐本地零点含今天;小数 = 小时级滚动窗口,如 0.5 = 12 小时)
14
+ * -n, --keep <n> 保留最近 n 个会话(按 time_created 倒序取最新 n 个)
15
+ * -h/--help 显示帮助
16
16
  */
17
17
 
18
18
  import fs from 'node:fs'
@@ -35,15 +35,19 @@ export function startOfDayLocal(ts) {
35
35
  }
36
36
 
37
37
  /**
38
- * 解析 -d/--days | -h/--hours | -n/--num 的计数参数
38
+ * 解析 --days | -n/--keep 的计数参数
39
39
  * @param {string|undefined} value
40
- * @param {string} name - 参数名(days/hours/num),用于报错提示
41
- * @returns {number|null} 未提供返回 null,否则返回正整数
40
+ * @param {string} name - 参数名(days/keep),用于报错提示
41
+ * @param {{ allowFraction?: boolean }} [opts] - allowFraction=true 时接受正小数(days 支持小时级)
42
+ * @returns {number|null} 未提供返回 null,否则返回正数
42
43
  */
43
- export function parseCount(value, name) {
44
+ export function parseCount(value, name, { allowFraction = false } = {}) {
44
45
  if (value === undefined) return null
45
46
  const n = Number(value)
46
- if (!Number.isInteger(n) || n < 1) {
47
+ if (!Number.isFinite(n) || n <= 0) {
48
+ throw new Error(`invalid --${name} value: "${value}" (expect ${allowFraction ? 'a positive number' : 'a positive integer'})`)
49
+ }
50
+ if (!allowFraction && !Number.isInteger(n)) {
47
51
  throw new Error(`invalid --${name} value: "${value}" (expect a positive integer)`)
48
52
  }
49
53
  return n
@@ -51,18 +55,18 @@ export function parseCount(value, name) {
51
55
 
52
56
  /**
53
57
  * 计算时间清理边界(毫秒)
54
- * days: 最近 n 天,对齐本地零点(含今天),边界 = 今天零点 - (n-1) 天
55
- * hours: 最近 n 小时,滚动窗口,边界 = now - n 小时
56
- * @param {{ days?: number|null, hours?: number|null }} opts
58
+ * days: 整数 = 最近 n 天,对齐本地零点(含今天),边界 = 今天零点 - (n-1) 天;
59
+ * 小数 = 小时级滚动窗口,边界 = now - n*24h(如 0.5 = 最近 12 小时)
60
+ * @param {{ days?: number|null }} opts
57
61
  * @returns {number}
58
62
  */
59
- export function computeBoundary({ days = null, hours = null } = {}) {
63
+ export function computeBoundary({ days = null } = {}) {
60
64
  const now = Date.now()
61
65
  if (days !== null) {
62
- return startOfDayLocal(now) - (days - 1) * 86400000
63
- }
64
- if (hours !== null) {
65
- return now - hours * 3600000
66
+ if (Number.isInteger(days)) {
67
+ return startOfDayLocal(now) - (days - 1) * 86400000
68
+ }
69
+ return now - days * 86400000
66
70
  }
67
71
  return startOfDayLocal(now)
68
72
  }
@@ -148,17 +152,16 @@ export async function runClear(argv) {
148
152
  const backup = flags.backup === 'true'
149
153
  const dbPath = flags.db || getDbPath()
150
154
 
151
- const days = parseCount(flags.days ?? flags.d, 'days')
152
- const hours = parseCount(flags.hours ?? flags.h, 'hours')
153
- const num = parseCount(flags.num ?? flags.n, 'num')
155
+ const days = parseCount(flags.days, 'days', { allowFraction: true })
156
+ const num = parseCount(flags.keep ?? flags.num ?? flags.n, 'keep')
154
157
 
155
- const given = ['days', 'hours', 'num'].filter((k) => ({ days, hours, num })[k] !== null)
158
+ const given = ['days', 'keep'].filter((k) => ({ days, keep: num })[k] !== null)
156
159
  if (given.length > 1) {
157
- throw new Error('--days / --hours / --num are mutually exclusive')
160
+ throw new Error('--days / --keep are mutually exclusive')
158
161
  }
159
162
 
160
163
  const mode = given[0] || 'days'
161
- const count = mode === 'days' ? (days ?? 1) : mode === 'hours' ? hours : num
164
+ const count = mode === 'days' ? (days ?? 1) : num
162
165
 
163
166
  if (!fs.existsSync(dbPath)) {
164
167
  throw new Error(`opencode database not found: ${dbPath}`)
@@ -170,15 +173,15 @@ export async function runClear(argv) {
170
173
  let scopeDesc
171
174
  let boundary = null
172
175
 
173
- if (mode === 'num') {
176
+ if (mode === 'keep') {
174
177
  const all = driver.listSessionsAsc()
175
178
  const keep = Math.min(count, all.length)
176
179
  rows = all.slice(0, all.length - keep)
177
180
  scopeDesc = `仅保留最新 ${keep} 个会话`
178
181
  } else {
179
- boundary = computeBoundary({ days, hours })
182
+ boundary = computeBoundary({ days })
180
183
  rows = driver.listBefore(boundary)
181
- scopeDesc = `删除 ${mode === 'days' ? `最近 ${count} 天` : `最近 ${count} 小时`}(${formatLocal(boundary)})之前的会话`
184
+ scopeDesc = `删除最近 ${count} 天(${formatLocal(boundary)})之前的会话`
182
185
  }
183
186
 
184
187
  console.log(`==> DB: ${dbPath}`)
@@ -93,8 +93,7 @@ export const opencodeHarness = {
93
93
  getLogPath,
94
94
  clear: runClear,
95
95
  clearOptions: [
96
- ['-d, --days <n>', 'Keep sessions from the last n days (default 1)'],
97
- ['-h, --hours <n>', 'Keep sessions from the last n hours'],
96
+ ['-d, --days <n>', 'Keep sessions from the last n days (default 1); integer = aligned to local midnight, fraction = hour window (0.5 = 12h)'],
98
97
  ['-n, --num <n>', 'Keep the most recent n sessions'],
99
98
  ['--dry-run', 'Preview only, do not modify anything'],
100
99
  ['-y, --yes', 'Skip the confirmation prompt'],
@@ -349,6 +349,22 @@ export class AptManager extends PackageManager {
349
349
  return { action: 'cache cleaned' }
350
350
  }
351
351
 
352
+ /**
353
+ * 列出系统已安装包(系统级管理器,无项目作用域)
354
+ * @returns {Promise<{ name: string, version: string }[]>}
355
+ */
356
+ async listInstalled(opts = {}) {
357
+ if (opts.global === false) return []
358
+ const out = this._exec(['list', '--installed'], { allowNonZero: true })
359
+ const items = []
360
+ // 行格式: name/arch version status
361
+ for (const line of out.split('\n')) {
362
+ const m = line.trim().match(/^(\S+?)\/\S+\s+(\S+)\s+\S*installed/)
363
+ if (m) items.push({ name: m[1], version: m[2] })
364
+ }
365
+ return items
366
+ }
367
+
352
368
  /**
353
369
  * 查询 apt 安装路径和包目录
354
370
  * @returns {{ name: string, install: string, listsDir: string, sourcesList: string, sourcesDir: string }}