@wwkit/opm 1.0.19 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwkit/opm",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
4
4
  "author": "bluesliu <langcai163@163.com>",
5
5
  "description": "Unified CLI — package management (npm/pip/dnf/apt) + config view + opencode maintenance",
6
6
  "type": "module",
@@ -44,7 +44,7 @@ export class NvmGroup extends ToolManagerGroup {
44
44
  throw new Error(`${nvmDir} is not a git repository (nvm was not installed via git clone). Run \`opm nvm uninstall -y\` then \`opm nvm install\` to reinstall.`)
45
45
  }
46
46
  execSync(`cd "${nvmDir}" && git remote set-url origin "${getActiveRegistry('nvm')}" && git pull --tags`, {
47
- stdio: 'inherit',
47
+ stdio: shell.stdioForExec(true),
48
48
  shell: shell.resolveBash(),
49
49
  })
50
50
  return { action: 'upgraded' }
@@ -52,12 +52,12 @@ export class PhpenvGroup extends ToolManagerGroup {
52
52
  install: () => installPhpenv(),
53
53
  upgrade: () => {
54
54
  execSync(`cd "${getPhpenvRoot()}" && git remote set-url origin "${getActiveRegistry('phpenv')}" && git pull --tags`, {
55
- stdio: 'inherit',
55
+ stdio: shell.stdioForExec(true),
56
56
  shell: shell.resolveBash(),
57
57
  })
58
58
  const pluginsDir = path.join(getPhpenvRoot(), 'plugins', 'php-build')
59
59
  if (fs.existsSync(pluginsDir)) {
60
- execSync(`cd "${pluginsDir}" && git pull`, { stdio: 'inherit', shell: shell.resolveBash() })
60
+ execSync(`cd "${pluginsDir}" && git pull`, { stdio: shell.stdioForExec(true), shell: shell.resolveBash() })
61
61
  }
62
62
  return { action: 'upgraded' }
63
63
  },
@@ -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)
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,9 +113,10 @@ if (groupName === 'version' || groupName === '-v' || groupName === '--version')
101
113
  return
102
114
  }
103
115
 
104
- // 剪切板快捷命令 → share 组的 copy/paste(id=clipboard)
105
- if (groupName === 'copy' || groupName === 'paste') {
106
- return GROUPS.share.run([groupName])
116
+ // share 快捷命令 → share 组对应 action
117
+ const shortcutAction = SHARE_SHORTCUTS[groupName]
118
+ if (shortcutAction) {
119
+ return GROUPS.share.run([shortcutAction, ...rest])
107
120
  }
108
121
 
109
122
  const group = GROUPS[groupName]
@@ -131,8 +144,9 @@ Usage: opm <command> [args] [options]
131
144
 
132
145
  Commands:
133
146
  ${groupLines}
134
- copy Copy system clipboard to the share server (id=clipboard)
135
- paste Paste the server clipboard back to system clipboard
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)
136
150
  version Show opm version
137
151
  upgrade Upgrade opm itself (npm install -g @wwkit/opm@latest)
138
152
  doc Build docs HTML and open in browser
@@ -55,7 +55,7 @@ export function installNvm(proxy) {
55
55
  env.http_proxy = proxy
56
56
  env.https_proxy = proxy
57
57
  }
