@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.
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * opm share 命令组 — 文本分享服务
3
3
  *
4
- * 命令:upload / zip / get / view / open / list / delete / clear / config / server / help
4
+ * 命令:upload / zip / get / unzip / sync / open / list / delete / clear / config / server / help
5
5
  * 服务地址由 config.share.server.active 解析(不支持命令行 -s 参数)。
6
6
  * 需要服务调用的命令执行前先 ping 验证可达性;
7
7
  * 若 active=local 且服务未启动,自动后台启动后再执行。
8
8
  * server 子命令:start(后台启动)/ stop(停止)/ status(查询状态)。
9
- * zip 上传文件/文件夹(压缩为 zip 后 base64 上传),get -d 下载并解压。
9
+ * 未设 -i 时 id 默认 opm_share(临时槽,下次无 -i 上传即覆盖);zip 上传后用 unzip/sync 取回。
10
10
  */
11
11
 
12
12
  import fs from 'node:fs'
@@ -14,23 +14,46 @@ import path from 'node:path'
14
14
  import os from 'node:os'
15
15
  import { spawn, spawnSync } from 'node:child_process'
16
16
  import { parseFlags } from '../../cli/helpers/args.js'
17
+ import { JSON5 } from '@wwkit/shared'
17
18
  import { getConfig, getSection, getUserConfigDir } from '../../config.js'
18
19
  import { output } from '../../formatter.js'
19
- import { getShareServer, pingServer, uploadShare, getShare, getLatestShare, getHtmlUrl, listShares, deleteShare, clearShares } from './client.js'
20
+ import { getShareServer, pingServer, uploadShare, getShare, getHtmlUrl, listShares, deleteShare, clearShares } from './client.js'
20
21
  import { startServer } from './server.js'
22
+ import { readClipboard, writeClipboard } from '../clipboard.js'
21
23
 
22
24
  const DEFAULT_PASSWORD = '0000'
23
25
  const ZIP_TMP_PREFIX = 'opm-share-'
26
+ const CLIPBOARD_ID = 'clipboard'
27
+ const DEFAULT_TEMP_ID = 'opm_share'
24
28
 
25
29
  /**
26
- * 生成默认标题(内容前 30 字符折叠空白 + ...,≤30 用原文)
30
+ * 将 content 美化为可读字符串:若为 JSON5 可解析文本则返回 2 空格缩进的 JSON,否则原样返回
31
+ * 用于默认输出(直接打印到 stdout,保留真实换行)
27
32
  * @param {string} content
28
33
  * @returns {string}
29
34
  */
