dsh-linked-workspace 0.5.12 → 0.6.0

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/lib/index.js CHANGED
@@ -16,11 +16,12 @@
16
16
  * addresses and workspace paths.
17
17
  */
18
18
  import { randomUUID } from 'node:crypto'
19
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
20
20
  import { spawn } from 'node:child_process'
21
21
  import { mkdir, readdir, stat as fsStat } from 'node:fs/promises'
22
22
  import { homedir, tmpdir } from 'node:os'
23
- import { basename, dirname, isAbsolute, join, posix, resolve, sep } from 'node:path'
23
+ import { basename, dirname, extname, isAbsolute, join, posix, resolve, sep } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
24
25
  import { listHosts, RemoteHost, SshError } from './ssh.js'
25
26
  import * as linkedFs from './linked.js'
26
27
  import * as git from './git.js'
@@ -1423,6 +1424,129 @@ export function apply(ctx) {
1423
1424
  }
1424
1425
  }
1425
1426
 
1427
+ // ── document bridge (Markdown/PDF/DOCX/XLSX/PPTX) ──────────────────────
1428
+
1429
+ const DOC_MAX_BYTES = 80 * 1024 * 1024
1430
+ const DOC_TOOL = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'doc_tool.py')
1431
+ const DOC_KIND_BY_EXT = new Map([
1432
+ ['.md', 'markdown'], ['.markdown', 'markdown'],
1433
+ ['.pdf', 'pdf'], ['.docx', 'docx'], ['.xlsx', 'xlsx'], ['.pptx', 'pptx'],
1434
+ ])
1435
+
1436
+ function docKind(path) {
1437
+ return DOC_KIND_BY_EXT.get(extname(String(path || '')).toLowerCase()) || ''
1438
+ }
1439
+
1440
+ function docPython() {
1441
+ return process.env.DSH_OFFICE_PYTHON || process.env.DSH_PYTHON || (process.platform === 'win32' ? 'python' : 'python3')
1442
+ }
1443
+
1444
+ function runDocTool(args, timeoutMs = 120000) {
1445
+ return new Promise((resolvePromise, rejectPromise) => {
1446
+ let child
1447
+ try {
1448
+ child = spawn(docPython(), [DOC_TOOL, ...args], { windowsHide: true })
1449
+ } catch (error) {
1450
+ rejectPromise(new ApiError('DOC_RUNTIME', '需要 Python 3 与文档库:' + (error && error.message ? error.message : error)))
1451
+ return
1452
+ }
1453
+ const out = []
1454
+ const err = []
1455
+ const timer = setTimeout(() => {
1456
+ try { child.kill() } catch { /* already gone */ }
1457
+ rejectPromise(new ApiError('DOC_TIMEOUT', '文档处理超时'))
1458
+ }, timeoutMs)
1459
+ child.stdout.on('data', (chunk) => out.push(chunk))
1460
+ child.stderr.on('data', (chunk) => err.push(chunk))
1461
+ child.on('error', (error) => {
1462
+ clearTimeout(timer)
1463
+ if (error && error.code === 'ENOENT') {
1464
+ rejectPromise(new ApiError('DOC_RUNTIME', '未找到 Python。请安装 Python 3 以及 python-docx / openpyxl / python-pptx / PyMuPDF,或设置 DSH_OFFICE_PYTHON。'))
1465
+ } else {
1466
+ rejectPromise(new ApiError('DOC_RUNTIME', error && error.message ? error.message : String(error)))
1467
+ }
1468
+ })
1469
+ child.on('close', (code) => {
1470
+ clearTimeout(timer)
1471
+ const stdout = Buffer.concat(out).toString('utf8')
1472
+ const stderr = Buffer.concat(err).toString('utf8')
1473
+ let parsed
1474
+ try {
1475
+ parsed = JSON.parse(stdout.trim().split(String.fromCharCode(10)).filter(Boolean).pop() || '{}')
1476
+ } catch {
1477
+ parsed = null
1478
+ }
1479
+ if (code !== 0 || !parsed || parsed.ok !== true) {
1480
+ const message = (parsed && parsed.error) || stderr.trim() || ('文档处理失败(退出码 ' + code + ')')
1481
+ rejectPromise(new ApiError('DOC_FAILED', String(message).slice(0, 800)))
1482
+ return
1483
+ }
1484
+ resolvePromise(parsed.data)
1485
+ })
1486
+ })
1487
+ }
1488
+
1489
+ async function docTarget(params) {
1490
+ if (params.linkedId || params.workspaceId) {
1491
+ const scope = await requireScope(params)
1492
+ const target = scopeTarget(scope, params.path)
1493
+ return { remote: true, scope, path: target }
1494
+ }
1495
+ const raw = typeof params.path === 'string' ? params.path.trim() : ''
1496
+ if (raw === '' || !isAbsolute(raw)) throw new ApiError('BAD_REQUEST', '本地路径必须是绝对路径')
1497
+ return { remote: false, path: resolve(raw) }
1498
+ }
1499
+
1500
+ async function readDocBytes(target, current) {
1501
+ if (!current.remote) {
1502
+ const info = await fsStat(current.path)
1503
+ if (!info.isFile()) throw new ApiError('FS_NOT_DIR', '只能打开文件')
1504
+ if (info.size > DOC_MAX_BYTES) throw new ApiError('DOC_TOO_LARGE', '文档超过 80 MB')
1505
+ return { data: readFileSync(current.path), mtime: info.mtimeMs, size: info.size }
1506
+ }
1507
+ const stat = await current.scope.remote.stat(current.path)
1508
+ if (stat.type !== 'file') throw new ApiError('FS_NOT_DIR', '只能打开文件')
1509
+ if (stat.size > DOC_MAX_BYTES) throw new ApiError('DOC_TOO_LARGE', '文档超过 80 MB')
1510
+ const read = await current.scope.remote.readFile(current.path, { maxBytes: DOC_MAX_BYTES })
1511
+ return { data: read.data, mtime: stat.mtime, size: stat.size }
1512
+ }
1513
+
1514
+ async function writeDocBytes(target, data) {
1515
+ if (!target.remote) {
1516
+ await mkdir(dirname(target.path), { recursive: true })
1517
+ const temp = target.path + '.' + randomUUID() + '.tmp'
1518
+ writeFileSync(temp, data)
1519
+ try { renameSync(temp, target.path) } catch (error) { rmSync(temp, { force: true }); throw error }
1520
+ const info = await fsStat(target.path)
1521
+ return { path: target.path, size: info.size, mtime: info.mtimeMs }
1522
+ }
1523
+ const result = await target.scope.remote.writeFileAtomic(target.path, data)
1524
+ return { path: target.path, size: result.size, mtime: result.mtime }
1525
+ }
1526
+
1527
+ async function makeDocTemp() {
1528
+ const directory = mkdtempSync(join(tmpdir(), 'dsh-doc-'))
1529
+ return {
1530
+ dir: directory,
1531
+ file(name) { return join(directory, name) },
1532
+ cleanup() { try { rmSync(directory, { recursive: true, force: true }) } catch { /* best effort */ } },
1533
+ }
1534
+ }
1535
+
1536
+ function assertDocKind(path, expected) {
1537
+ const kind = docKind(path)
1538
+ if (kind === '') throw new ApiError('DOC_UNSUPPORTED', '仅支持 Markdown、PDF、DOCX、XLSX、PPTX')
1539
+ if (expected && expected !== kind) throw new ApiError('DOC_UNSUPPORTED', '文件类型与请求不一致')
1540
+ return kind
1541
+ }
1542
+
1543
+ async function docStat(target) {
1544
+ if (!target.remote) {
1545
+ try { const info = await fsStat(target.path); return info } catch { return null }
1546
+ }
1547
+ try { return await target.scope.remote.stat(target.path) } catch { return null }
1548
+ }
1549
+
1426
1550
  // ── method table ─────────────────────────────────────────────────────────
