@wwkit/opm 1.0.17 → 1.0.19

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.
@@ -272,6 +272,29 @@ export class BunManager extends PackageManager {
272
272
  return { action: 'cache cleaned' }
273
273
  }
274
274
 
275
+ /**
276
+ * 列出已安装包(bun pm ls 文本树解析;-g 列全局)
277
+ * @param {{ global?: boolean }} [opts]
278
+ * @returns {Promise<{ name: string, version: string }[]>}
279
+ */
280
+ async listInstalled(opts = {}) {
281
+ const args = opts.global ? ['pm', 'ls', '-g', '--all'] : ['pm', 'ls', '--all']
282
+ const out = this._exec(args, { allowNonZero: true })
283
+ const items = []
284
+ for (const line of out.split('\n')) {
285
+ const cleaned = line.replace(/^[\s├└─│]+/, '').trim()
286
+ if (!cleaned) continue
287
+ // scoped 包:@babel/core@7.0.0 → 最后一个 @ 为版本分隔
288
+ const idx = cleaned.lastIndexOf('@')
289
+ if (idx > 0) {
290
+ const name = cleaned.slice(0, idx)
291
+ const version = cleaned.slice(idx + 1)
292
+ if (name && version) items.push({ name, version: version.replace(/^v/, '') })
293
+ }
294
+ }
295
+ return items
296
+ }
297
+
275
298
  /**
276
299
  * 查询 bun 安装路径和包目录
277
300
  * @returns {{ name: string, install: string, globalDir: string, cacheDir: string }}
@@ -344,6 +344,17 @@ export class ComposerManager extends PackageManager {
344
344
  return { action: 'cache cleaned' }
345
345
  }
346
346
 
347
+ /**
348
+ * 列出已安装包(缺省读当前项目 vendor/composer/installed.json,global 读全局 installed.json)
349
+ * @param {{ global?: boolean }} [opts]
350
+ * @returns {Promise<{ name: string, version: string }[]>}
351
+ */
352
+ async listInstalled(opts = {}) {
353
+ const packages = opts.global ? this._readGlobalInstalledJson() : this._readInstalledJson()
354
+ if (!packages) return []
355
+ return packages.map((p) => ({ name: p.name, version: p.version || 'unknown' }))
356
+ }
357
+
347
358
  /**
348
359
  * 查询 composer 安装路径和包目录
349
360
  * @returns {{ name: string, install: string, globalHome: string, vendorDir: string }}
@@ -342,6 +342,22 @@ export class DnfManager extends PackageManager {
342
342
  return { action: 'cache cleaned' }
343
343
  }
344
344
 
345
+ /**
346
+ * 列出系统已安装包(系统级管理器,无项目作用域)
347
+ * @returns {Promise<{ name: string, version: string }[]>}
348
+ */
349
+ async listInstalled(opts = {}) {
350
+ if (opts.global === false) return []
351
+ const out = this._exec(['list', 'installed', '--quiet'], { allowNonZero: true })
352
+ const items = []
353
+ // 行格式: name.arch version repo
354
+ for (const line of out.split('\n')) {
355
+ const m = line.trim().match(/^(\S+?)\.\S+\s+(\S+)\s+\S+/)
356
+ if (m) items.push({ name: m[1], version: m[2] })
357
+ }
358
+ return items
359
+ }
360
+
345
361
  /**
346
362
  * 查询 dnf 安装路径和包目录
347
363
  * @returns {{ name: string, install: string, cacheDir: string, repoDir: string }}
@@ -505,6 +505,18 @@ export class NpmManager extends PackageManager {
505
505
  return { action: 'cache cleaned' }
506
506
  }
507
507
 
508
+ /**
509
+ * 列出已安装包
510
+ * @param {{ global?: boolean }} [opts] - global=true 列全局,缺省列当前项目
511
+ * @returns {Promise<{ name: string, version: string }[]>}
512
+ */
513
+ async listInstalled(opts = {}) {
514
+ const args = opts.global ? ['ls', '-g', '--depth=0'] : ['ls', '--depth=0']
515
+ const data = this._execJson(args)
516
+ const deps = (data && data.dependencies) || {}
517
+ return Object.entries(deps).map(([name, d]) => ({ name, version: (d.version || '').replace(/^v/, '') }))
518
+ }
519
+
508
520
  /**
509
521
  * 查询 npm 安装路径和包目录
510
522
  * @returns {{ name: string, install: string, globalDir: string, localDir: string, cacheDir: string, prefix: string }}
@@ -557,6 +557,22 @@ export class PipManager extends PackageManager {
557
557
  return { action: 'cache cleaned' }
558
558
  }
559
559
 
560
+ /**
561
+ * 列出已安装包
562
+ * @param {{ global?: boolean }} [opts] - global=true 用系统级 Python 环境,缺省当前环境
563
+ * @returns {Promise<{ name: string, version: string }[]>}
564
+ */
565
+ async listInstalled(opts = {}) {
566
+ const out = this._execPip(['list', '--format=json', '--disable-pip-version-check'], { allowNonZero: true, global: opts.global })
567
+ try {
568
+ const data = JSON.parse(out)
569
+ if (!Array.isArray(data)) return []
570
+ return data.map((i) => ({ name: i.name, version: i.version || '' }))
571
+ } catch {
572
+ return []
573
+ }
574
+ }
575
+
560
576
  /**
561
577
  * 查询 pip 安装路径和包目录
562
578
  * @returns {{ name: string, install: string, sitePackages: string, userSite: string }}
@@ -242,6 +242,18 @@ export class WingetManager extends PackageManager {
242
242
  return { action: 'winget has no cache clean command' }
243
243
  }
244
244
 
245
+ /**
246
+ * 列出系统已安装包(winget list 表格解析)
247
+ * @returns {Promise<{ name: string, version: string }[]>}
248
+ */
249
+ async listInstalled(opts = {}) {
250
+ const out = this._exec(['list'], { allowNonZero: true })
251
+ const rows = this._parseTable(out)
252
+ return rows
253
+ .filter((r) => r.Name)
254
+ .map((r) => ({ name: r.Name, version: r.Version || '' }))
255
+ }
256
+
245
257
  /**
246
258
  * 查询 winget 安装路径
247
259
  * @returns {{ name: string, install: string }}
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 系统剪切板读写工具
3
+ *
4
+ * 跨平台读取/写入系统级剪切板:
5
+ * - darwin: pbpaste / pbcopy
6
+ * - linux: 优先 wl-paste / wl-copy(Wayland),回退 xclip / xsel
7
+ * - win32: PowerShell Get-Clipboard / Set-Clipboard
8
+ * 读取失败(命令不存在/非零退出)返回空字符串,调用方用 trim 判断空。
9
+ */
10
+
11
+ import { spawnSync } from 'node:child_process'
12
+
13
+ /**
14
+ * 执行外部命令并捕获输出
15
+ * @param {string} cmd
16
+ * @param {string[]} args
17
+ * @param {{input?: string}} [opts]
18
+ * @returns {{ok: boolean, stdout: string, stderr: string}}
19
+ */
20
+ function run(cmd, args, opts = {}) {
21
+ const res = spawnSync(cmd, args, { encoding: 'utf8', ...opts })
22
+ if (res.error) {
23
+ return { ok: false, stdout: '', stderr: res.error.message }
24
+ }
25
+ return { ok: res.status === 0, stdout: res.stdout || '', stderr: res.stderr || '' }
26
+ }
27
+
28
+ /**
29
+ * 读取系统剪切板内容(失败返回空字符串)
30
+ * @returns {string}
31
+ */
32
+ export function readClipboard() {
33
+ const p = process.platform
34
+ if (p === 'darwin') {
35
+ return run('pbpaste', []).stdout
36
+ }
37
+ if (p === 'win32') {
38
+ const r = run('powershell', [
39
+ '-NoProfile', '-Command',
40
+ '[Console]::Out.Write((Get-Clipboard -Raw -ErrorAction SilentlyContinue))',
41
+ ])
42
+ return r.ok ? r.stdout : ''
43
+ }
44
+ // linux
45
+ const r = run('bash', [
46
+ '-c',
47
+ 'wl-paste --no-newline 2>/dev/null || xclip -selection clipboard -o 2>/dev/null || xsel --clipboard --output 2>/dev/null',
48
+ ])
49
+ return r.ok ? r.stdout : ''
50
+ }
51
+
52
+ /**
53
+ * 写入系统剪切板
54
+ * @param {string} text
55
+ */
56
+ export function writeClipboard(text) {
57
+ const input = String(text ?? '')
58
+ const p = process.platform
59
+ if (p === 'darwin') {
60
+ run('pbcopy', [], { input })
61
+ return
62
+ }
63
+ if (p === 'win32') {
64
+ run('powershell', ['-NoProfile', '-Command', 'Set-Clipboard -Value $input'], { input })
65
+ return
66
+ }
67
+ run('bash', ['-c', 'wl-copy 2>/dev/null || xclip -selection clipboard 2>/dev/null || xsel --clipboard --input 2>/dev/null'], { input })
68
+ }
@@ -196,7 +196,7 @@ export class FtpGroup {
196
196
  console.error('Usage: opm ftp upload <file> [-n name] [-s subdir]')
197
197
  process.exit(1)
198
198
  }