30
- function defaultTitle(content) {
31
- const folded = content.replace(/\s+/g, ' ').trim()
32
- if (folded.length <= 30) return folded
33
- return folded.slice(0, 30) + '...'
35
+ function formatContent(content) {
36
+ if (typeof content !== 'string' || !content.trim()) return content || ''
37
+ try {
38
+ return JSON.stringify(JSON5.parse(content), null, 2)
39
+ } catch {
40
+ return content
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 将 content 解析为结构化值:若为 JSON5 可解析则返回解析后的对象/数组,否则返回原字符串
46
+ * 用于 --detail 模式,使 JSON content 在 dict 中以嵌套结构显示而非转义字符串
47
+ * @param {string} content
48
+ * @returns {*}
49
+ */
50
+ function parseContentJson(content) {
51
+ if (typeof content !== 'string' || !content.trim()) return content
52
+ try {
53
+ return JSON5.parse(content)
54
+ } catch {
55
+ return content
56
+ }
34
57
  }
35
58
 
36
59
  /**
@@ -45,6 +68,25 @@ function expandTilde(p) {
45
68
  return p
46
69
  }
47
70
 
71
+ /**
72
+ * 计算跨系统可还原的 sourcePath:保留 ~ 锚点
73
+ * - 入参以 ~/ 开头(用户引号保留)→ 原样
74
+ * - 解析后绝对路径落在 home 下 → 反推 ~
75
+ * - home 之外(绝对/相对)→ 存绝对路径(无 ~ 锚点,sync 回退默认目录)
76
+ * @param {string} inputPath - 原始入参
77
+ * @param {string} resolved - path.resolve 后的绝对路径
78
+ * @returns {string}
79
+ */
80
+ function tildefy(inputPath, resolved) {
81
+ if (inputPath.startsWith('~/')) return inputPath
82
+ const home = os.homedir()
83
+ if (home && resolved.startsWith(home)) {
84
+ const rest = resolved.slice(home.length)
85
+ return '~' + rest
86
+ }
87
+ return resolved
88
+ }
89
+
48
90
  /**
49
91
  * 浏览器打开 URL
50
92
  * @param {string} url
@@ -102,16 +144,19 @@ function isProcessAlive(pid) {
102
144
  }
103
145
 
104
146
  const ACTIONS = {
105
- upload: { desc: 'Upload text content (positional, -f file, or stdin; -i for custom id)' },
106
- zip: { desc: 'Upload file/folder as compressed zip (get -d to extract; -i for custom id)' },
107
- get: { desc: 'Get shared content (id optional; defaults to latest; -d to extract zip)' },
108
- view: { desc: 'View shared content (id optional; defaults to latest)' },
109
- open: { desc: 'Open shared content in browser (id optional; defaults to latest)' },
147
+ upload: { desc: 'Upload text (positional string OR existing file path, auto-detected; -f file; stdin; -i for custom id; default id=opm_share)' },
148
+ zip: { desc: 'Upload file/folder as zip (preserves ~ -anchored sourcePath; -i for custom id; default id=opm_share)' },
149
+ get: { desc: 'Get text content (id optional; defaults to opm_share; --detail for full dict; -o file to save)' },
150
+ unzip: { desc: 'Download and extract a zip share <id> (dir optional, default ~/.config/opm/share/<id>/; --dry-run to preview)' },
151
+ sync: { desc: 'Sync a zip share <id> to its original ~ -anchored path (--dry-run to preview; fallback to default dir)' },
152
+ open: { desc: 'Open shared content in browser (id optional; defaults to opm_share)' },
110
153
  list: { desc: 'List recent shares (-n count, default 10)' },
111
154
  delete: { desc: 'Delete a share by id' },
112
- clear: { desc: 'Clean up shares by days and/or keep count' },
155
+ clear: { desc: 'Keep shares by days and/or count (--days n keeps last n days, --keep n keeps n most recent)' },
113
156
  config: { desc: 'Show current share config section' },
114
157
  server: { desc: 'Manage local server (start/stop/status)' },
158
+ copy: { desc: 'Copy system clipboard to server (id=clipboard; skips when clipboard is empty)' },
159
+ paste: { desc: 'Fetch server clipboard (id=clipboard) back into system clipboard and print' },
115
160
  help: { desc: 'Show this help' },
116
161
  }
117
162
 
@@ -129,7 +174,7 @@ export class ShareGroup {
129
174
  return
130
175
  }
131
176
 
132
- const parsed = parseFlags(rest)
177
+ const parsed = parseFlags(rest, { booleans: ['detail'] })
133
178
 
134
179
  switch (action) {
135
180
  case 'upload':
@@ -138,8 +183,10 @@ export class ShareGroup {
138
183
  return this._zip(parsed)
139
184
  case 'get':
140
185
  return this._get(parsed)
141
- case 'view':
142
- return this._view(parsed)
186
+ case 'unzip':
187
+ return this._unzip(parsed)
188
+ case 'sync':
189
+ return this._sync(parsed)
143
190
  case 'open':
144
191
  return this._open(parsed)
145
192
  case 'list':
@@ -152,6 +199,10 @@ export class ShareGroup {
152
199
  return this._config(parsed)
153
200
  case 'server':
154
201
  return this._server(parsed)
202
+ case 'copy':
203
+ return this._copy(parsed)
204
+ case 'paste':
205
+ return this._paste(parsed)
155
206
  default:
156
207
  return this._upload(parseFlags([action, ...rest]))
157
208
  }
@@ -182,17 +233,18 @@ export class ShareGroup {
182
233
  }
183
234
 
184
235
  /**
185
- * 解析密码:-p/--password 参数 > 配置 share.password > 位置参数(get/open)> 默认 0000
236
+ * 解析密码:-p/--password 参数 > 位置参数(get/open)> 配置 share.password > 默认 0000
186
237
  * @param {object} parsed - parseFlags 结果
187
238
  * @param {string} [positional] - 位置参数密码(get/open 的第二个位置参数)
188
239
  * @returns {string}
189
240
  */
190
241
  _resolvePassword(parsed, positional = '') {
191
- const flag = parsed.flags.p || parsed.flags.password || ''
242
+ const flag = parsed.flags.password || parsed.flags.p || ''
192
243
  if (flag && flag !== 'true') return flag
244
+ if (positional) return positional
193
245
  const cfgPassword = this._getShareConfig().password
194
246
  if (cfgPassword) return cfgPassword
195
- return positional || DEFAULT_PASSWORD
247
+ return DEFAULT_PASSWORD
196
248
  }
197
249
 
198
250
  /**
@@ -233,9 +285,8 @@ export class ShareGroup {
233
285
  }
234
286
 
235
287
  async _upload(parsed) {
236
- const title = parsed.flags.t || parsed.flags.title || ''
237
- const file = parsed.flags.f || parsed.flags.file || ''
238
- const id = parsed.flags.i || parsed.flags.id || ''
288
+ const file = parsed.flags.file || parsed.flags.f || ''
289
+ const id = parsed.flags.i || parsed.flags.id || DEFAULT_TEMP_ID
239
290
  const password = this._resolvePassword(parsed)
240
291
 
241
292
  let content = ''
@@ -243,47 +294,60 @@ export class ShareGroup {
243
294
  if (file) {
244
295
  const resolved = path.resolve(file)
245
296
  if (!fs.existsSync(resolved)) {
246
- console.error(`File not found: ${resolved}`)
297
+ console.error(`[opm share] File not found: ${resolved}`)
247
298
  process.exit(1)
248
299
  }
249
300
  content = fs.readFileSync(resolved, 'utf8')
250
301
  } else if (parsed.positional.length > 0) {
251
- content = parsed.positional.join(' ')
302
+ const first = parsed.positional[0]
303
+ const expandedFirst = expandTilde(first)
304
+ if (parsed.positional.length === 1 && fs.existsSync(expandedFirst)) {
305
+ const stat = fs.statSync(expandedFirst)
306
+ if (stat.isFile()) {
307
+ content = fs.readFileSync(expandedFirst, 'utf8')
308
+ } else if (stat.isDirectory()) {
309
+ console.error(`[opm share] "${first}" is a directory. Use "opm share zip ${first}" to upload as zip.`)
310
+ process.exit(1)
311
+ }
312
+ }
313
+ if (!content) {
314
+ content = parsed.positional.join(' ')
315
+ }
252
316
  } else if (!process.stdin.isTTY) {
253
317
  content = fs.readFileSync(0, 'utf8')
254
318
  }
255
319
 
256
320
  if (!content) {
257
- console.error('Usage: opm share upload "<content>" [-t title] [-f file] [-i id] [-p password]')
258
- console.error(' Or: echo "..." | opm share upload [-t title] [-i id] [-p password]')
259
- console.error(' Or: opm share "<content>" [-t title] [-i id] [-p password] (shorthand without "upload")')
321
+ console.error('Usage: opm share upload "<content>" [-f file] [-i id] [-p password]')
322
+ console.error(' Or: echo "..." | opm share upload [-i id] [-p password]')
323
+ console.error(' Or: opm share "<content>" [-i id] [-p password] (shorthand without "upload")')
324
+ console.error(' No -i → id defaults to "opm_share" (temporary slot, overwritten on next upload without -i)')
260
325
  process.exit(1)
261
326
  }
262
327
 
263
328
  const baseUrl = await this._ensureReachable()
264
329
  const result = await uploadShare(baseUrl, {
265
- title: title || defaultTitle(content),
266
330
  content,
267
331
  password,
268
- ...(id ? { id } : {}),
332
+ id,
269
333
  })
270
334
  output(result)
271
335
  }
272
336
 
273
337
  async _zip(parsed) {
274
- const inputPath = parsed.positional[0] || parsed.flags.f || parsed.flags.file || ''
275
- const title = parsed.flags.t || parsed.flags.title || ''
276
- const id = parsed.flags.i || parsed.flags.id || ''
338
+ const inputPath = parsed.positional[0] || parsed.flags.file || parsed.flags.f || ''
339
+ const id = parsed.flags.i || parsed.flags.id || DEFAULT_TEMP_ID
277
340
  const password = this._resolvePassword(parsed)
278
341
 
279
342
  if (!inputPath) {
280
- console.error('Usage: opm share zip <path> [-t title] [-i id] [-p password]')
343
+ console.error('Usage: opm share zip <path> [-i id] [-p password]')
344
+ console.error(' No -i → id defaults to "opm_share" (temporary slot, overwritten on next upload without -i)')
281
345
  process.exit(1)
282
346
  }
283
347
 
284
- const resolved = path.resolve(inputPath)
348
+ const resolved = path.resolve(expandTilde(inputPath))
285
349
  if (!fs.existsSync(resolved)) {
286
- console.error(`Path not found: ${resolved}`)
350
+ console.error(`[opm share] Path not found: ${resolved}`)
287
351
  process.exit(1)
288
352
  }
289
353
 
@@ -292,11 +356,13 @@ export class ShareGroup {
292
356
  if (isDir) {
293
357
  const entries = fs.readdirSync(resolved)
294
358
  if (entries.length === 0) {
295
- console.error(`Directory is empty: ${resolved}`)
359
+ console.error(`[opm share] Directory is empty: ${resolved}`)
296
360
  process.exit(1)
297
361
  }
298
362
  }
299
363
 
364
+ const sourcePath = tildefy(inputPath, resolved)
365
+
300
366
  const baseName = path.basename(resolved)
301
367
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), ZIP_TMP_PREFIX))
302
368
  const zipPath = path.join(tmpDir, `${baseName}.zip`)
@@ -309,7 +375,7 @@ export class ShareGroup {
309
375
 
310
376
  if (zipResult.status !== 0) {
311
377
  const stderr = zipResult.stderr ? zipResult.stderr.toString().trim() : 'unknown error'
312
- console.error(`Zip failed: ${stderr}`)
378
+ console.error(`[opm share] Zip failed: ${stderr}`)
313
379
  fs.rmSync(tmpDir, { recursive: true, force: true })
314
380
  process.exit(1)
315
381
  }
@@ -321,64 +387,178 @@ export class ShareGroup {
321
387
 
322
388
  const baseUrl = await this._ensureReachable()
323
389
  const result = await uploadShare(baseUrl, {
324
- title: title || `${baseName}.zip`,
390
+ title: `${baseName}.zip`,
325
391
  content,
326
392
  password,
327
393
  type: 'zip',
328
394
  filename: `${baseName}.zip`,
329
- ...(id ? { id } : {}),
395
+ sourcePath,
396
+ id,
330
397
  })
331
398
  output(result)
332
399
  }
333
400
 
334
401
  async _get(parsed) {
335
- const id = parsed.positional[0] || ''
402
+ const id = parsed.positional[0] || DEFAULT_TEMP_ID
336
403
  const password = this._resolvePassword(parsed, parsed.positional[1] || '')
404
+ const detail = parsed.flags.detail === 'true' || !!parsed.flags.detail
405
+ const outputFile = parsed.flags.output || parsed.flags.o || ''
337
406
  const extractDir = parsed.flags.d || parsed.flags.dir || ''
338
407
 
408
+ if (extractDir) {
409
+ console.error('[opm share] "get -d/--dir" is deprecated. Use "opm share unzip <id> <dir>" instead.')
410
+ process.exit(1)
411
+ }
412
+
339
413
  const baseUrl = await this._ensureReachable()
340
- const result = id
341
- ? await getShare(baseUrl, id, password)
342
- : await getLatestShare(baseUrl, password)
414
+ const result = await getShare(baseUrl, id, password)
343
415
 
344
- if (result.type === 'zip' && extractDir) {
345
- await this._extractZip(result, extractDir)
416
+ if (detail) {
417
+ output({ ...result, content: parseContentJson(result.content) })
346
418
  return
347
419
  }
348
420
 
349
- output(result)
350
- }
421
+ if ((result.type || 'text') === 'zip') {
422
+ console.error(`[opm share] This is a zip share. Use "opm share unzip ${id || result.id}" to extract, or "opm share get ${id || result.id} --detail" for metadata.`)
423
+ process.exit(1)
424
+ }
351
425
 
352
- async _extractZip(result, dir) {
353
- const resolvedDir = path.resolve(dir)
354
- fs.mkdirSync(resolvedDir, { recursive: true })
426
+ if (outputFile) {
427
+ const resolved = path.resolve(expandTilde(outputFile))
428
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
429
+ const buf = Buffer.from(formatContent(result.content || ''), 'utf8')
430
+ fs.writeFileSync(resolved, buf)
431
+ console.error(`[opm share] Wrote ${resolved} (${buf.length} bytes)`)
432
+ return
433
+ }
434
+
435
+ const formatted = formatContent(result.content || '')
436
+ process.stdout.write(formatted)
437
+ if (!formatted.endsWith('\n')) {
438
+ process.stdout.write('\n')
439
+ }
440
+ }
355
441
 
442
+ /**
443
+ * 解码 zip content 并解压到目标目录(或 dry-run 时解压到临时目录仅列条目)
444
+ * @param {object} result - getShare 返回的 share 对象
445
+ * @param {string} targetDir - 解压目标目录(绝对路径)
446
+ * @param {boolean} dryRun - 仅预览,不落盘
447
+ * @returns {Promise<{files: Array, targetDir: string, extracted: boolean}>}
448
+ */
449
+ async _extractZipTo(result, targetDir, dryRun) {
356
450
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), ZIP_TMP_PREFIX))
357
451
  const zipPath = path.join(tmpDir, result.filename || `${result.id}.zip`)
452
+ try {
453
+ fs.writeFileSync(zipPath, Buffer.from(result.content, 'base64'))
454
+ const { default: extractZip } = await import('extract-zip')
455
+ const extractTarget = dryRun ? tmpDir : targetDir
456
+ if (!dryRun) fs.mkdirSync(targetDir, { recursive: true })
457
+ await extractZip(zipPath, { dir: extractTarget })
458
+ fs.unlinkSync(zipPath)
459
+ const files = this._listTopLevel(extractTarget)
460
+ return { files, targetDir, extracted: !dryRun }
461
+ } catch (err) {
462
+ throw new Error(`Extract failed: ${err.message}`)
463
+ } finally {
464
+ fs.rmSync(tmpDir, { recursive: true, force: true })
465
+ }
466
+ }
467
+
468
+ async _unzip(parsed) {
469
+ const id = parsed.positional[0] || ''
470
+ const dir = parsed.positional[1] || ''
471
+ const password = this._resolvePassword(parsed)
472
+ const dryRun = parsed.flags['dry-run'] === 'true'
473
+
474
+ if (!id) {
475
+ console.error('Usage: opm share unzip <id> [dir] [--dry-run] [-p password]')
476
+ console.error(' <id> is required (zip share id to extract). Default dir: ~/.config/opm/share/<id>/')
477
+ process.exit(1)
478
+ }
479
+
480
+ const baseUrl = await this._ensureReachable()
481
+ const result = await getShare(baseUrl, id, password)
482
+
483
+ if ((result.type || 'text') !== 'zip') {
484
+ console.error(`[opm share] Not a zip share (type=${result.type || 'text'}). Use "opm share get ${id || result.id}" for text content.`)
485
+ process.exit(1)
486
+ }
487
+
488
+ const targetDir = dir
489
+ ? path.resolve(expandTilde(dir))
490
+ : path.join(getUserConfigDir(), 'share', id || result.id)
358
491
 
359
492
  try {
360
- const buf = Buffer.from(result.content, 'base64')
361
- fs.writeFileSync(zipPath, buf)
493
+ const { files, targetDir: target, extracted } = await this._extractZipTo(result, targetDir, dryRun)
494
+ output({
495
+ id: result.id,
496
+ title: result.title,
497
+ type: 'zip',
498
+ filename: result.filename,
499
+ sourcePath: result.sourcePath || '',
500
+ created: result.created,
501
+ dryRun,
502
+ extracted,
503
+ dir: target,
504
+ files,
505
+ })
506
+ } catch (err) {
507
+ console.error(`[opm share] ${err.message}`)
508
+ process.exit(1)
509
+ }
510
+ }
362
511
 