1427
1551
 
1428
1552
  const methods = {
@@ -1606,6 +1730,136 @@ export function apply(ctx) {
1606
1730
  return { path: target, size: info.size, mtime: info.mtime }
1607
1731
  },
1608
1732
 
1733
+ async 'docs.read'(params = {}) {
1734
+ const target = await docTarget(params)
1735
+ const kind = assertDocKind(target.path)
1736
+ const current = await readDocBytes(target, target)
1737
+ if (kind === 'markdown') {
1738
+ if (looksBinary(current.data)) throw new ApiError('DOC_BINARY', 'Markdown 文件包含二进制内容')
1739
+ return { kind, path: target.path, text: decodeUtf8(current.data), size: current.size, mtime: current.mtime }
1740
+ }
1741
+ const temp = await makeDocTemp()
1742
+ try {
1743
+ const input = temp.file('input.' + (kind === 'markdown' ? 'md' : kind))
1744
+ writeFileSync(input, current.data)
1745
+ const data = await runDocTool(['read', '--kind', kind, '--file', input])
1746
+ return { ...data, path: target.path, size: current.size, mtime: current.mtime }
1747
+ } finally {
1748
+ temp.cleanup()
1749
+ }
1750
+ },
1751
+
1752
+ async 'docs.write'(params = {}) {
1753
+ const target = await docTarget(params)
1754
+ const kind = assertDocKind(target.path)
1755
+ const data = params.data
1756
+ if (!data || typeof data !== 'object') throw new ApiError('BAD_REQUEST', '缺少文档数据')
1757
+ if (typeof params.expectedMtime === 'number' && params.expectedMtime > 0) {
1758
+ const current = await docStat(target)
1759
+ if (current && Math.floor(current.mtime / 1000) !== Math.floor(params.expectedMtime / 1000)) {
1760
+ throw new ApiError('CONFLICT', '文件已被修改,请重新加载后再保存')
1761
+ }
1762
+ }
1763
+ if (kind === 'markdown') {
1764
+ if (typeof data.text !== 'string') throw new ApiError('BAD_REQUEST', 'Markdown 内容无效')
1765
+ const written = await writeDocBytes(target, Buffer.from(data.text, 'utf8'))
1766
+ return { kind, ...written }
1767
+ }
1768
+ const current = await readDocBytes(target, target)
1769
+ const temp = await makeDocTemp()
1770
+ try {
1771
+ const source = temp.file('source.' + kind)
1772
+ const spec = temp.file('data.json')
1773
+ const output = temp.file('output.' + kind)
1774
+ writeFileSync(source, current.data)
1775
+ writeFileSync(spec, JSON.stringify(data))
1776
+ await runDocTool(['write', '--kind', kind, '--file', source, '--data', spec, '--out', output])
1777
+ const written = await writeDocBytes(target, readFileSync(output))
1778
+ return { kind, ...written }
1779
+ } finally {
1780
+ temp.cleanup()
1781
+ }
1782
+ },
1783
+
1784
+ async 'docs.create'(params = {}) {
1785
+ const target = await docTarget(params)
1786
+ const kind = assertDocKind(target.path, params.kind)
1787
+ const existing = await docStat(target)
1788
+ if (existing) throw new ApiError('FS_EXISTS', '目标文件已存在')
1789
+ const temp = await makeDocTemp()
1790
+ try {
1791
+ const cliKind = kind === 'markdown' ? 'md' : kind
1792
+ const output = temp.file('new.' + cliKind)
1793
+ await runDocTool(['create', '--kind', cliKind, '--out', output])
1794
+ const written = await writeDocBytes(target, readFileSync(output))
1795
+ return { kind, ...written }
1796
+ } finally {
1797
+ temp.cleanup()
1798
+ }
1799
+ },
1800
+
1801
+ async 'docs.mdPreview'(params = {}) {
1802
+ const target = await docTarget(params)
1803
+ assertDocKind(target.path, 'markdown')
1804
+ let text = typeof params.text === 'string' ? params.text : null
1805
+ if (text === null) {
1806
+ const current = await readDocBytes(target, target)
1807
+ text = decodeUtf8(current.data)
1808
+ }
1809
+ const temp = await makeDocTemp()
1810
+ try {
1811
+ const input = temp.file('preview.md')
1812
+ writeFileSync(input, text, 'utf8')
1813
+ const data = await runDocTool(['md-preview', '--file', input])
1814
+ return data
1815
+ } finally {
1816
+ temp.cleanup()
1817
+ }
1818
+ },
1819
+
1820
+ async 'docs.mdPdf'(params = {}) {
1821
+ const target = await docTarget(params)
1822
+ assertDocKind(target.path, 'markdown')
1823
+ let text = typeof params.text === 'string' ? params.text : null
1824
+ if (text === null) {
1825
+ const current = await readDocBytes(target, target)
1826
+ text = decodeUtf8(current.data)
1827
+ }
1828
+ const extension = extname(target.path)
1829
+ const base = target.path.slice(0, -extension.length)
1830
+ let outputPath = base + '.pdf'
1831
+ if (target.remote) {
1832
+ const directory = posix.dirname(outputPath)
1833
+ let candidate = outputPath
1834
+ let index = 1
1835
+ while (await target.scope.remote.stat(candidate).catch(() => null)) {
1836
+ candidate = posix.join(directory, posix.basename(base) + '-' + index + '.pdf')
1837
+ index += 1
1838
+ }
1839
+ outputPath = candidate
1840
+ } else {
1841
+ const directory = dirname(outputPath)
1842
+ let candidate = outputPath
1843
+ let index = 1
1844
+ while (existsSync(candidate)) {
1845
+ candidate = join(directory, basename(base) + '-' + index + '.pdf')
1846
+ index += 1
1847
+ }
1848
+ outputPath = candidate
1849
+ }
1850
+ const temp = await makeDocTemp()
1851
+ try {
1852
+ const input = temp.file('export.md')
1853
+ const output = temp.file('export.pdf')
1854
+ writeFileSync(input, text, 'utf8')
1855
+ await runDocTool(['md-pdf', '--file', input, '--out', output], 180000)
1856
+ const written = await writeDocBytes({ ...target, path: outputPath }, readFileSync(output))
1857
+ return { kind: 'pdf', ...written }
1858
+ } finally {
1859
+ temp.cleanup()
1860
+ }
1861
+ },
1862
+
1609
1863
  async 'fs.stat'(params = {}) {
1610
1864
  const scope = await requireScope(params)
1611
1865
  const target = scopeTarget(scope, params.path)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-linked-workspace",
3
- "version": "0.5.12",
3
+ "version": "0.6.0",
4
4
  "description": "Linked local ⇄ remote workspaces and SSH-backed remote directories for DeepSeek Harness: theme-aware split editor, file-type icons, and an agent tool that edits remote files directly.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -22,7 +22,8 @@
22
22
  "LICENSE",
23
23
  "README.en.md",
24
24
  "docs/API.md",
25
- "docs/VERIFY.md"
25
+ "docs/VERIFY.md",
26
+ "scripts/doc_tool.py"
26
27
  ],
27
28
  "keywords": [
28
29
  "deepseek-harness",