@wwkit/opm 1.0.3 → 1.0.4

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.
@@ -0,0 +1,735 @@
1
+ /**
2
+ * opm share 命令组 — 文本分享服务
3
+ *
4
+ * 命令:upload / zip / get / view / open / list / delete / clear / config / serve / help
5
+ * 服务地址由 config.share.server.active 解析(不支持命令行 -s 参数)。
6
+ * 需要服务调用的命令执行前先 ping 验证可达性;
7
+ * 若 active=local 且服务未启动,自动后台启动后再执行。
8
+ * serve 子命令:start(后台启动)/ stop(停止)/ status(查询状态)。
9
+ * zip 上传文件/文件夹(压缩为 zip 后 base64 上传),get -d 下载并解压。
10
+ */
11
+
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import os from 'node:os'
15
+ import { spawn, spawnSync } from 'node:child_process'
16
+ import { parseFlags } from '../../cli/helpers/args.js'
17
+ import { getConfig, getSection, getUserConfigDir } from '../../config.js'
18
+ import { output } from '../../formatter.js'
19
+ import { getShareServer, pingServer, uploadShare, getShare, getLatestShare, getHtmlUrl, listShares, deleteShare, clearShares } from './client.js'
20
+ import { startServer } from './server.js'
21
+
22
+ const DEFAULT_PASSWORD = '0000'
23
+ const ZIP_TMP_PREFIX = 'opm-share-'
24
+
25
+ /**
26
+ * 生成默认标题(内容前 30 字符折叠空白 + ...,≤30 用原文)
27
+ * @param {string} content
28
+ * @returns {string}
29
+ */
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) + '...'
34
+ }
35
+
36
+ /**
37
+ * 展开 ~ 路径
38
+ * @param {string} p
39
+ * @returns {string}
40
+ */
41
+ function expandTilde(p) {
42
+ if (p && p.startsWith('~/')) {
43
+ return path.join(os.homedir(), p.slice(2))
44
+ }
45
+ return p
46
+ }
47
+
48
+ /**
49
+ * 浏览器打开 URL
50
+ * @param {string} url
51
+ */
52
+ function openBrowser(url) {
53
+ const platform = process.platform
54
+ let cmd
55
+ if (platform === 'darwin') {
56
+ cmd = `open "${url}"`
57
+ } else if (platform === 'win32') {
58
+ cmd = `start "" "${url}"`
59
+ } else {
60
+ cmd = `xdg-open "${url}"`
61
+ }
62
+ try {
63
+ spawnSync(cmd, { shell: true, stdio: 'ignore' })
64
+ } catch {}
65
+ }
66
+
67
+ /**
68
+ * 获取 serve 状态文件路径
69
+ * @returns {string}
70
+ */
71
+ function getServePidFile() {
72
+ return path.join(getUserConfigDir(), 'share', 'serve.pid')
73
+ }
74
+
75
+ /**
76
+ * 读取 serve PID 文件
77
+ * @returns {{pid: number, url: string, host: string, port: number, dataDir: string, started: string}|null}
78
+ */
79
+ function readServePid() {
80
+ const pidFile = getServePidFile()
81
+ if (!fs.existsSync(pidFile)) return null
82
+ try {
83
+ return JSON.parse(fs.readFileSync(pidFile, 'utf8'))
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 检查进程是否存活
91
+ * @param {number} pid
92
+ * @returns {boolean}
93
+ */
94
+ function isProcessAlive(pid) {
95
+ if (!pid) return false
96
+ try {
97
+ process.kill(pid, 0)
98
+ return true
99
+ } catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ 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)' },
110
+ list: { desc: 'List recent shares (-n count, default 10)' },
111
+ delete: { desc: 'Delete a share by id' },
112
+ clear: { desc: 'Clean up shares by days and/or keep count' },
113
+ config: { desc: 'Show current share config section' },
114
+ serve: { desc: 'Manage local server (start/stop/status)' },
115
+ help: { desc: 'Show this help' },
116
+ }
117
+
118
+ export class ShareGroup {
119
+ constructor() {
120
+ this.name = 'share'
121
+ this.desc = 'Text sharing service (upload/get/open/list/serve)'
122
+ }
123
+
124
+ async run(argv) {
125
+ const [action, ...rest] = argv
126
+
127
+ if (!action || action === '-h' || action === '--help' || action === 'help') {
128
+ this.printHelp()
129
+ return
130
+ }
131
+
132
+ const parsed = parseFlags(rest)
133
+
134
+ switch (action) {
135
+ case 'upload':
136
+ return this._upload(parsed)
137
+ case 'zip':
138
+ return this._zip(parsed)
139
+ case 'get':
140
+ return this._get(parsed)
141
+ case 'view':
142
+ return this._view(parsed)
143
+ case 'open':
144
+ return this._open(parsed)
145
+ case 'list':
146
+ return this._list(parsed)
147
+ case 'delete':
148
+ return this._delete(parsed)
149
+ case 'clear':
150
+ return this._clear(parsed)
151
+ case 'config':
152
+ return this._config(parsed)
153
+ case 'serve':
154
+ return this._serve(parsed)
155
+ default:
156
+ return this._upload(parseFlags([action, ...rest]))
157
+ }
158
+ }
159
+
160
+ _getShareConfig() {
161
+ return getSection(getConfig(), 'share') || {}
162
+ }
163
+
164
+ _getServerUrl() {
165
+ return getShareServer(this._getShareConfig())
166
+ }
167
+
168
+ _isLocalActive() {
169
+ const cfg = this._getShareConfig()
170
+ const server = cfg.server || {}
171
+ return server.active === 'local'
172
+ }
173
+
174
+ _getServeOpts() {
175
+ const cfg = this._getShareConfig()
176
+ const serve = cfg.serve || {}
177
+ return {
178
+ host: serve.host || '0.0.0.0',
179
+ port: serve.port || 8787,
180
+ dataDir: expandTilde(serve.dataDir || '~/.config/opm/share/data'),
181
+ }
182
+ }
183
+
184
+ /**
185
+ * 解析密码:-p/--password 参数 > 配置 share.password > 位置参数(get/open)> 默认 0000
186
+ * @param {object} parsed - parseFlags 结果
187
+ * @param {string} [positional] - 位置参数密码(get/open 的第二个位置参数)
188
+ * @returns {string}
189
+ */
190
+ _resolvePassword(parsed, positional = '') {
191
+ const flag = parsed.flags.p || parsed.flags.password || ''
192
+ if (flag && flag !== 'true') return flag
193
+ const cfgPassword = this._getShareConfig().password
194
+ if (cfgPassword) return cfgPassword
195
+ return positional || DEFAULT_PASSWORD
196
+ }
197
+
198
+ /**
199
+ * 确保服务可达:ping 不通且 active=local 时自动后台启动
200
+ * @returns {Promise<string>} baseUrl
201
+ */
202
+ async _ensureReachable() {
203
+ const baseUrl = this._getServerUrl()
204
+ if (!baseUrl) {
205
+ console.error('[opm share] No server configured. Run "opm share config" to check.')
206
+ process.exit(1)
207
+ }
208
+
209
+ const result = await pingServer(baseUrl)
210
+ if (result.reachable) return baseUrl
211
+
212
+ if (!this._isLocalActive()) {
213
+ console.error(`[opm share] Server unreachable: ${baseUrl}`)
214
+ console.error(`[opm share] ${result.message}`)
215
+ console.error('[opm share] Run "opm share config" to check.')
216
+ process.exit(1)
217
+ }
218
+
219
+ console.error('[opm share] Local server not running, starting...')
220
+ this._startBackground()
221
+
222
+ for (let i = 0; i < 10; i++) {
223
+ await new Promise((r) => setTimeout(r, 300))
224
+ const recheck = await pingServer(baseUrl)
225
+ if (recheck.reachable) {
226
+ console.error('[opm share] Server started.')
227
+ return baseUrl
228
+ }
229
+ }
230
+
231
+ console.error(`[opm share] Failed to start local server. Run "opm share serve status" to check.`)
232
+ process.exit(1)
233
+ }
234
+
235
+ 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 || ''
239
+ const password = this._resolvePassword(parsed)
240
+
241
+ let content = ''
242
+
243
+ if (file) {
244
+ const resolved = path.resolve(file)
245
+ if (!fs.existsSync(resolved)) {
246
+ console.error(`File not found: ${resolved}`)
247
+ process.exit(1)
248
+ }
249
+ content = fs.readFileSync(resolved, 'utf8')
250
+ } else if (parsed.positional.length > 0) {
251
+ content = parsed.positional.join(' ')
252
+ } else if (!process.stdin.isTTY) {
253
+ content = fs.readFileSync(0, 'utf8')
254
+ }
255
+
256
+ 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")')
260
+ process.exit(1)
261
+ }
262
+
263
+ const baseUrl = await this._ensureReachable()
264
+ const result = await uploadShare(baseUrl, {
265
+ title: title || defaultTitle(content),
266
+ content,
267
+ password,
268
+ ...(id ? { id } : {}),
269
+ })
270
+ output(result)
271
+ }
272
+
273
+ 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 || ''
277
+ const password = this._resolvePassword(parsed)
278
+
279
+ if (!inputPath) {
280
+ console.error('Usage: opm share zip <path> [-t title] [-i id] [-p password]')
281
+ process.exit(1)
282
+ }
283
+
284
+ const resolved = path.resolve(inputPath)
285
+ if (!fs.existsSync(resolved)) {
286
+ console.error(`Path not found: ${resolved}`)
287
+ process.exit(1)
288
+ }
289
+
290
+ const stat = fs.statSync(resolved)
291
+ const isDir = stat.isDirectory()
292
+ if (isDir) {
293
+ const entries = fs.readdirSync(resolved)
294
+ if (entries.length === 0) {
295
+ console.error(`Directory is empty: ${resolved}`)
296
+ process.exit(1)
297
+ }
298
+ }
299
+
300
+ const baseName = path.basename(resolved)
301
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), ZIP_TMP_PREFIX))
302
+ const zipPath = path.join(tmpDir, `${baseName}.zip`)
303
+
304
+ const zipArgs = isDir ? ['-r', zipPath, baseName] : [zipPath, baseName]
305
+ const zipResult = spawnSync('zip', zipArgs, {
306
+ cwd: path.dirname(resolved),
307
+ stdio: ['pipe', 'pipe', 'pipe'],
308
+ })
309
+
310
+ if (zipResult.status !== 0) {
311
+ const stderr = zipResult.stderr ? zipResult.stderr.toString().trim() : 'unknown error'
312
+ console.error(`Zip failed: ${stderr}`)
313
+ fs.rmSync(tmpDir, { recursive: true, force: true })
314
+ process.exit(1)
315
+ }
316
+
317
+ const zipBuffer = fs.readFileSync(zipPath)
318
+ const content = zipBuffer.toString('base64')
319
+
320
+ fs.rmSync(tmpDir, { recursive: true, force: true })
321
+
322
+ const baseUrl = await this._ensureReachable()
323
+ const result = await uploadShare(baseUrl, {
324
+ title: title || `${baseName}.zip`,
325
+ content,
326
+ password,
327
+ type: 'zip',
328
+ filename: `${baseName}.zip`,
329
+ ...(id ? { id } : {}),
330
+ })
331
+ output(result)
332
+ }
333
+
334
+ async _get(parsed) {
335
+ const id = parsed.positional[0] || ''
336
+ const password = this._resolvePassword(parsed, parsed.positional[1] || '')
337
+ const extractDir = parsed.flags.d || parsed.flags.dir || ''
338
+
339
+ const baseUrl = await this._ensureReachable()
340
+ const result = id
341
+ ? await getShare(baseUrl, id, password)
342
+ : await getLatestShare(baseUrl, password)
343
+
344
+ if (result.type === 'zip' && extractDir) {
345
+ await this._extractZip(result, extractDir)
346
+ return
347
+ }
348
+
349
+ output(result)
350
+ }
351
+
352
+ async _extractZip(result, dir) {
353
+ const resolvedDir = path.resolve(dir)
354
+ fs.mkdirSync(resolvedDir, { recursive: true })
355
+
356
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), ZIP_TMP_PREFIX))
357
+ const zipPath = path.join(tmpDir, result.filename || `${result.id}.zip`)
358
+
359
+ try {
360
+ const buf = Buffer.from(result.content, 'base64')
361
+ fs.writeFileSync(zipPath, buf)
362
+
363
+ const { default: extractZip } = await import('extract-zip')
364
+ await extractZip(zipPath, { dir: resolvedDir })
365
+
366
+ const files = this._listTopLevel(resolvedDir)
367
+ output({
368
+ id: result.id,
369
+ title: result.title,
370
+ type: 'zip',
371
+ filename: result.filename,
372
+ created: result.created,
373
+ extracted: true,
374
+ dir: resolvedDir,
375
+ files,
376
+ })
377
+ } catch (err) {
378
+ console.error(`Extract failed: ${err.message}`)
379
+ process.exit(1)
380
+ } finally {
381
+ fs.rmSync(tmpDir, { recursive: true, force: true })
382
+ }
383
+ }
384
+
385
+ /**
386
+ * 列出目录一级文件清单
387
+ * @param {string} dir
388
+ * @returns {Array<{name: string, type: string}>}
389
+ */
390
+ _listTopLevel(dir) {
391
+ try {
392
+ return fs.readdirSync(dir, { withFileTypes: true }).map((e) => ({
393
+ name: e.name,
394
+ type: e.isDirectory() ? 'dir' : 'file',
395
+ }))
396
+ } catch {
397
+ return []
398
+ }
399
+ }
400
+
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
+ async _open(parsed) {
442
+ const id = parsed.positional[0] || ''
443
+ const password = this._resolvePassword(parsed, parsed.positional[1] || '')
444
+
445
+ 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
+ }
457
+ }
458
+
459
+ async _list(parsed) {
460
+ const n = parseInt(parsed.flags.n || parsed.flags.count || '10', 10) || 10
461
+
462
+ const baseUrl = await this._ensureReachable()
463
+ const result = await listShares(baseUrl, n)
464
+ output(result)
465
+ }
466
+
467
+ async _delete(parsed) {
468
+ const id = parsed.positional[0]
469
+ if (!id) {
470
+ console.error('Usage: opm share delete <id>')
471
+ process.exit(1)
472
+ }
473
+
474
+ const baseUrl = await this._ensureReachable()
475
+ const result = await deleteShare(baseUrl, id)
476
+ output(result)
477
+ }
478
+
479
+ async _clear(parsed) {
480
+ const days = parseInt(parsed.flags.days || '0', 10) || 0
481
+ const keep = parseInt(parsed.flags.keep || parsed.flags.n || '0', 10) || 0
482
+
483
+ if (!days && !keep) {
484
+ console.error('Usage: opm share clear [--days <n>] [--keep <n>]')
485
+ console.error(' --days <n> Delete shares older than n days')
486
+ console.error(' --keep <n> Keep only the most recent n shares')
487
+ console.error(' Both can be combined: clear --days 7 --keep 50')
488
+ process.exit(1)
489
+ }
490
+
491
+ const baseUrl = await this._ensureReachable()
492
+ const result = await clearShares(baseUrl, { days, keep })
493
+ output(result)
494
+ }
495
+
496
+ _config() {
497
+ const cfg = this._getShareConfig()
498
+ output(cfg)
499
+ }
500
+
501
+ async _serve(parsed) {
502
+ const [sub] = parsed.positional
503
+
504
+ if (sub === 'start') {
505
+ return this._serveStart()
506
+ }
507
+ if (sub === 'stop') {
508
+ return this._serveStop()
509
+ }
510
+ if (sub === 'status' || !sub) {
511
+ return this._serveStatus()
512
+ }
513
+ if (sub === 'help' || sub === '-h' || sub === '--help') {
514
+ this._serveHelp()
515
+ return
516
+ }
517
+
518
+ console.error(`Unknown serve subcommand: ${sub}`)
519
+ console.error('Usage: opm share serve [start|stop|status|help]')
520
+ process.exit(1)
521
+ }
522
+
523
+ _serveHelp() {
524
+ console.log(`
525
+ Usage: opm share serve <subcommand>
526
+
527
+ Subcommands:
528
+ start Start local server (background, reads host/port/dataDir from config)
529
+ stop Stop local server
530
+ status Check if server is running
531
+ help Show this help
532
+
533
+ Local development:
534
+ opm share serve start Start in background (reads config)
535
+ opm share serve status Check status
536
+ opm share serve stop Stop server
537
+ opm share config Show current config (host/port/dataDir)
538
+
539
+ # Or run server.js directly (foreground):
540
+ node src/tools/share/server.js
541
+ OPM_SHARE_HOST=0.0.0.0 OPM_SHARE_PORT=9000 node src/tools/share/server.js
542
+
543
+ Deploy to server:
544
+ # 1. Install @wwkit/opm on the server
545
+ npm install -g @wwkit/opm
546
+
547
+ # 2. Configure share.server.active to the server's URL
548
+ # Edit ~/.config/opm/config.json5:
549
+ # share.server.active = "official" # or a custom URL
550
+
551
+ # 3. Start the server (foreground or via process manager)
552
+ opm share serve start
553
+ # Or with custom env:
554
+ OPM_SHARE_HOST=0.0.0.0 OPM_SHARE_PORT=80 OPM_SHARE_DATA_DIR=/var/lib/opm/share node src/tools/share/server.js
555
+
556
+ # 4. Use systemd / pm2 / docker for production:
557
+ # pm2 start src/tools/share/server.js --name opm-share
558
+ # pm2 save && pm2 startup
559
+
560
+ # 5. Update client config to point to the server:
561
+ # opm share config # verify active points to the right URL
562
+
563
+ API endpoints:
564
+ POST /share Upload share
565
+ Body: {title?, content, password?, type?, filename?}
566
+ Response: {id, url, htmlUrl}
567
+
568
+ GET /share/<id> Browser -> HTML page; Accept: application/json -> JSON
569
+ GET /share/<id>/<password> Same with password
570
+
571
+ GET /share/html/<id> Always returns HTML page
572
+ GET /share/html/<id>/<password> Same with password
573
+
574
+ GET /share/list?n=10 List recent shares (default 10)
575
+ Response: {count, items: [{id, title, created}]}
576
+
577
+ POST /share/delete/<id> Delete a share by id
578
+ Response: {deleted: true, id}
579
+
580
+ POST /share/clear?days=7&keep=10 Clean up by age and/or count
581
+ Response: {deleted: number, ids: [string]}
582
+
583
+ Environment variables (for server.js direct execution):
584
+ OPM_SHARE_HOST Listen host (default: 127.0.0.1)
585
+ OPM_SHARE_PORT Listen port (default: 8787)
586
+ OPM_SHARE_DATA_DIR Data directory (default: ~/.config/opm/share/data)
587
+ `)
588
+ }
589
+
590
+ _serveStart() {
591
+ const existing = readServePid()
592
+ if (existing && isProcessAlive(existing.pid)) {
593
+ output({ running: true, pid: existing.pid, url: existing.url, host: existing.host, port: existing.port, dataDir: existing.dataDir, started: existing.started, message: 'Server already running' })
594
+ return
595
+ }
596
+
597
+ this._startBackground()
598
+ output({ started: true, message: 'Server started in background. Run "opm share serve status" to check.' })
599
+ }
600
+
601
+ _startBackground() {
602
+ const opts = this._getServeOpts()
603
+ const pidFile = getServePidFile()
604
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true })
605
+
606
+ const logFile = path.join(path.dirname(pidFile), 'serve.log')
607
+ const logFd = fs.openSync(logFile, 'a')
608
+ const child = spawn(process.execPath, [path.join(path.dirname(new URL(import.meta.url).pathname), 'server.js')], {
609
+ detached: true,
610
+ stdio: ['ignore', logFd, logFd],
611
+ env: {
612
+ ...process.env,
613
+ OPM_SHARE_HOST: opts.host,
614
+ OPM_SHARE_PORT: String(opts.port),
615
+ OPM_SHARE_DATA_DIR: opts.dataDir,
616
+ },
617
+ })
618
+ child.unref()
619
+
620
+ const pidInfo = {
621
+ pid: child.pid,
622
+ url: `http://${opts.host}:${opts.port}`,
623
+ host: opts.host,
624
+ port: opts.port,
625
+ dataDir: opts.dataDir,
626
+ started: new Date().toISOString(),
627
+ }
628
+ fs.writeFileSync(pidFile, JSON.stringify(pidInfo, null, 2), 'utf8')
629
+ }
630
+
631
+ _serveStop() {
632
+ const pidInfo = readServePid()
633
+ if (!pidInfo) {
634
+ output({ stopped: true, message: 'No serve process found (pid file missing)' })
635
+ return
636
+ }
637
+
638
+ if (!isProcessAlive(pidInfo.pid)) {
639
+ fs.unlinkSync(getServePidFile())
640
+ output({ stopped: true, message: 'Process was not running (cleaned stale pid file)' })
641
+ return
642
+ }
643
+
644
+ try {
645
+ process.kill(pidInfo.pid, 'SIGTERM')
646
+ } catch {}
647
+
648
+ const pidFile = getServePidFile()
649
+ if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile)
650
+ output({ stopped: true, pid: pidInfo.pid, url: pidInfo.url, message: 'Server stopped' })
651
+ }
652
+
653
+ _serveStatus() {
654
+ const pidInfo = readServePid()
655
+ if (!pidInfo) {
656
+ output({ running: false, message: 'No serve process found' })
657
+ return
658
+ }
659
+
660
+ if (!isProcessAlive(pidInfo.pid)) {
661
+ const pidFile = getServePidFile()
662
+ if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile)
663
+ output({ running: false, pid: pidInfo.pid, message: 'Process was not running (cleaned stale pid file)' })
664
+ return
665
+ }
666
+
667
+ output({ running: true, ...pidInfo })
668
+ }
669
+
670
+ printHelp() {
671
+ const actionLines = Object.entries(ACTIONS)
672
+ .map(([sig, { desc }]) => ` ${sig.padEnd(12)} ${desc}`)
673
+ .join('\n')
674
+
675
+ console.log(`
676
+ Usage: opm share <action> [args] [options]
677
+
678
+ Actions:
679
+ ${actionLines}
680
+
681
+ Serve subcommands:
682
+ serve start Start local server (background)
683
+ serve stop Stop local server
684
+ serve status Check local server status
685
+
686
+ 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
690
+
691
+ Options:
692
+ -t, --title <title> Title (default: first 30 chars of content)
693
+ -f, --file <path> Read content from file
694
+ -i, --id <id> Custom share id (upload/zip; easier to remember)
695
+ -p, --password <password> Password (default: config share.password, fallback 0000)
696
+ -n, --count <n> Number of items (list, default: 10)
697
+ -d, --dir <dir> Extract zip to this directory (get)
698
+ -h, --help Show this help
699
+
700
+ Server config (config.json5):
701
+ share.password Default password for share operations
702
+ share.server.active Active server (preset key or URL)
703
+ share.serve.host / .port / .dataDir Local serve parameters
704
+
705
+ Examples:
706
+ opm share config Show current share config
707
+ opm share serve start Start local server (background)
708
+ opm share serve status Check if server is running
709
+ opm share serve 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
715
+ 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
726
+ opm share open myid Open share by custom id in browser
727
+ opm share list List recent 10 shares
728
+ opm share list -n 5 List recent 5 shares
729
+ opm share delete abc12345 Delete a share by id
730
+ opm share clear --days 7 Delete shares older than 7 days
731
+ opm share clear --keep 50 Keep only the most recent 50 shares
732
+ opm share clear --days 7 --keep 50 Both combined
733
+ `)
734
+ }
735
+ }