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/CHANGELOG.md +17 -0
- package/README.en.md +22 -0
- package/README.md +12 -0
- package/docs/API.md +10 -0
- package/lib/client.js +438 -12
- package/lib/index.js +256 -2
- package/package.json +3 -2
- package/scripts/doc_tool.py +405 -0
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.
|
|
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",
|