363
- const { default: extractZip } = await import('extract-zip')
364
- await extractZip(zipPath, { dir: resolvedDir })
512
+ async _sync(parsed) {
513
+ const id = parsed.positional[0] || ''
514
+ const password = this._resolvePassword(parsed)
515
+ const dryRun = parsed.flags['dry-run'] === 'true'
365
516
 
366
- const files = this._listTopLevel(resolvedDir)
517
+ if (!id) {
518
+ console.error('Usage: opm share sync <id> [--dry-run] [-p password]')
519
+ console.error(' <id> is required (zip share id to sync to its original ~ -anchored path)')
520
+ process.exit(1)
521
+ }
522
+
523
+ const baseUrl = await this._ensureReachable()
524
+ const result = await getShare(baseUrl, id, password)
525
+
526
+ if ((result.type || 'text') !== 'zip') {
527
+ console.error(`[opm share] Not a zip share (type=${result.type || 'text'}). Use "opm share get ${id || result.id}" for text content.`)
528
+ process.exit(1)
529
+ }
530
+
531
+ const sourcePath = result.sourcePath || ''
532
+ const defaultDir = path.join(getUserConfigDir(), 'share', id || result.id)
533
+ let targetDir
534
+ let mode
535
+ if (sourcePath.startsWith('~/')) {
536
+ const expanded = expandTilde(sourcePath)
537
+ targetDir = path.dirname(expanded)
538
+ mode = `sync → ${sourcePath} (expanded: ${expanded})`
539
+ } else {
540
+ targetDir = defaultDir
541
+ mode = `fallback (no ~ anchor; sourcePath=${sourcePath || '<none>'}) → default dir`
542
+ }
543
+
544
+ try {
545
+ const { files, targetDir: target, extracted } = await this._extractZipTo(result, targetDir, dryRun)
367
546
  output({
368
547
  id: result.id,
369
548
  title: result.title,
370
549
  type: 'zip',
371
550
  filename: result.filename,
551
+ sourcePath,
372
552
  created: result.created,
373
- extracted: true,
374
- dir: resolvedDir,
553
+ dryRun,
554
+ extracted,
555
+ mode,
556
+ dir: target,
375
557
  files,
376
558
  })
377
559
  } catch (err) {
378
- console.error(`Extract failed: ${err.message}`)
560
+ console.error(`[opm share] ${err.message}`)
379
561
  process.exit(1)
380
- } finally {
381
- fs.rmSync(tmpDir, { recursive: true, force: true })
382
562
  }
383
563
  }