199
- const name = parsed.flags.name || parsed.flags.n || ''
199
+ const name = parsed.flags.name || ''
200
200
  const subdir = parsed.flags.subdir || parsed.flags.s || ''
201
201
  const cfg = getFtpConfig()
202
202
 
@@ -242,14 +242,14 @@ export class FtpGroup {
242
242
  async _rsync(parsed) {
243
243
  const local = parsed.positional[0]
244
244
  if (!local) {
245
- console.error('Usage: opm ftp rsync <local> [-s subdir] [-x pattern] [--dry-run] [--prune]')
245
+ console.error('Usage: opm ftp rsync <local> [-s subdir] [-x pattern] [--dry-run] [--delete]')
246
246
  process.exit(1)
247
247
  }
248
248
  const subdir = parsed.flags.subdir || parsed.flags.s || ''
249
249
  const excludes = parsed.flags.exclude || parsed.flags.x || []
250
250
  const excludeList = Array.isArray(excludes) ? excludes : [excludes]
251
251
  const dryRun = parsed.flags['dry-run'] === 'true'
252
- const prune = parsed.flags.prune === 'true'
252
+ const deleteRemote = parsed.flags.delete === 'true'
253
253
 
254
254
  const cfg = getFtpConfig()
255
255
  const localDir = path.resolve(local)
@@ -282,7 +282,7 @@ export class FtpGroup {
282
282
  }
283
283
 
284
284
  const deletePlan = []
285
- if (prune) {
285
+ if (deleteRemote) {
286
286
  for (const [rel] of remoteFiles) {
287
287
  if (!toUpload.includes(rel)) {
288
288
  deletePlan.push(rel)
@@ -335,9 +335,9 @@ export class FtpGroup {
335
335
  skippedSame: skippedSame.length,
336
336
  skippedExcluded,
337
337
  failed,
338
- pruned: deleted.length,
339
- prunedFiles: deleted,
340
- pruneFailed: deleteFailed,
338
+ deleted: deleted.length,
339
+ deletedFiles: deleted,
340
+ deleteFailed,
341
341
  })
342
342
  }
343
343
 
@@ -370,7 +370,7 @@ Actions:
370
370
  uninstall Uninstall basic-ftp
371
371
  upgrade Upgrade basic-ftp to latest (uses opm npm registry + proxy)
372
372
  connect Test FTP connection
373
- upload <file> [-n name] [-s subdir] Upload file (local path or HTTP URL)
373
+ upload <file> [--name name] [-s subdir] Upload file (local path or HTTP URL)
374
374
  rsync <local> [options] Recursive upload directory (FTP-side dedup)
375
375
  download <remote> <local> Download file from FTP
376
376
  root Show configured remote root directory
@@ -380,7 +380,7 @@ rsync options:
380
380
  -s, --subdir <dir> Remote subdirectory (under remote_root)
381
381
  -x, --exclude <pattern> Exclude glob pattern (repeatable)
382
382
  --dry-run Preview without uploading
383
- --prune Delete remote files not in local
383
+ --delete Delete remote files not in local
384
384
 
385
385
  Options:
386
386
  -p, --proxy <url> Proxy for install/upgrade (overrides config proxy.active)
@@ -393,12 +393,12 @@ Examples:
393
393
  opm ftp upgrade
394
394
  opm ftp connect
395
395
  opm ftp upload ./img.png
396
- opm ftp upload ./img.png -n cover -s banner
396
+ opm ftp upload ./img.png --name cover -s banner
397
397
  opm ftp upload https://example.com/a.jpg -n cover.jpg
398
398
  opm ftp rsync ./webwork/
399
399
  opm ftp rsync ./webwork/ -s deploy -x "vendor/*" -x "*.log"
400
400
  opm ftp rsync ./webwork/ --dry-run
401
- opm ftp rsync ./webwork/ --prune
401
+ opm ftp rsync ./webwork/ --delete
402
402
  opm ftp download 20260913/cover.jpg ./cover.jpg
403
403
  opm ftp root
404
404
  `)
@@ -40,6 +40,20 @@ export class GitGroup {
40
40
  this.desc = 'Git config management (init/config/user/log/export)'
41
41
  }
42
42
 
43
+ /**
44
+ * 当前 action 下的上下文布尔标志
45
+ * 同名标志在不同 action 中语义不同(-f:init=force / export=from;--unset:config 带值 / export mark 布尔),
46
+ * 由 action 决定解析方式,避免布尔标志吞掉后续参数。
47
+ * @param {string} action
48
+ * @returns {string[]}
49
+ * @private
50
+ */
51
+ _booleanFlags(action) {
52
+ if (action === 'init') return ['f', 'force']
53
+ if (action === 'export') return ['remove', 'unset']
54
+ return []
55
+ }
56
+
43
57
  async run(argv) {
44
58
  const [action, ...rest] = argv
45
59
 
@@ -48,7 +62,7 @@ export class GitGroup {
48
62
  return
49
63
  }
50
64
 
51
- const parsed = parseFlags(rest)
65
+ const parsed = parseFlags(rest, { booleans: this._booleanFlags(action) })
52
66
 
53
67
  switch (action) {
54
68
  case 'version':
@@ -146,7 +160,7 @@ export class GitGroup {
146
160
 
147
161
  async _user(parsed) {
148
162
  const scope = this._getScope(parsed)
149
- const name = parsed.flags.n || parsed.flags.name || ''
163
+ const name = parsed.flags.name || ''
150
164
  const email = parsed.flags.e || parsed.flags.email || ''
151
165
 
152
166
  if (name || email) {
@@ -209,7 +223,7 @@ export class GitGroup {
209
223
  const workDir = parsed.flags.d || parsed.flags.dir || process.cwd()
210
224
  const ref = parsed.positional[1]
211
225
 
212
- if (parsed.flags.unset === 'true') {
226
+ if (parsed.flags.remove === 'true' || parsed.flags.unset === 'true') {
213
227
  const ok = unsetMark(workDir)
214
228
  output({ mark: 'removed', ok })
215
229
  return
@@ -230,7 +244,7 @@ export class GitGroup {
230
244
  console.error('\nTips:')
231
245
  console.error(' opm git log -d <dir> -n 10 # view commit history without cd')
232
246
  console.error(' opm git export mark <commit> # change diff base to another commit')
233
- console.error(' opm git export mark --unset # remove current mark')
247
+ console.error(' opm git export mark --remove # remove current mark')
234
248
  console.error(' opm git export # export diff (mark -> HEAD)')
235
249
  this._printRecentLog(workDir)
236
250
  return
@@ -250,7 +264,7 @@ export class GitGroup {
250
264
  process.exit(1)
251
265
  }
252
266
 
253
- const fromRaw = parsed.flags.f || parsed.flags.from || ''
267
+ const fromRaw = parsed.flags.from || ''
254
268
  let fromRef
255
269
  if (fromRaw) {
256
270
  const n = parseInt(fromRaw, 10)
@@ -271,7 +285,7 @@ export class GitGroup {
271
285
  if (!outFile) {
272
286
  const opts = { encoding: 'utf8', timeout: 5000, cwd: resolvedDir, stdio: ['pipe', 'pipe', 'pipe'] }
273
287
  const gitRoot = execSync('git rev-parse --show-toplevel', opts).trim()
274
- const name = parsed.flags.n || parsed.flags.name || ''
288
+ const name = parsed.flags.name || ''
275
289
  const fileName = name ? `${name}.diff` : (() => {
276
290
  const fromShort = execSync(`git rev-parse --short ${fromRef}`, opts).trim()
277
291
  const toShort = execSync('git rev-parse --short HEAD', opts).trim()
@@ -331,7 +345,7 @@ Export subcommands:
331
345
  export mark [-d dir] Show current diff base mark
332
346
  export mark 0|HEAD [-d dir] Set diff base mark to current HEAD
333
347
  export mark <commit> [-d dir] Set diff base mark to a commit
334
- export mark --unset [-d dir] Remove the diff base mark
348
+ export mark --remove [-d dir] Remove the diff base mark
335
349
  export [-d dir] Export diff (mark -> HEAD)
336
350
  export -f <n> [-d dir] Export diff (HEAD~n -> HEAD, override mark)
337
351
  export -f <ref> [-d dir] Export diff (<ref> -> HEAD, override mark)
@@ -344,11 +358,15 @@ Options:
344
358
  -n, --count <n> Number of commits to show (log, default: 10)
345
359
  -e, --email <email> User email (user)
346
360
  -o, --output <file> Output file path (export)
347
- -f, --from <n>|<ref> Override from ref (export)
361
+ --from <n>|<ref> Override from ref (export)
348
362
  -f, --force Force overwrite existing config (init)
349
- --unset <key> Unset a config key (config)
363
+ --unset <key> Unset a config key (config only)
364
+ --remove Remove the diff base mark (export mark)
350
365
  -h, --help Show this help
351
366
 
367
+ Note: -n 在 log 中为 --count 数量,user/export 中用 --name 名称(无短形式);
368
+ -f 在 init 中为 --force 布尔,export 中用 --from 带值(无短形式)。
369
+
352
370
  Examples:
353
371
  opm git version Show installed git version
354
372
  opm git install Install git via system package manager
@@ -365,12 +383,11 @@ Examples:
365
383
  opm git export mark 0 Set diff base to current HEAD (cwd)
366
384
  opm git export mark HEAD Set diff base to current HEAD (cwd)
367
385
  opm git export mark 3af967b Set diff base at commit (cwd)
368
- opm git export mark --unset Remove diff base mark (cwd)
369
- opm git export mark abc -d /repo Set diff base at commit in /repo
386
+ opm git export mark --remove Remove diff base mark (cwd) opm git export mark abc -d /repo Set diff base at commit in /repo
370
387
  opm git export Export diff in current repo (mark -> HEAD)
371
- opm git export -f 2 Override: HEAD~2 -> HEAD (current repo)
388
+ opm git export --from 2 Override: HEAD~2 -> HEAD (current repo)
372
389
  opm git export -d /path/to/repo Export diff in specified repo
373
- opm git export -n feature Save as feature.diff
390
+ opm git export --name feature Save as feature.diff
374
391
  opm git import file.diff Import diff file (current repo)
375
392
  opm git import file.diff -d /repo Import diff file into specified repo
376
393
  `)
@@ -18,9 +18,11 @@ import { getConfig, getSection, getUserConfigDir } from '../../config.js'
18
18
  import { output } from '../../formatter.js'
19
19
  import { getShareServer, pingServer, uploadShare, getShare, getLatestShare, getHtmlUrl, listShares, deleteShare, clearShares } from './client.js'
20
20
  import { startServer } from './server.js'
21
+ import { readClipboard, writeClipboard } from '../clipboard.js'
21
22
 
22
23
  const DEFAULT_PASSWORD = '0000'
23
24
  const ZIP_TMP_PREFIX = 'opm-share-'
25
+ const CLIPBOARD_ID = 'clipboard'
24
26
 
25
27
  /**
26
28
  * 生成默认标题(内容前 30 字符折叠空白 + ...,≤30 用原文)
@@ -109,9 +111,11 @@ const ACTIONS = {
109
111
  open: { desc: 'Open shared content in browser (id optional; defaults to latest)' },
110
112
  list: { desc: 'List recent shares (-n count, default 10)' },
111
113
  delete: { desc: 'Delete a share by id' },
112
- clear: { desc: 'Clean up shares by days and/or keep count' },
114
+ clear: { desc: 'Keep shares by days and/or count (--days n keeps last n days, --keep n keeps n most recent)' },
113
115
  config: { desc: 'Show current share config section' },
114
116
  server: { desc: 'Manage local server (start/stop/status)' },
117
+ copy: { desc: 'Copy system clipboard to server (id=clipboard; skips when clipboard is empty)' },
118
+ paste: { desc: 'Fetch server clipboard (id=clipboard) back into system clipboard and print' },
115
119
  help: { desc: 'Show this help' },
116
120
  }
117
121
 
@@ -152,6 +156,10 @@ export class ShareGroup {
152
156
  return this._config(parsed)
153
157
  case 'server':
154
158
  return this._server(parsed)
159
+ case 'copy':
160
+ return this._copy(parsed)
161
+ case 'paste':
162
+ return this._paste(parsed)
155
163
  default:
156
164
  return this._upload(parseFlags([action, ...rest]))
157
165
  }
@@ -188,7 +196,7 @@ export class ShareGroup {
188
196
  * @returns {string}
189
197
  */
190
198
  _resolvePassword(parsed, positional = '') {
191
- const flag = parsed.flags.p || parsed.flags.password || ''
199
+ const flag = parsed.flags.password || ''
192
200
  if (flag && flag !== 'true') return flag
193
201
  const cfgPassword = this._getShareConfig().password
194
202
  if (cfgPassword) return cfgPassword
@@ -234,7 +242,7 @@ export class ShareGroup {
234
242
 
235
243
  async _upload(parsed) {
236
244
  const title = parsed.flags.t || parsed.flags.title || ''
237
- const file = parsed.flags.f || parsed.flags.file || ''
245
+ const file = parsed.flags.file || ''
238
246
  const id = parsed.flags.i || parsed.flags.id || ''
239
247
  const password = this._resolvePassword(parsed)
240
248
 
@@ -271,7 +279,7 @@ export class ShareGroup {
271
279
  }
272
280
 
273
281
  async _zip(parsed) {
274
- const inputPath = parsed.positional[0] || parsed.flags.f || parsed.flags.file || ''
282
+ const inputPath = parsed.positional[0] || parsed.flags.file || ''
275
283
  const title = parsed.flags.t || parsed.flags.title || ''
276
284
  const id = parsed.flags.i || parsed.flags.id || ''
277
285
  const password = this._resolvePassword(parsed)
@@ -477,12 +485,12 @@ export class ShareGroup {
477
485
  }
478
486
 
479
487
  async _clear(parsed) {
480
- const days = parseInt(parsed.flags.days || '0', 10) || 0
488
+ const days = parseFloat(parsed.flags.days || '0') || 0
481
489
  const keep = parseInt(parsed.flags.keep || parsed.flags.n || '0', 10) || 0
482
490
 
483
491
  if (!days && !keep) {
484
492
  console.error('Usage: opm share clear [--days <n>] [--keep <n>]')
485
- console.error(' --days <n> Delete shares older than n days')
493
+ console.error(' --days <n> Keep shares from the last n days (integer = aligned to local midnight, fraction = hour window; default keeps today)')
486
494
  console.error(' --keep <n> Keep only the most recent n shares')
487
495
  console.error(' Both can be combined: clear --days 7 --keep 50')
488
496
  process.exit(1)
@@ -667,6 +675,50 @@ Environment variables (for server.js direct execution):
667
675
  output({ running: true, ...pidInfo })
668
676
  }
669
677
 
678
+ /**
679
+ * opm copy — 将系统剪切板内容以 id=clipboard 上传到服务器
680
+ * 剪切板为空(trim 后)时提示并停止。
681
+ * @param {object} parsed
682
+ */
683
+ async _copy(parsed) {
684
+ const content = readClipboard()
685
+ if (!content.trim()) {
686
+ console.error('[opm share] Clipboard is empty, nothing to upload.')
687
+ process.exit(1)
688
+ }
689
+
690
+ const password = this._resolvePassword(parsed)
691
+ const baseUrl = await this._ensureReachable()
692
+ const result = await uploadShare(baseUrl, {
693
+ title: defaultTitle(content),
694
+ content,
695
+ password,
696
+ id: CLIPBOARD_ID,
697
+ })
698
+ output({ ...result, copied: true })
699
+ }
700
+
701
+ /**
702
+ * opm paste — 取回服务器上 id=clipboard 的内容,写回系统剪切板并显示
703
+ * @param {object} parsed
704
+ */
705
+ async _paste(parsed) {
706
+ const password = this._resolvePassword(parsed, parsed.positional[0] || '')
707
+ const baseUrl = await this._ensureReachable()
708
+ const result = await getShare(baseUrl, CLIPBOARD_ID, password)
709
+
710
+ const content = result.content || ''
711
+ writeClipboard(content)
712
+ output({
713
+ pasted: true,
714
+ id: result.id,
715
+ title: result.title,
716
+ created: result.created,
717
+ length: content.length,
718
+ content,
719
+ })
720
+ }
721
+
670
722
  printHelp() {
671
723
  const actionLines = Object.entries(ACTIONS)
672
724
  .map(([sig, { desc }]) => ` ${sig.padEnd(12)} ${desc}`)
@@ -690,9 +742,9 @@ Shorthand:
690
742
 
691
743
  Options:
692
744
  -t, --title <title> Title (default: first 30 chars of content)
693
- -f, --file <path> Read content from file
745
+ --file <path> Read content from file
694
746
  -i, --id <id> Custom share id (upload/zip; easier to remember)
695
- -p, --password <password> Password (default: config share.password, fallback 0000)
747
+ --password <password> Password (default: config share.password, fallback 0000)
696
748
  -n, --count <n> Number of items (list, default: 10)
697
749
  -d, --dir <dir> Extract zip to this directory (get)
698
750
  -h, --help Show this help
@@ -703,14 +755,18 @@ Server config (config.json5):
703
755
  share.serve.host / .port / .dataDir Local serve parameters
704
756
 
705
757
  Examples:
758
+ opm share copy Copy system clipboard to server (id=clipboard)
759
+ opm share paste Fetch server clipboard back to system clipboard
760
+ opm copy Top-level shortcut for "opm share copy"
761
+ opm paste Top-level shortcut for "opm share paste"
706
762
  opm share config Show current share config
707
763
  opm share server start Start local server (background)
708
764
  opm share server status Check if server is running
709
765
  opm share server stop Stop local server
710
766
  opm share "hello world" -t test Upload text
711
767
  opm share "hello world" -i myid Upload text with custom id "myid"
712
- opm share "hello world" -p 1234 Upload text with password override
713
- opm share -f doc.md Upload file (title auto from content)
768
+ opm share "hello world" --password 1234 Upload text with password override
769
+ opm share --file doc.md Upload file (title auto from content)
714
770
  opm share -f doc.md -t "README" Upload file with custom title
715
771
  echo "hello" | opm share Upload from stdin
716
772
  opm share zip ./src Upload folder as zip
@@ -718,7 +774,7 @@ Examples:
718
774
  opm share get Get the latest share (JSON)
719
775
  opm share get myid Get share by custom id
720
776
  opm share get abc12345 Get share by random id
721
- opm share get abc12345 -p 1234 Get with password override
777
+ opm share get abc12345 --password 1234 Get with password override
722
778
  opm share get abc12345 -d ./out Download and extract zip to ./out
723
779
  opm share view View the latest share content
724
780
  opm share view myid View share by custom id
@@ -727,7 +783,7 @@ Examples:
727
783
  opm share list List recent 10 shares
728
784
  opm share list -n 5 List recent 5 shares
729
785
  opm share delete abc12345 Delete a share by id
730
- opm share clear --days 7 Delete shares older than 7 days
786
+ opm share clear --days 7 Keep shares from the last 7 days
731
787
  opm share clear --keep 50 Keep only the most recent 50 shares
732
788
  opm share clear --days 7 --keep 50 Both combined
733
789
  `)
@@ -300,8 +300,18 @@ function handleDelete(req, res, dataDir, id) {
300
300
  sendJson(res, 200, { deleted: true, id })
301
301
  }
302
302
 
303
+ /**
304
+ * 某毫秒时间戳所在「天」的本地零点(与 opencode clear 语义一致)
305
+ * @param {number} ts - epoch ms
306
+ * @returns {number}
307
+ */
308
+ function startOfDayLocal(ts) {
309
+ const d = new Date(ts)
310
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
311
+ }
312
+
303
313
  function handleClear(req, res, dataDir, url) {
304
- const days = parseInt(url.searchParams.get('days') || '0', 10) || 0
314
+ const days = parseFloat(url.searchParams.get('days') || '0') || 0
305
315
  const keep = parseInt(url.searchParams.get('keep') || '0', 10) || 0
306
316
 
307
317
  let files = fs.readdirSync(dataDir)
@@ -315,7 +325,11 @@ function handleClear(req, res, dataDir, url) {
315
325
  const now = Date.now()
316
326
 
317
327
  if (days > 0) {
318
- const cutoff = now - days * 24 * 60 * 60 * 1000
328
+ // 语义与 opencode clear 统一:保留最近 n 天(含今天)。
329
+ // 整数天对齐本地零点(删除 今天零点-(n-1)天 之前);小数天 = 小时级滚动窗口(0.5 = 12h)
330
+ const cutoff = Number.isInteger(days)
331
+ ? startOfDayLocal(now) - (days - 1) * 86400000
332
+ : now - days * 86400000
319
333
  for (const f of files) {
320
334
  const createdMs = new Date(f.created).getTime()
321
335
  if (createdMs < cutoff) {
@@ -33,7 +33,7 @@ const COMPONENTS = [
33
33
 
34
34
  const ACTIONS = {
35
35
  version: { desc: 'Show installed version of each component' },
36
- versions: { desc: 'Show latest available versions (default 10, -n <num>)' },
36
+ versions: { desc: 'Show latest available versions (default 10, -n <count>)' },
37
37
  installed: { desc: 'Show install status (true/false) of each component' },
38
38
  install: { desc: 'Mirror check + ensure runtime + write registry env + blues-lib + ww init' },
39
39
  uninstall: { desc: 'Uninstall all components in reverse order' },
@@ -96,7 +96,7 @@ export class WebworkGroup {
96
96
 
97
97
  async _versions(parsed) {
98
98
  const proxy = this._resolveProxy(parsed)
99
- const num = parseInt(parsed.flags.n || parsed.flags.num || '10', 10)
99
+ const num = parseInt(parsed.flags.n || parsed.flags.count || parsed.flags.num || '10', 10)
100
100
 
101
101
  const result = {}
102
102
 
@@ -274,6 +274,10 @@ export class WebworkGroup {
274
274
  await this._runBatch('uninstall', reversed, proxy, (key, p) => this._uninstallComponent(key, p))
275
275
  }
276
276
 
277
+ // TODO(P2-6): webwork 自身需要提供真正的 uninstall 命令。
278
+ // 当前 _uninstall 仅卸载 blues-lib(python/node 作为系统依赖跳过);
279
+ // 自身的完整卸载方案(registry env 清理、ww 相关配置移除等)待实现。
280
+
277
281
  /**
278
282
  * 检查镜像可达性(HEAD 请求,不可达则抛错)
279
283
  * 必查:npm / pip(blues-lib 依赖,环境必然可用)