58
- execSync(`git clone "${mirror}" "${nvmDir}"`, { stdio: 'inherit', shell: shell.resolveBash(), env })
58
+ execSync(`git clone "${mirror}" "${nvmDir}"`, { stdio: shell.stdioForExec(true), shell: shell.resolveBash(), env })
59
59
  const lines = [
60
60
  `export NVM_DIR="${nvmDir}"`,
61
61
  `[ -s "\\$NVM_DIR/nvm.sh" ] && \\. "\\$NVM_DIR/nvm.sh"`,
@@ -51,9 +51,9 @@ export function isPhpenvInstalled() {
51
51
  export function installPhpenv() {
52
52
  const mirror = getActiveRegistry('phpenv')
53
53
  const root = getPhpenvRoot()
54
- execSync(`git clone "${mirror}" "${root}"`, { stdio: 'inherit', shell: shell.resolveBash() })
54
+ execSync(`git clone "${mirror}" "${root}"`, { stdio: shell.stdioForExec(true), shell: shell.resolveBash() })
55
55
  execSync(`git clone "${PHP_BUILD_GIT}" "${path.join(root, 'plugins', 'php-build')}"`, {
56
- stdio: 'inherit',
56
+ stdio: shell.stdioForExec(true),
57
57
  shell: shell.resolveBash(),
58
58
  })
59
59
  const lines = [
@@ -127,7 +127,7 @@ export class RuntimeManager extends PackageManager {
127
127
  throw new Error(this.cfg.toolMissingMsg)
128
128
  }
129
129
  const result = spawnSync(this._cmd(args), {
130
- stdio: 'inherit',
130
+ stdio: shell.stdioForExec(this.cfg.needsBash),
131
131
  shell: shell.shellForExec(this.cfg.needsBash),
132
132
  env: this._buildEnv(opts.proxy),
133
133
  })
@@ -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,25 +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'
21
22
  import { readClipboard, writeClipboard } from '../clipboard.js'
22
23
 
23
24
  const DEFAULT_PASSWORD = '0000'
24
25
  const ZIP_TMP_PREFIX = 'opm-share-'
25
26
  const CLIPBOARD_ID = 'clipboard'
27
+ const DEFAULT_TEMP_ID = 'opm_share'
26
28
 
27
29
  /**
28
- * 生成默认标题(内容前 30 字符折叠空白 + ...,≤30 用原文)
30
+ * 将 content 美化为可读字符串:若为 JSON5 可解析文本则返回 2 空格缩进的 JSON,否则原样返回
31
+ * 用于默认输出(直接打印到 stdout,保留真实换行)
29
32
  * @param {string} content
30
33
  * @returns {string}
31
34
  */
32
- function defaultTitle(content) {
33
- const folded = content.replace(/\s+/g, ' ').trim()
34
- if (folded.length <= 30) return folded
35
- 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
+ }
36
57
  }
37
58
 
38
59
  /**
@@ -47,6 +68,25 @@ function expandTilde(p) {
47
68
  return p
48
69
  }
49
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
+
50
90
  /**
51
91
  * 浏览器打开 URL
52
92
  * @param {string} url
@@ -104,11 +144,12 @@ function isProcessAlive(pid) {
104
144
  }
105
145
 
106
146
  const ACTIONS = {
107
- upload: { desc: 'Upload text content (positional, -f file, or stdin; -i for custom id)' },
108
- zip: { desc: 'Upload file/folder as compressed zip (get -d to extract; -i for custom id)' },
109
- get: { desc: 'Get shared content (id optional; defaults to latest; -d to extract zip)' },
110
- view: { desc: 'View shared content (id optional; defaults to latest)' },
111
- 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)' },
112
153
  list: { desc: 'List recent shares (-n count, default 10)' },
113
154
  delete: { desc: 'Delete a share by id' },
114
155
  clear: { desc: 'Keep shares by days and/or count (--days n keeps last n days, --keep n keeps n most recent)' },
@@ -133,7 +174,7 @@ export class ShareGroup {
133
174
  return
134
175
  }
135
176
 
136
- const parsed = parseFlags(rest)
177
+ const parsed = parseFlags(rest, { booleans: ['detail'] })
137
178
 
138
179
  switch (action) {
139
180
  case 'upload':
@@ -142,8 +183,10 @@ export class ShareGroup {
142
183
  return this._zip(parsed)
143
184
  case 'get':
144
185
  return this._get(parsed)
145
- case 'view':
146
- return this._view(parsed)
186
+ case 'unzip':
187
+ return this._unzip(parsed)
188
+ case 'sync':
189
+ return this._sync(parsed)
147
190
  case 'open':
148
191
  return this._open(parsed)
149
192
  case 'list':
@@ -190,17 +233,18 @@ export class ShareGroup {
190
233
  }
191
234
 
192
235
  /**
193
- * 解析密码:-p/--password 参数 > 配置 share.password > 位置参数(get/open)> 默认 0000
236
+ * 解析密码:-p/--password 参数 > 位置参数(get/open)> 配置 share.password > 默认 0000
194
237
  * @param {object} parsed - parseFlags 结果
195
238
  * @param {string} [positional] - 位置参数密码(get/open 的第二个位置参数)
196
239
  * @returns {string}
197
240
  */
198
241
  _resolvePassword(parsed, positional = '') {
199
- const flag = parsed.flags.password || ''
242
+ const flag = parsed.flags.password || parsed.flags.p || ''
200
243
  if (flag && flag !== 'true') return flag
244
+ if (positional) return positional
201
245
  const cfgPassword = this._getShareConfig().password
202
246
  if (cfgPassword) return cfgPassword
203
- return positional || DEFAULT_PASSWORD
247
+ return DEFAULT_PASSWORD
204
248
  }
205
249
 
206
250
  /**
@@ -241,9 +285,8 @@ export class ShareGroup {
241
285
  }
242
286
 
243
287
  async _upload(parsed) {
244
- const title = parsed.flags.t || parsed.flags.title || ''
245
- const file = parsed.flags.file || ''
246
- 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
247
290
  const password = this._resolvePassword(parsed)
248
291
 
249
292
  let content = ''
@@ -251,47 +294,60 @@ export class ShareGroup {
251
294
  if (file) {
252
295
  const resolved = path.resolve(file)
253
296
  if (!fs.existsSync(resolved)) {
254
- console.error(`File not found: ${resolved}`)
297
+ console.error(`[opm share] File not found: ${resolved}`)
255
298
  process.exit(1)
256
299
  }
257
300
  content = fs.readFileSync(resolved, 'utf8')
258
301
  } else if (parsed.positional.length > 0) {
259
- 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
+ }
260
316
  } else if (!process.stdin.isTTY) {
261
317
  content = fs.readFileSync(0, 'utf8')
262
318
  }
263
319
 
264
320
  if (!content) {
265
- console.error('Usage: opm share upload "<content>" [-t title] [-f file] [-i id] [-p password]')
266
- console.error(' Or: echo "..." | opm share upload [-t title] [-i id] [-p password]')
267
- 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)')
268
325
  process.exit(1)
269
326
  }
270
327
 
271
328
  const baseUrl = await this._ensureReachable()
272
329
  const result = await uploadShare(baseUrl, {
273
- title: title || defaultTitle(content),
274
330
  content,
275
331
  password,
276
- ...(id ? { id } : {}),
332
+ id,
277
333
  })
278
334
  output(result)
279
335
  }
280
336
 
281
337
  async _zip(parsed) {
282
- const inputPath = parsed.positional[0] || parsed.flags.file || ''
283
- const title = parsed.flags.t || parsed.flags.title || ''
284
- 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
285
340
  const password = this._resolvePassword(parsed)
286
341
 
287
342
  if (!inputPath) {
288
- 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)')
289
345
  process.exit(1)
290
346
  }
291
347
 
292
- const resolved = path.resolve(inputPath)
348
+ const resolved = path.resolve(expandTilde(inputPath))
293
349
  if (!fs.existsSync(resolved)) {
294
- console.error(`Path not found: ${resolved}`)
350
+ console.error(`[opm share] Path not found: ${resolved}`)
295
351
  process.exit(1)
296
352
  }
297
353
 
@@ -300,11 +356,13 @@ export class ShareGroup {
300
356
  if (isDir) {
301
357
  const entries = fs.readdirSync(resolved)
302
358
  if (entries.length === 0) {
303
- console.error(`Directory is empty: ${resolved}`)
359
+ console.error(`[opm share] Directory is empty: ${resolved}`)
304
360
  process.exit(1)
305
361
  }
306
362
  }
307
363
 
364
+ const sourcePath = tildefy(inputPath, resolved)
365
+
308
366
  const baseName = path.basename(resolved)
309
367
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), ZIP_TMP_PREFIX))
310
368
  const zipPath = path.join(tmpDir, `${baseName}.zip`)
@@ -317,7 +375,7 @@ export class ShareGroup {
317
375
 
318
376
  if (zipResult.status !== 0) {
319
377
  const stderr = zipResult.stderr ? zipResult.stderr.toString().trim() : 'unknown error'
320
- console.error(`Zip failed: ${stderr}`)
378
+ console.error(`[opm share] Zip failed: ${stderr}`)
321
379
  fs.rmSync(tmpDir, { recursive: true, force: true })
322
380
  process.exit(1)
323
381
  }
@@ -329,64 +387,178 @@ export class ShareGroup {
329
387
 
330
388
  const baseUrl = await this._ensureReachable()
331
389
  const result = await uploadShare(baseUrl, {
332
- title: title || `${baseName}.zip`,
390
+ title: `${baseName}.zip`,
333
391
  content,
334
392
  password,
335
393
  type: 'zip',
336
394
  filename: `${baseName}.zip`,
337
- ...(id ? { id } : {}),
395
+ sourcePath,
396
+ id,
338
397
  })
339
398
  output(result)
340
399
  }
341
400
 
342
401
  async _get(parsed) {
343
- const id = parsed.positional[0] || ''
402
+ const id = parsed.positional[0] || DEFAULT_TEMP_ID
344
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 || ''
345
406
  const extractDir = parsed.flags.d || parsed.flags.dir || ''
346
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
+
347
413
  const baseUrl = await this._ensureReachable()
348
- const result = id
349
- ? await getShare(baseUrl, id, password)
350
- : await getLatestShare(baseUrl, password)
414
+ const result = await getShare(baseUrl, id, password)
351
415
 
352
- if (result.type === 'zip' && extractDir) {
353
- await this._extractZip(result, extractDir)
416
+ if (detail) {
417
+ output({ ...result, content: parseContentJson(result.content) })
354
418
  return
355
419
  }
356
420
 
357
- output(result)
358
- }
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
+ }
359
425
 
360
- async _extractZip(result, dir) {
361
- const resolvedDir = path.resolve(dir)
362
- 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
+ }
363
434
 
435
+ const formatted = formatContent(result.content || '')
436
+ process.stdout.write(formatted)
437
+ if (!formatted.endsWith('\n')) {
438
+ process.stdout.write('\n')
439
+ }
440
+ }
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) {
364
450
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), ZIP_TMP_PREFIX))
365
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)
366
491
 
367
492
  try {
368
- const buf = Buffer.from(result.content, 'base64')
369
- 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
+ }
370
511
 
371
- const { default: extractZip } = await import('extract-zip')
372
- 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'
516
+
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
+ }
373
543
 
374
- const files = this._listTopLevel(resolvedDir)
544
+ try {
545
+ const { files, targetDir: target, extracted } = await this._extractZipTo(result, targetDir, dryRun)
375
546
  output({
376
547
  id: result.id,
377
548
  title: result.title,
378
549
  type: 'zip',
379
550
  filename: result.filename,
551
+ sourcePath,
380
552
  created: result.created,
381
- extracted: true,
382
- dir: resolvedDir,
553
+ dryRun,
554
+ extracted,
555
+ mode,
556
+ dir: target,
383
557
  files,
384
558
  })
385
559
  } catch (err) {
386
- console.error(`Extract failed: ${err.message}`)
560
+ console.error(`[opm share] ${err.message}`)
387
561
  process.exit(1)
388
- } finally {
389
- fs.rmSync(tmpDir, { recursive: true, force: true })
390
562
  }
391
563
  }
392
564
 
@@ -406,62 +578,14 @@ export class ShareGroup {
406
578
  }
407
579
  }
408
580
 
409
- async _view(parsed) {
410
- const id = parsed.positional[0] || ''
411
- const password = this._resolvePassword(parsed, parsed.positional[1] || '')
412
-
413
- const baseUrl = await this._ensureReachable()
414
- const result = id
415
- ? await getShare(baseUrl, id, password)
416
- : await getLatestShare(baseUrl, password)
417
-
418
- if (result.type === 'zip') {
419
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), ZIP_TMP_PREFIX))
420
- try {
421
- const zipPath = path.join(tmpDir, result.filename || `${result.id}.zip`)
422
- const buf = Buffer.from(result.content, 'base64')
423
- fs.writeFileSync(zipPath, buf)
424
-
425
- const { default: extractZip } = await import('extract-zip')
426
- await extractZip(zipPath, { dir: tmpDir })
427
-
428
- fs.unlinkSync(zipPath)
429
-
430
- const files = this._listTopLevel(tmpDir)
431
- for (const f of files) {
432
- console.log(`${f.type === 'dir' ? 'd' : 'f'} ${f.name}`)
433
- }
434
- } catch (err) {
435
- console.error(`View zip failed: ${err.message}`)
436
- process.exit(1)
437
- } finally {
438
- fs.rmSync(tmpDir, { recursive: true, force: true })
439
- }
440
- return
441
- }
442
-
443
- process.stdout.write(result.content || '')
444
- if (!(result.content || '').endsWith('\n')) {
445
- process.stdout.write('\n')
446
- }
447
- }
448
-
449
581
  async _open(parsed) {
450
- const id = parsed.positional[0] || ''
582
+ const id = parsed.positional[0] || DEFAULT_TEMP_ID
451
583
  const password = this._resolvePassword(parsed, parsed.positional[1] || '')
452
584
 
453
585
  const baseUrl = await this._ensureReachable()
454
-
455
- if (id) {
456
- const htmlUrl = getHtmlUrl(baseUrl, id, password)
457
- openBrowser(htmlUrl)
458
- output({ opened: true, url: htmlUrl })
459
- } else {
460
- const result = await getLatestShare(baseUrl, password)
461
- const htmlUrl = getHtmlUrl(baseUrl, result.id, password)
462
- openBrowser(htmlUrl)
463
- output({ opened: true, id: result.id, url: htmlUrl })
464
- }
586
+ const htmlUrl = getHtmlUrl(baseUrl, id, password)
587
+ openBrowser(htmlUrl)
588
+ output({ opened: true, id, url: htmlUrl })
465
589
  }
466
590
 
467
591
  async _list(parsed) {
@@ -523,7 +647,7 @@ export class ShareGroup {
523
647
  return
524
648
  }
525
649
 
526
- console.error(`Unknown server subcommand: ${sub}`)
650
+ console.error(`[opm share] Unknown server subcommand: ${sub}`)
527
651
  console.error('Usage: opm share server [start|stop|status|help]')
528
652
  process.exit(1)
529
653
  }
@@ -690,7 +814,6 @@ Environment variables (for server.js direct execution):
690
814
  const password = this._resolvePassword(parsed)
691
815
  const baseUrl = await this._ensureReachable()
692
816
  const result = await uploadShare(baseUrl, {
693
- title: defaultTitle(content),
694
817
  content,
695
818
  password,
696
819
  id: CLIPBOARD_ID,
@@ -736,17 +859,22 @@ Server subcommands:
736
859
  server status Check local server status
737
860
 
738
861
  Shorthand:
739
- opm share "<content>" [-t title] Upload (without "upload" keyword)
740
- opm share -f <file> Upload file content (title auto-generated)
741
- 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.
742
869
 
743
870
  Options:
744
- -t, --title <title> Title (default: first 30 chars of content)
745
- --file <path> Read content from file
746
- -i, --id <id> Custom share id (upload/zip; easier to remember)
747
- --password <password> Password (default: config share.password, fallback 0000)
871
+ -f, --file <path> Read content from file
872
+ -i, --id <id> Custom share id (default: opm_share, overwritten on next no-id upload)
873
+ -p, --password <password> Password (default: config share.password, fallback 0000)
748
874
  -n, --count <n> Number of items (list, default: 10)
749
- -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)
750
878
  -h, --help Show this help
751
879
 
752
880
  Server config (config.json5):
@@ -757,28 +885,32 @@ Server config (config.json5):
757
885
  Examples:
758
886
  opm share copy Copy system clipboard to server (id=clipboard)
759
887
  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"
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
762
891
  opm share config Show current share config
763
892
  opm share server start Start local server (background)
764
893
  opm share server status Check if server is running
765
894
  opm share server stop Stop local server
766
- opm share "hello world" -t test Upload text
767
- opm share "hello world" -i myid Upload text with custom id "myid"
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)
768
897
  opm share "hello world" --password 1234 Upload text with password override
769
- opm share --file doc.md Upload file (title auto from content)
770
- opm share -f doc.md -t "README" Upload file with custom title
898
+ opm share doc.md Upload existing file content (auto-detected)
899
+ opm share -f doc.md Upload file content
771
900
  echo "hello" | opm share Upload from stdin
772
- opm share zip ./src Upload folder as zip
773
- opm share zip ./config.json5 Upload file as zip
774
- opm share get Get the latest share (JSON)
775
- opm share get myid Get share by custom id
776
- opm share get abc12345 Get share by random id
777
- opm share get abc12345 --password 1234 Get with password override
778
- opm share get abc12345 -d ./out Download and extract zip to ./out
779
- opm share view View the latest share content
780
- opm share view myid View share by custom id
781
- 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
782
914
  opm share open myid Open share by custom id in browser
783
915
  opm share list List recent 10 shares
784
916
  opm share list -n 5 List recent 5 shares
@@ -786,6 +918,6 @@ Examples:
786
918
  opm share clear --days 7 Keep shares from the last 7 days
787
919
  opm share clear --keep 50 Keep only the most recent 50 shares
788
920
  opm share clear --days 7 --keep 50 Both combined
789
- `)
921
+ `)
790
922
  }
791
923
  }
@@ -219,6 +219,7 @@ async function handleUpload(req, res, dataDir, baseUrl) {
219
219
  }
220
220
 
221
221
  const id = parsed.id || genId()
222
+ const existed = fs.existsSync(path.join(dataDir, `${id}.json`))
222
223
  const item = {
223
224
  id,
224
225
  title: parsed.title || defaultTitle(content),
@@ -226,11 +227,12 @@ async function handleUpload(req, res, dataDir, baseUrl) {
226
227
  password: parsed.password || DEFAULT_PASSWORD,
227
228
  type: parsed.type || 'text',
228
229
  filename: parsed.filename || '',
230
+ sourcePath: parsed.sourcePath || '',
229
231
  created: new Date().toISOString(),
230
232
  }
231
233
 
232
234
  writeShare(dataDir, item)
233
- sendJson(res, 200, { id, url: `${baseUrl}/share/${id}`, htmlUrl: `${baseUrl}/share/html/${id}` })
235
+ sendJson(res, 200, { id, url: `${baseUrl}/share/${id}`, htmlUrl: `${baseUrl}/share/html/${id}`, overwritten: existed })
234
236
  }
235
237
 
236
238
  function handleList(req, res, dataDir, url) {
@@ -269,7 +271,7 @@ function handleLatest(req, res, dataDir, pw) {
269
271
  }
270
272
  sendByAccept(
271
273
  req, res, 200,
272
- { id: item.id, title: item.title, content: item.content, type: item.type || 'text', filename: item.filename || '', created: item.created },
274
+ { id: item.id, title: item.title, content: item.content, type: item.type || 'text', filename: item.filename || '', sourcePath: item.sourcePath || '', created: item.created },
273
275
  renderHtml(item),
274
276
  )
275
277
  }
@@ -286,7 +288,7 @@ function handleGet(req, res, dataDir, id, pw) {
286
288
  }
287
289
  sendByAccept(
288
290
  req, res, 200,
289
- { id: item.id, title: item.title, content: item.content, type: item.type || 'text', filename: item.filename || '', created: item.created },
291
+ { id: item.id, title: item.title, content: item.content, type: item.type || 'text', filename: item.filename || '', sourcePath: item.sourcePath || '', created: item.created },
290
292
  renderHtml(item),
291
293
  )
292
294
  }