384
564
 
@@ -398,62 +578,14 @@ export class ShareGroup {
398
578
  }
399
579
  }
400
580
 
401
- async _view(parsed) {
402
- const id = parsed.positional[0] || ''
403
- const password = this._resolvePassword(parsed, parsed.positional[1] || '')
404
-
405
- const baseUrl = await this._ensureReachable()
406
- const result = id
407
- ? await getShare(baseUrl, id, password)
408
- : await getLatestShare(baseUrl, password)
409
-
410
- if (result.type === 'zip') {
411
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), ZIP_TMP_PREFIX))
412
- try {
413
- const zipPath = path.join(tmpDir, result.filename || `${result.id}.zip`)
414
- const buf = Buffer.from(result.content, 'base64')
415
- fs.writeFileSync(zipPath, buf)
416
-
417
- const { default: extractZip } = await import('extract-zip')
418
- await extractZip(zipPath, { dir: tmpDir })
419
-
420
- fs.unlinkSync(zipPath)
421
-
422
- const files = this._listTopLevel(tmpDir)
423
- for (const f of files) {
424
- console.log(`${f.type === 'dir' ? 'd' : 'f'} ${f.name}`)
425
- }
426
- } catch (err) {
427
- console.error(`View zip failed: ${err.message}`)
428
- process.exit(1)
429
- } finally {
430
- fs.rmSync(tmpDir, { recursive: true, force: true })
431
- }
432
- return
433
- }
434
-
435
- process.stdout.write(result.content || '')
436
- if (!(result.content || '').endsWith('\n')) {
437
- process.stdout.write('\n')
438
- }
439
- }
440
-
441
581
  async _open(parsed) {
442
- const id = parsed.positional[0] || ''
582
+ const id = parsed.positional[0] || DEFAULT_TEMP_ID
443
583
  const password = this._resolvePassword(parsed, parsed.positional[1] || '')
444
584
 
445
585
  const baseUrl = await this._ensureReachable()
446
-
447
- if (id) {
448
- const htmlUrl = getHtmlUrl(baseUrl, id, password)
449
- openBrowser(htmlUrl)
450
- output({ opened: true, url: htmlUrl })
451
- } else {
452
- const result = await getLatestShare(baseUrl, password)
453
- const htmlUrl = getHtmlUrl(baseUrl, result.id, password)
454
- openBrowser(htmlUrl)
455
- output({ opened: true, id: result.id, url: htmlUrl })
456
- }
586
+ const htmlUrl = getHtmlUrl(baseUrl, id, password)
587
+ openBrowser(htmlUrl)
588
+ output({ opened: true, id, url: htmlUrl })
457
589
  }
458
590
 
459
591
  async _list(parsed) {
@@ -477,12 +609,12 @@ export class ShareGroup {
477
609
  }
478
610
 
479
611
  async _clear(parsed) {
480
- const days = parseInt(parsed.flags.days || '0', 10) || 0
612
+ const days = parseFloat(parsed.flags.days || '0') || 0
481
613
  const keep = parseInt(parsed.flags.keep || parsed.flags.n || '0', 10) || 0
482
614
 
483
615
  if (!days && !keep) {
484
616
  console.error('Usage: opm share clear [--days <n>] [--keep <n>]')
485
- console.error(' --days <n> Delete shares older than n days')
617
+ console.error(' --days <n> Keep shares from the last n days (integer = aligned to local midnight, fraction = hour window; default keeps today)')
486
618
  console.error(' --keep <n> Keep only the most recent n shares')
487
619
  console.error(' Both can be combined: clear --days 7 --keep 50')
488
620
  process.exit(1)
@@ -515,7 +647,7 @@ export class ShareGroup {
515
647
  return
516
648
  }
517
649
 
518
- console.error(`Unknown server subcommand: ${sub}`)
650
+ console.error(`[opm share] Unknown server subcommand: ${sub}`)
519
651
  console.error('Usage: opm share server [start|stop|status|help]')
520
652
  process.exit(1)
521
653
  }
@@ -667,6 +799,49 @@ Environment variables (for server.js direct execution):
667
799
  output({ running: true, ...pidInfo })
668
800
  }
669
801
 
802
+ /**
803
+ * opm copy — 将系统剪切板内容以 id=clipboard 上传到服务器
804
+ * 剪切板为空(trim 后)时提示并停止。
805
+ * @param {object} parsed
806
+ */
807
+ async _copy(parsed) {
808
+ const content = readClipboard()
809
+ if (!content.trim()) {
810
+ console.error('[opm share] Clipboard is empty, nothing to upload.')
811
+ process.exit(1)
812
+ }
813
+
814
+ const password = this._resolvePassword(parsed)
815
+ const baseUrl = await this._ensureReachable()
816
+ const result = await uploadShare(baseUrl, {
817
+ content,
818
+ password,
819
+ id: CLIPBOARD_ID,
820
+ })
821
+ output({ ...result, copied: true })
822
+ }
823
+
824
+ /**
825
+ * opm paste — 取回服务器上 id=clipboard 的内容,写回系统剪切板并显示
826
+ * @param {object} parsed
827
+ */
828
+ async _paste(parsed) {
829
+ const password = this._resolvePassword(parsed, parsed.positional[0] || '')
830
+ const baseUrl = await this._ensureReachable()
831
+ const result = await getShare(baseUrl, CLIPBOARD_ID, password)
832
+
833
+ const content = result.content || ''
834
+ writeClipboard(content)
835
+ output({
836
+ pasted: true,
837
+ id: result.id,
838
+ title: result.title,
839
+ created: result.created,
840
+ length: content.length,
841
+ content,
842
+ })
843
+ }
844
+
670
845
  printHelp() {
671
846
  const actionLines = Object.entries(ACTIONS)
672
847
  .map(([sig, { desc }]) => ` ${sig.padEnd(12)} ${desc}`)
@@ -684,17 +859,22 @@ Server subcommands:
684
859
  server status Check local server status
685
860
 
686
861
  Shorthand:
687
- opm share "<content>" [-t title] Upload (without "upload" keyword)
688
- opm share -f <file> Upload file content (title auto-generated)
689
- echo "..." | opm share [-t title] Upload from stdin
862
+ opm share "<content>" Upload text (without "upload" keyword)
863
+ opm share <file> Upload existing file content (auto-detected)
864
+ opm share -f <file> Upload file content (explicit)
865
+ echo "..." | opm share Upload from stdin
866
+
867
+ Without -i, id defaults to "opm_share" — a temporary slot overwritten on the next
868
+ upload/zip without -i. Use -i <id> for a persistent, named share.
690
869
 
691
870
  Options:
692
- -t, --title <title> Title (default: first 30 chars of content)
693
871
  -f, --file <path> Read content from file
694
- -i, --id <id> Custom share id (upload/zip; easier to remember)
872
+ -i, --id <id> Custom share id (default: opm_share, overwritten on next no-id upload)
695
873
  -p, --password <password> Password (default: config share.password, fallback 0000)
696
874
  -n, --count <n> Number of items (list, default: 10)
697
- -d, --dir <dir> Extract zip to this directory (get)
875
+ -o, --output <file> Write text content to file instead of stdout (get)
876
+ --detail Show full dict (get); default prints content only, JSON pretty-printed
877
+ --dry-run Preview only, do not write to disk (unzip/sync)
698
878
  -h, --help Show this help
699
879
 
700
880
  Server config (config.json5):
@@ -703,33 +883,41 @@ Server config (config.json5):
703
883
  share.serve.host / .port / .dataDir Local serve parameters
704
884
 
705
885
  Examples:
886
+ opm share copy Copy system clipboard to server (id=clipboard)
887
+ opm share paste Fetch server clipboard back to system clipboard
888
+ opm scopy Top-level shortcut for "opm share copy"
889
+ opm spaste Top-level shortcut for "opm share paste"
890
+ opm sget / szip / sunzip / ssync / sopen Other share top-level shortcuts
706
891
  opm share config Show current share config
707
892
  opm share server start Start local server (background)
708
893
  opm share server status Check if server is running
709
894
  opm share server stop Stop local server
710
- opm share "hello world" -t test Upload text
711
- 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)
714
- opm share -f doc.md -t "README" Upload file with custom title
895
+ opm share "hello world" Upload text (id=opm_share, overwritten on next no-id upload)
896
+ opm share "hello world" -i myid Upload text with custom id "myid" (persistent)
897
+ opm share "hello world" --password 1234 Upload text with password override
898
+ opm share doc.md Upload existing file content (auto-detected)
899
+ opm share -f doc.md Upload file content
715
900
  echo "hello" | opm share Upload from stdin
716
- opm share zip ./src Upload folder as zip
717
- opm share zip ./config.json5 Upload file as zip
718
- opm share get Get the latest share (JSON)
719
- opm share get myid Get share by custom id
720
- opm share get abc12345 Get share by random id
721
- opm share get abc12345 -p 1234 Get with password override
722
- opm share get abc12345 -d ./out Download and extract zip to ./out
723
- opm share view View the latest share content
724
- opm share view myid View share by custom id
725
- opm share open Open the latest share in browser
901
+ opm share zip ~/Projects/foo Upload folder as zip (sourcePath preserved as ~/Projects/foo)
902
+ opm share zip './src' -i mysrc Upload folder; quote ~ to preserve literal, or rely on auto re-tilde-ify
903
+ opm share get Get text share id=opm_share (content only; JSON pretty-printed if parseable)
904
+ opm share get myid Get text share by custom id (content only)
905
+ opm share get myid --detail Get share showing the full dict (content parsed as JSON if parseable)
906
+ opm share get myid -o out.json Write text content to out.json (no stdout)
907
+ opm share get myid --password 1234 Get with password override
908
+ opm share unzip myid Extract zip share myid → ~/.config/opm/share/myid/
909
+ opm share unzip myid ./out Extract zip share myid → ./out/
910
+ opm share unzip myid --dry-run Preview zip entries without extracting
911
+ opm share sync myid Sync zip share to its original ~ -anchored path (e.g. ~/Projects/foo)
912
+ opm share sync myid --dry-run Preview sync target path without extracting
913
+ opm share open Open share id=opm_share in browser
726
914
  opm share open myid Open share by custom id in browser
727
915
  opm share list List recent 10 shares
728
916
  opm share list -n 5 List recent 5 shares
729
917
  opm share delete abc12345 Delete a share by id
730
- opm share clear --days 7 Delete shares older than 7 days
918
+ opm share clear --days 7 Keep shares from the last 7 days
731
919
  opm share clear --keep 50 Keep only the most recent 50 shares
732
920
  opm share clear --days 7 --keep 50 Both combined
733
- `)
921
+ `)
734
922
  }
735
923
  }