dsh-linked-workspace 0.8.1 → 0.8.3
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 +35 -0
- package/README.en.md +29 -0
- package/README.md +29 -0
- package/docs/API.md +6 -6
- package/docs/VERIFY.md +24 -0
- package/lib/client.js +397 -61
- package/lib/index.js +203 -45
- package/lib/linked.js +46 -24
- package/package.json +2 -2
- package/scripts/doc_tool.py +10 -3
package/lib/index.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* addresses and workspace paths.
|
|
17
17
|
*/
|
|
18
18
|
import { randomUUID } from 'node:crypto'
|
|
19
|
-
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, 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'
|
|
@@ -61,6 +61,7 @@ const LINKED_SYNC_BYTES = 50 * 1024 * 1024
|
|
|
61
61
|
const LINKED_SYNC_FILE = 20 * 1024 * 1024
|
|
62
62
|
const LINKED_SYNC_EXCLUDES = new Set(['.git', 'node_modules', '__pycache__', '.conda', '.venv', '.mypy_cache', '.pytest_cache', '.ipynb_checkpoints', '.ruff_cache'])
|
|
63
63
|
const LINKED_SYNC_TIMEOUT_MS = 30_000
|
|
64
|
+
const LINKED_SYNC_MAX_DEPTH = 24
|
|
64
65
|
const LINKED_OPEN_FILE_CAP = 64 * 1024 * 1024
|
|
65
66
|
const MAX_BODY_BYTES = 16 * 1024 * 1024
|
|
66
67
|
const EDITABLE_LIMIT = 2 * 1024 * 1024
|
|
@@ -82,6 +83,32 @@ const IMAGE_MIME = Object.freeze({
|
|
|
82
83
|
const MAX_NAME_LENGTH = 120
|
|
83
84
|
const CONNECT_TIMEOUT_MS = 20_000
|
|
84
85
|
const OP_TIMEOUT_MS = 60_000
|
|
86
|
+
// Directory listings are paged so a folder with tens of thousands of entries
|
|
87
|
+
// cannot stall the Host or flood the renderer.
|
|
88
|
+
const LIST_PAGE_SIZE = 500
|
|
89
|
+
const LIST_PAGE_MAX = 5000
|
|
90
|
+
// SFTP readdir has no paging, so a directory is always read in one call. A
|
|
91
|
+
// freshly read listing is kept briefly so "load more" does not repeat the same
|
|
92
|
+
// expensive scan; a first page (offset 0) is always re-read.
|
|
93
|
+
const LISTING_CACHE_TTL_MS = 5000
|
|
94
|
+
const LISTING_CACHE_LIMIT = 128
|
|
95
|
+
const listingCache = new Map()
|
|
96
|
+
const listingCacheKey = (scope, target) => `${scope.kind === 'linked' ? 'L' : 'W'}:${scope.id}:${target}`
|
|
97
|
+
const rememberListing = (key, listing) => {
|
|
98
|
+
listingCache.delete(key)
|
|
99
|
+
listingCache.set(key, { at: Date.now(), listing })
|
|
100
|
+
while (listingCache.size > LISTING_CACHE_LIMIT) {
|
|
101
|
+
listingCache.delete(listingCache.keys().next().value)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const forgetListings = () => listingCache.clear()
|
|
105
|
+
|
|
106
|
+
/** Clamp a requested page window to the listing limits. */
|
|
107
|
+
const pageWindow = (params = {}, total = 0) => {
|
|
108
|
+
const limit = Math.min(Math.max(Number(params.limit) || LIST_PAGE_SIZE, 1), LIST_PAGE_MAX)
|
|
109
|
+
const offset = Math.min(Math.max(Number(params.offset) || 0, 0), Math.max(total, 0))
|
|
110
|
+
return { limit, offset }
|
|
111
|
+
}
|
|
85
112
|
|
|
86
113
|
const LOG_PREFIX = '[dsh-linked-workspace]'
|
|
87
114
|
|
|
@@ -99,6 +126,7 @@ const ERROR_STATUS = {
|
|
|
99
126
|
FS_IS_DIR: 400,
|
|
100
127
|
FS_NOT_DIR: 400,
|
|
101
128
|
FS_TOO_LARGE: 413,
|
|
129
|
+
FS_INCOMPLETE: 500,
|
|
102
130
|
FS_EXISTS: 409,
|
|
103
131
|
CONFLICT: 409,
|
|
104
132
|
INTERNAL: 500,
|
|
@@ -505,10 +533,31 @@ function mapLocalFsError(error, fallback = 'INTERNAL') {
|
|
|
505
533
|
if (code === 'ENOENT') return new ApiError('FS_NOT_FOUND', '本地路径不存在')
|
|
506
534
|
if (code === 'EACCES' || code === 'EPERM') return new ApiError('FS_PERMISSION', '没有权限访问本地路径')
|
|
507
535
|
if (code === 'ENOTDIR') return new ApiError('FS_NOT_DIR', '本地路径不是目录')
|
|
508
|
-
if (code === 'EEXIST') return new ApiError('
|
|
536
|
+
if (code === 'EEXIST') return new ApiError('FS_EXISTS', '同名文件或文件夹已存在')
|
|
509
537
|
return new ApiError(fallback, error && error.message ? error.message : String(error))
|
|
510
538
|
}
|
|
511
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Read at most `maxBytes` from the start of a local file.
|
|
542
|
+
* Unlike readFileSync this never maps the whole file, so a peek at a huge
|
|
543
|
+
* (or larger-than-2-GiB) file stays cheap.
|
|
544
|
+
*/
|
|
545
|
+
function readHeadSync(absolute, maxBytes) {
|
|
546
|
+
const fd = openSync(absolute, 'r')
|
|
547
|
+
try {
|
|
548
|
+
const buffer = Buffer.allocUnsafe(Math.max(1, Math.min(maxBytes, 16 * 1024 * 1024)))
|
|
549
|
+
let filled = 0
|
|
550
|
+
while (filled < buffer.length) {
|
|
551
|
+
const read = readSync(fd, buffer, filled, buffer.length - filled, filled)
|
|
552
|
+
if (read <= 0) break
|
|
553
|
+
filled += read
|
|
554
|
+
}
|
|
555
|
+
return buffer.subarray(0, filled)
|
|
556
|
+
} finally {
|
|
557
|
+
closeSync(fd)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
512
561
|
function localDrives() {
|
|
513
562
|
if (process.platform !== 'win32') return []
|
|
514
563
|
const drives = []
|
|
@@ -670,6 +719,7 @@ class HostPool {
|
|
|
670
719
|
this.knownHostsFile = knownHostsFile
|
|
671
720
|
this.records = new Map() // hostId -> HostRecord
|
|
672
721
|
this.connections = new Map() // hostId -> { authority, remote: RemoteHost }
|
|
722
|
+
this.connecting = new Map() // hostId -> { authority, promise }
|
|
673
723
|
this.statuses = new Map() // hostId -> { state, message?, latencyMs?, checkedAt }
|
|
674
724
|
}
|
|
675
725
|
|
|
@@ -684,6 +734,12 @@ class HostPool {
|
|
|
684
734
|
this.connections.delete(hostId)
|
|
685
735
|
}
|
|
686
736
|
}
|
|
737
|
+
// In-flight connects for removed or re-addressed hosts must not be reused:
|
|
738
|
+
// the attempt itself notices the lost map entry and disposes its socket.
|
|
739
|
+
for (const [hostId, pending] of [...this.connecting]) {
|
|
740
|
+
const host = this.records.get(hostId)
|
|
741
|
+
if (!host || `${host.host}:${host.port}` !== pending.authority) this.connecting.delete(hostId)
|
|
742
|
+
}
|
|
687
743
|
}
|
|
688
744
|
|
|
689
745
|
host(hostId) {
|
|
@@ -703,8 +759,8 @@ class HostPool {
|
|
|
703
759
|
}
|
|
704
760
|
|
|
705
761
|
async remote(hostRecord) {
|
|
706
|
-
const existing = this.connections.get(hostRecord.id)
|
|
707
762
|
const authority = `${hostRecord.host}:${hostRecord.port}`
|
|
763
|
+
const existing = this.connections.get(hostRecord.id)
|
|
708
764
|
if (existing && existing.authority === authority) {
|
|
709
765
|
try {
|
|
710
766
|
await existing.remote.ensureConnected()
|
|
@@ -715,15 +771,41 @@ class HostPool {
|
|
|
715
771
|
throw error
|
|
716
772
|
}
|
|
717
773
|
}
|
|
774
|
+
// Two concurrent first requests for one host must share one connection;
|
|
775
|
+
// otherwise the second RemoteHost overwrites the map entry and the first
|
|
776
|
+
// SSH socket leaks. The pending attempt is bound to the address it dials,
|
|
777
|
+
// so a host edit during connect never hands the old socket to the new
|
|
778
|
+
// address.
|
|
779
|
+
const pending = this.connecting.get(hostRecord.id)
|
|
780
|
+
if (pending && pending.authority === authority) return pending.promise
|
|
718
781
|
const remote = new RemoteHost({
|
|
719
782
|
host: hostRecord,
|
|
720
783
|
knownHostsFile: this.knownHostsFile,
|
|
721
784
|
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
722
785
|
logger: log,
|
|
723
786
|
})
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
787
|
+
const entry = { authority, remote }
|
|
788
|
+
this.connections.set(hostRecord.id, entry)
|
|
789
|
+
const attempt = (async () => {
|
|
790
|
+
try {
|
|
791
|
+
await remote.connect()
|
|
792
|
+
// The host may have been removed while the socket was connecting: keep
|
|
793
|
+
// the map state authoritative so the unused connection is closed.
|
|
794
|
+
if (this.connections.get(hostRecord.id) !== entry) {
|
|
795
|
+
remote.dispose().catch(() => {})
|
|
796
|
+
}
|
|
797
|
+
return remote
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (this.connections.get(hostRecord.id) === entry) this.connections.delete(hostRecord.id)
|
|
800
|
+
remote.dispose().catch(() => {})
|
|
801
|
+
throw error
|
|
802
|
+
} finally {
|
|
803
|
+
const current = this.connecting.get(hostRecord.id)
|
|
804
|
+
if (current && current.promise === attempt) this.connecting.delete(hostRecord.id)
|
|
805
|
+
}
|
|
806
|
+
})()
|
|
807
|
+
this.connecting.set(hostRecord.id, { authority, promise: attempt })
|
|
808
|
+
return attempt
|
|
727
809
|
}
|
|
728
810
|
|
|
729
811
|
async disposeAll() {
|
|
@@ -1107,8 +1189,11 @@ export function apply(ctx) {
|
|
|
1107
1189
|
const seen = new Set()
|
|
1108
1190
|
const shouldStop = () => job.cancel || job.files >= job.maxFiles || job.bytes >= job.maxBytes
|
|
1109
1191
|
const visit = async (directory, depth) => {
|
|
1110
|
-
|
|
1111
|
-
|
|
1192
|
+
// Hitting the depth limit makes the scan incomplete, exactly like the
|
|
1193
|
+
// file/byte caps: flag it so the removal pass is skipped and the UI
|
|
1194
|
+
// can say the archive is partial.
|
|
1195
|
+
if (depth > LINKED_SYNC_MAX_DEPTH || shouldStop()) {
|
|
1196
|
+
if (!job.cancel) job.truncated = true
|
|
1112
1197
|
return
|
|
1113
1198
|
}
|
|
1114
1199
|
let listing
|
|
@@ -1325,7 +1410,15 @@ export function apply(ctx) {
|
|
|
1325
1410
|
const remoteCopyRecursive = async (remote, source, target) => {
|
|
1326
1411
|
const stat = await remote.stat(source)
|
|
1327
1412
|
if (stat.type !== 'dir') {
|
|
1413
|
+
// readFile truncates silently, so an oversized source has to be refused
|
|
1414
|
+
// here: copying only its first 64 MiB would corrupt the destination.
|
|
1415
|
+
if (stat.size > LINKED_OPEN_FILE_CAP) {
|
|
1416
|
+
throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 超过 64 MB,无法复制`)
|
|
1417
|
+
}
|
|
1328
1418
|
const read = await remote.readFile(source, { maxBytes: LINKED_OPEN_FILE_CAP })
|
|
1419
|
+
if (read.truncated || read.data.length !== stat.size) {
|
|
1420
|
+
throw new ApiError('FS_INCOMPLETE', `${posix.basename(source)} 读取不完整,已取消复制`)
|
|
1421
|
+
}
|
|
1329
1422
|
await remote.writeFileAtomic(target, read.data)
|
|
1330
1423
|
return 1
|
|
1331
1424
|
}
|
|
@@ -1532,6 +1625,7 @@ export function apply(ctx) {
|
|
|
1532
1625
|
// ── document bridge (Markdown/PDF/DOCX/XLSX/PPTX) ──────────────────────
|
|
1533
1626
|
|
|
1534
1627
|
const DOC_MAX_BYTES = 80 * 1024 * 1024
|
|
1628
|
+
const DOC_PDF_SUFFIX_MAX = 999
|
|
1535
1629
|
const DOC_TOOL = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'doc_tool.py')
|
|
1536
1630
|
const DOC_KIND_BY_EXT = new Map([
|
|
1537
1631
|
['.md', 'markdown'], ['.markdown', 'markdown'],
|
|
@@ -1804,17 +1898,34 @@ export function apply(ctx) {
|
|
|
1804
1898
|
const target = params.path === undefined || params.path === ''
|
|
1805
1899
|
? scope.root
|
|
1806
1900
|
: scopeTarget(scope, params.path)
|
|
1807
|
-
const
|
|
1901
|
+
const limit = Math.min(Math.max(Number(params.limit) || LIST_PAGE_SIZE, 1), LIST_PAGE_MAX)
|
|
1902
|
+
const requestedOffset = Math.max(Number(params.offset) || 0, 0)
|
|
1903
|
+
const key = listingCacheKey(scope, target)
|
|
1904
|
+
let listing
|
|
1905
|
+
if (requestedOffset > 0) {
|
|
1906
|
+
const hit = listingCache.get(key)
|
|
1907
|
+
if (hit && Date.now() - hit.at < LISTING_CACHE_TTL_MS) listing = hit.listing
|
|
1908
|
+
}
|
|
1909
|
+
if (!listing) {
|
|
1910
|
+
listing = await scope.remote.listDir(target)
|
|
1911
|
+
rememberListing(key, listing)
|
|
1912
|
+
}
|
|
1913
|
+
const all = listing.entries.map((entry) => ({
|
|
1914
|
+
name: entry.name,
|
|
1915
|
+
path: entry.path,
|
|
1916
|
+
type: entry.type,
|
|
1917
|
+
size: entry.size,
|
|
1918
|
+
mtime: entry.mtime,
|
|
1919
|
+
relative: scopeRelative(scope, entry.path),
|
|
1920
|
+
}))
|
|
1921
|
+
const page = pageWindow({ limit, offset: requestedOffset }, all.length)
|
|
1808
1922
|
return {
|
|
1809
1923
|
path: target,
|
|
1810
|
-
entries:
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
mtime: entry.mtime,
|
|
1816
|
-
relative: scopeRelative(scope, entry.path),
|
|
1817
|
-
})),
|
|
1924
|
+
entries: all.slice(page.offset, page.offset + page.limit),
|
|
1925
|
+
total: all.length,
|
|
1926
|
+
offset: page.offset,
|
|
1927
|
+
limit: page.limit,
|
|
1928
|
+
hasMore: page.offset + page.limit < all.length,
|
|
1818
1929
|
}
|
|
1819
1930
|
},
|
|
1820
1931
|
|
|
@@ -1876,6 +1987,7 @@ export function apply(ctx) {
|
|
|
1876
1987
|
if (apiError.code !== 'FS_NOT_FOUND') throw apiError
|
|
1877
1988
|
current = undefined
|
|
1878
1989
|
}
|
|
1990
|
+
if (params.createOnly === true && current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
|
|
1879
1991
|
if (current && current.type === 'dir') throw new ApiError('FS_IS_DIR', '目标是目录,无法写入')
|
|
1880
1992
|
if (current && typeof params.expectedMtime === 'number' && params.expectedMtime > 0) {
|
|
1881
1993
|
const currentSeconds = Math.floor(current.mtime / 1000)
|
|
@@ -1889,6 +2001,7 @@ export function apply(ctx) {
|
|
|
1889
2001
|
mode: current ? current.mode : undefined,
|
|
1890
2002
|
})
|
|
1891
2003
|
await scopeMirror(scope, target, data)
|
|
2004
|
+
forgetListings()
|
|
1892
2005
|
return { path: target, size: info.size, mtime: info.mtime }
|
|
1893
2006
|
},
|
|
1894
2007
|
|
|
@@ -2013,19 +2126,21 @@ export function apply(ctx) {
|
|
|
2013
2126
|
const directory = posix.dirname(outputPath)
|
|
2014
2127
|
let candidate = outputPath
|
|
2015
2128
|
let index = 1
|
|
2016
|
-
while (await target.scope.remote.stat(candidate).catch(() => null)) {
|
|
2129
|
+
while (index <= DOC_PDF_SUFFIX_MAX && await target.scope.remote.stat(candidate).catch(() => null)) {
|
|
2017
2130
|
candidate = posix.join(directory, posix.basename(base) + '-' + index + '.pdf')
|
|
2018
2131
|
index += 1
|
|
2019
2132
|
}
|
|
2133
|
+
if (index > DOC_PDF_SUFFIX_MAX) throw new ApiError('FS_EXISTS', '同名 PDF 过多,请先清理目录中的旧导出')
|
|
2020
2134
|
outputPath = candidate
|
|
2021
2135
|
} else {
|
|
2022
2136
|
const directory = dirname(outputPath)
|
|
2023
2137
|
let candidate = outputPath
|
|
2024
2138
|
let index = 1
|
|
2025
|
-
while (existsSync(candidate)) {
|
|
2139
|
+
while (index <= DOC_PDF_SUFFIX_MAX && existsSync(candidate)) {
|
|
2026
2140
|
candidate = join(directory, basename(base) + '-' + index + '.pdf')
|
|
2027
2141
|
index += 1
|
|
2028
2142
|
}
|
|
2143
|
+
if (index > DOC_PDF_SUFFIX_MAX) throw new ApiError('FS_EXISTS', '同名 PDF 过多,请先清理目录中的旧导出')
|
|
2029
2144
|
outputPath = candidate
|
|
2030
2145
|
}
|
|
2031
2146
|
const temp = await makeDocTemp()
|
|
@@ -2059,7 +2174,19 @@ export function apply(ctx) {
|
|
|
2059
2174
|
const scope = await requireScope(params)
|
|
2060
2175
|
const target = scopeTarget(scope, params.path)
|
|
2061
2176
|
if (target === scope.root) throw new ApiError('BAD_REQUEST', '不能创建工作区根目录')
|
|
2177
|
+
if (params.createOnly === true) {
|
|
2178
|
+
let current
|
|
2179
|
+
try {
|
|
2180
|
+
current = await scope.remote.stat(target)
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
const apiError = asApiError(error)
|
|
2183
|
+
if (apiError.code !== 'FS_NOT_FOUND') throw apiError
|
|
2184
|
+
current = undefined
|
|
2185
|
+
}
|
|
2186
|
+
if (current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
|
|
2187
|
+
}
|
|
2062
2188
|
await scope.remote.mkdirp(target)
|
|
2189
|
+
forgetListings()
|
|
2063
2190
|
return {}
|
|
2064
2191
|
},
|
|
2065
2192
|
|
|
@@ -2070,6 +2197,7 @@ export function apply(ctx) {
|
|
|
2070
2197
|
if (from === scope.root) throw new ApiError('BAD_REQUEST', '不能移动工作区根目录')
|
|
2071
2198
|
await scope.remote.rename(from, to)
|
|
2072
2199
|
await scopeMirrorRemove(scope, from)
|
|
2200
|
+
forgetListings()
|
|
2073
2201
|
return {}
|
|
2074
2202
|
},
|
|
2075
2203
|
|
|
@@ -2091,6 +2219,7 @@ export function apply(ctx) {
|
|
|
2091
2219
|
if (await scope.remote.stat(target).catch(() => null)) throw new ApiError('FS_EXISTS', '目标目录中已存在同名文件或文件夹')
|
|
2092
2220
|
await scope.remote.rename(source, target)
|
|
2093
2221
|
await scopeMirrorRemove(scope, source)
|
|
2222
|
+
forgetListings()
|
|
2094
2223
|
return { path: target }
|
|
2095
2224
|
},
|
|
2096
2225
|
|
|
@@ -2100,6 +2229,7 @@ export function apply(ctx) {
|
|
|
2100
2229
|
if (target === scope.root) throw new ApiError('BAD_REQUEST', '不能删除工作区根目录')
|
|
2101
2230
|
await remoteDeleteRecursive(scope.remote, target)
|
|
2102
2231
|
await scopeMirrorRemove(scope, target)
|
|
2232
|
+
forgetListings()
|
|
2103
2233
|
return {}
|
|
2104
2234
|
},
|
|
2105
2235
|
|
|
@@ -2107,7 +2237,9 @@ export function apply(ctx) {
|
|
|
2107
2237
|
const scope = await requireScope(params)
|
|
2108
2238
|
if (scope.kind !== 'linked') throw new ApiError('BAD_REQUEST', '只有联动工作区支持文件传输')
|
|
2109
2239
|
if (params.side === 'local') {
|
|
2110
|
-
|
|
2240
|
+
const result = await transferLocalToRemote(scope.record, scope.remote, params.path)
|
|
2241
|
+
forgetListings()
|
|
2242
|
+
return result
|
|
2111
2243
|
}
|
|
2112
2244
|
const target = scopeTarget(scope, params.path)
|
|
2113
2245
|
return await transferToMirror(scope.record, scope.remote, target)
|
|
@@ -2138,7 +2270,16 @@ export function apply(ctx) {
|
|
|
2138
2270
|
throw new ApiError('BAD_REQUEST', '不能把目录复制到自身内部')
|
|
2139
2271
|
}
|
|
2140
2272
|
const target = await remoteUniqueTarget(scope.remote, parent, posix.basename(source))
|
|
2141
|
-
|
|
2273
|
+
let files
|
|
2274
|
+
try {
|
|
2275
|
+
files = await remoteCopyRecursive(scope.remote, source, target)
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
// A half-copied tree is worse than a clean failure: remove what this
|
|
2278
|
+
// call created (the target name was unique, so nothing else lives there).
|
|
2279
|
+
await remoteDeleteRecursive(scope.remote, target).catch(() => {})
|
|
2280
|
+
throw error
|
|
2281
|
+
}
|
|
2282
|
+
forgetListings()
|
|
2142
2283
|
return { path: target, files }
|
|
2143
2284
|
},
|
|
2144
2285
|
|
|
@@ -2187,32 +2328,43 @@ export function apply(ctx) {
|
|
|
2187
2328
|
throw mapLocalFsError(error)
|
|
2188
2329
|
}
|
|
2189
2330
|
if (!info.isDirectory()) throw new ApiError('FS_NOT_DIR', '本地路径不是目录')
|
|
2190
|
-
|
|
2191
|
-
try {
|
|
2192
|
-
rawEntries = await readdir(target, { withFileTypes: true })
|
|
2193
|
-
} catch (error) {
|
|
2331
|
+
const rawEntries = await readdir(target, { withFileTypes: true }).catch((error) => {
|
|
2194
2332
|
throw mapLocalFsError(error)
|
|
2195
|
-
}
|
|
2333
|
+
})
|
|
2196
2334
|
const entries = []
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2335
|
+
// Stat in small parallel batches: one await per entry is slow for big
|
|
2336
|
+
// folders, but an unbounded Promise.all can exhaust file descriptors.
|
|
2337
|
+
for (let start = 0; start < rawEntries.length; start += 128) {
|
|
2338
|
+
const batch = rawEntries.slice(start, start + 128)
|
|
2339
|
+
const stated = await Promise.all(batch.map(async (entry) => {
|
|
2340
|
+
const full = join(target, entry.name)
|
|
2341
|
+
let stat
|
|
2342
|
+
try {
|
|
2343
|
+
stat = await fsStat(full)
|
|
2344
|
+
} catch {
|
|
2345
|
+
return null
|
|
2346
|
+
}
|
|
2347
|
+
let type = 'file'
|
|
2348
|
+
if (stat.isDirectory()) type = 'dir'
|
|
2349
|
+
else if (stat.isSymbolicLink()) type = 'symlink'
|
|
2350
|
+
return { name: entry.name, path: full, type, size: stat.size, mtime: stat.mtimeMs }
|
|
2351
|
+
}))
|
|
2352
|
+
for (const item of stated) if (item) entries.push(item)
|
|
2209
2353
|
}
|
|
2210
2354
|
entries.sort((left, right) => {
|
|
2211
2355
|
if (left.type === 'dir' && right.type !== 'dir') return -1
|
|
2212
2356
|
if (right.type === 'dir' && left.type !== 'dir') return 1
|
|
2213
2357
|
return left.name.localeCompare(right.name)
|
|
2214
2358
|
})
|
|
2215
|
-
|
|
2359
|
+
const page = pageWindow(params, entries.length)
|
|
2360
|
+
return {
|
|
2361
|
+
path: target,
|
|
2362
|
+
entries: entries.slice(page.offset, page.offset + page.limit),
|
|
2363
|
+
total: entries.length,
|
|
2364
|
+
offset: page.offset,
|
|
2365
|
+
limit: page.limit,
|
|
2366
|
+
hasMore: page.offset + page.limit < entries.length,
|
|
2367
|
+
}
|
|
2216
2368
|
},
|
|
2217
2369
|
|
|
2218
2370
|
async 'local.stat'(params = {}) {
|
|
@@ -2246,13 +2398,15 @@ export function apply(ctx) {
|
|
|
2246
2398
|
if (info.isDirectory()) throw new ApiError('FS_IS_DIR', '目标是目录,无法作为文件读取')
|
|
2247
2399
|
const tooLarge = info.size > EDITABLE_LIMIT
|
|
2248
2400
|
const cap = tooLarge ? PEEK_LIMIT : READ_CAP
|
|
2401
|
+
// Read at most `cap` bytes. Reading the whole file first would pull
|
|
2402
|
+
// multi-gigabyte files into Host memory (and on Windows readFileSync
|
|
2403
|
+
// throws ERR_FS_FILE_TOO_LARGE above 2 GiB even for a peek).
|
|
2249
2404
|
let data
|
|
2250
2405
|
try {
|
|
2251
|
-
data =
|
|
2406
|
+
data = readHeadSync(absolute, cap)
|
|
2252
2407
|
} catch (error) {
|
|
2253
2408
|
throw mapLocalFsError(error)
|
|
2254
2409
|
}
|
|
2255
|
-
if (data.length > cap) data = data.subarray(0, cap)
|
|
2256
2410
|
const binary = looksBinary(data)
|
|
2257
2411
|
return {
|
|
2258
2412
|
path: absolute,
|
|
@@ -2640,7 +2794,6 @@ export function apply(ctx) {
|
|
|
2640
2794
|
if (nextName.length > MAX_NAME_LENGTH) throw new ApiError('BAD_REQUEST', '工作区名称过长')
|
|
2641
2795
|
const nextMirror = typeof params.mirror === 'boolean' ? params.mirror : record.mirror
|
|
2642
2796
|
const renamed = nextName !== '' && nextName !== record.name
|
|
2643
|
-
if (renamed && nextName === '') throw new ApiError('BAD_REQUEST', '名称不能为空')
|
|
2644
2797
|
record.name = nextName !== '' ? nextName : record.name
|
|
2645
2798
|
record.mirror = nextMirror
|
|
2646
2799
|
if (nextMirror === false) {
|
|
@@ -2919,8 +3072,11 @@ export function apply(ctx) {
|
|
|
2919
3072
|
return toolText(`${target} (${stat.size} B, mtime ${stat.mtime}) is a binary file; use list_dir/stat or download it locally.`)
|
|
2920
3073
|
}
|
|
2921
3074
|
const content = decodeUtf8(read.data)
|
|
2922
|
-
const
|
|
2923
|
-
|
|
3075
|
+
const truncated = read.data.length < stat.size
|
|
3076
|
+
const suffix = truncated ? `\n...(truncated at ${read.data.length} of ${stat.size} bytes)` : ''
|
|
3077
|
+
// Only a complete read may be archived: mirroring a truncated prefix
|
|
3078
|
+
// would leave a silently corrupt copy under .remote.
|
|
3079
|
+
if (!truncated) await mirrorWrite(link, target, read.data)
|
|
2924
3080
|
return toolText(`${target} (${stat.size} B, mtime ${stat.mtime})${suffix}\n\n${content}`)
|
|
2925
3081
|
}
|
|
2926
3082
|
|
|
@@ -2993,7 +3149,9 @@ export function apply(ctx) {
|
|
|
2993
3149
|
if (action === 'delete') {
|
|
2994
3150
|
if (target === root) throw new ApiError('BAD_REQUEST', '不能删除联动工作区根目录')
|
|
2995
3151
|
const stat = await remote.stat(target)
|
|
2996
|
-
|
|
3152
|
+
// rmdir only removes empty directories (same as the UI's fs.delete,
|
|
3153
|
+
// which uses this recursive helper).
|
|
3154
|
+
if (stat.type === 'dir') await remoteDeleteRecursive(remote, target)
|
|
2997
3155
|
else await remote.unlink(target)
|
|
2998
3156
|
await linkedFs.removeMirrorPath(link.localPath, linkedRelative(link, target)).catch(() => {})
|
|
2999
3157
|
return toolText(`Deleted ${target}`)
|
package/lib/linked.js
CHANGED
|
@@ -509,6 +509,35 @@ function isContained(realRoot, realTarget) {
|
|
|
509
509
|
return offset !== '' && !offset.startsWith('..') && !isAbsolute(offset)
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Resolve a path the way the OS does. The JS `realpathSync` does not follow
|
|
514
|
+
* Windows junctions, so a junction pointing outside the workspace would pass a
|
|
515
|
+
* plain string containment test; the native call resolves it correctly.
|
|
516
|
+
*/
|
|
517
|
+
function realPathOf(target) {
|
|
518
|
+
return typeof realpathSync.native === 'function' ? realpathSync.native(target) : realpathSync(target)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* True for symlinks and for Windows junctions, which `lstatSync` reports as
|
|
523
|
+
* ordinary directories (mode 40666, `isSymbolicLink() === false`).
|
|
524
|
+
*/
|
|
525
|
+
function isLinkLike(target, stats) {
|
|
526
|
+
if (stats && stats.isSymbolicLink()) return true
|
|
527
|
+
if (process.platform !== 'win32') return false
|
|
528
|
+
try {
|
|
529
|
+
return realPathOf(target) !== resolve(target)
|
|
530
|
+
} catch {
|
|
531
|
+
return false
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function unsafeLinkError(target, detail) {
|
|
536
|
+
const error = new Error(`refusing to remove ${target}: ${detail}`)
|
|
537
|
+
error.code = 'ELINKED_UNSAFE'
|
|
538
|
+
return error
|
|
539
|
+
}
|
|
540
|
+
|
|
512
541
|
/**
|
|
513
542
|
* Remove empty parent directories of `target`, stopping at `stopAt` (exclusive).
|
|
514
543
|
* Best effort: the first non-empty or unreadable directory ends the walk.
|
|
@@ -550,8 +579,8 @@ export async function removeMirrorPath(localPath, relativePath) {
|
|
|
550
579
|
const mirrorRoot = join(root, MIRROR_DIR)
|
|
551
580
|
const target = join(mirrorRoot, ...relativeValue.split('/'))
|
|
552
581
|
|
|
553
|
-
// A symlinked `.remote` would redirect every operation
|
|
554
|
-
// workspace; refuse instead of deleting through it.
|
|
582
|
+
// A symlinked or junctioned `.remote` would redirect every operation
|
|
583
|
+
// outside the workspace; refuse instead of deleting through it.
|
|
555
584
|
let mirrorStats
|
|
556
585
|
try {
|
|
557
586
|
mirrorStats = lstatSync(mirrorRoot)
|
|
@@ -559,12 +588,13 @@ export async function removeMirrorPath(localPath, relativePath) {
|
|
|
559
588
|
if (error && error.code === 'ENOENT') return false
|
|
560
589
|
throw error
|
|
561
590
|
}
|
|
562
|
-
if (mirrorStats
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
591
|
+
if (isLinkLike(mirrorRoot, mirrorStats)) {
|
|
592
|
+
throw unsafeLinkError(target, `${mirrorRoot} is a symbolic link`)
|
|
593
|
+
}
|
|
594
|
+
const realRoot = realPathOf(root)
|
|
595
|
+
const realMirror = realPathOf(mirrorRoot)
|
|
596
|
+
if (!isContained(realRoot, realMirror)) {
|
|
597
|
+
throw unsafeLinkError(target, `${mirrorRoot} resolves outside ${root}`)
|
|
568
598
|
}
|
|
569
599
|
|
|
570
600
|
let stats
|
|
@@ -576,18 +606,13 @@ export async function removeMirrorPath(localPath, relativePath) {
|
|
|
576
606
|
throw error
|
|
577
607
|
}
|
|
578
608
|
|
|
579
|
-
if (stats
|
|
609
|
+
if (isLinkLike(target, stats)) {
|
|
580
610
|
// Delete the link itself, never what it points at.
|
|
581
611
|
removeSymlink(target)
|
|
582
612
|
} else {
|
|
583
|
-
const
|
|
584
|
-
const realTarget = realpathSync(target)
|
|
613
|
+
const realTarget = realPathOf(target)
|
|
585
614
|
if (!isContained(realMirror, realTarget)) {
|
|
586
|
-
|
|
587
|
-
`refusing to remove ${target}: it resolves outside ${mirrorRoot}`,
|
|
588
|
-
)
|
|
589
|
-
error.code = 'ELINKED_UNSAFE'
|
|
590
|
-
throw error
|
|
615
|
+
throw unsafeLinkError(target, `it resolves outside ${mirrorRoot}`)
|
|
591
616
|
}
|
|
592
617
|
rmSync(target, { recursive: true, force: true, maxRetries: 3 })
|
|
593
618
|
}
|
|
@@ -626,18 +651,15 @@ function removeOwnedDir(target, root) {
|
|
|
626
651
|
if (error && error.code === 'ENOENT') return false
|
|
627
652
|
throw error
|
|
628
653
|
}
|
|
629
|
-
if (stats
|
|
654
|
+
if (isLinkLike(target, stats)) {
|
|
655
|
+
// A junction/symlink owns only the link: remove it, keep the data outside.
|
|
630
656
|
removeSymlink(target)
|
|
631
657
|
return true
|
|
632
658
|
}
|
|
633
|
-
const realRoot =
|
|
634
|
-
const realTarget =
|
|
659
|
+
const realRoot = realPathOf(root)
|
|
660
|
+
const realTarget = realPathOf(target)
|
|
635
661
|
if (!isContained(realRoot, realTarget)) {
|
|
636
|
-
|
|
637
|
-
`refusing to remove ${target}: it resolves outside ${root}`,
|
|
638
|
-
)
|
|
639
|
-
error.code = 'ELINKED_UNSAFE'
|
|
640
|
-
throw error
|
|
662
|
+
throw unsafeLinkError(target, `it resolves outside ${root}`)
|
|
641
663
|
}
|
|
642
664
|
rmSync(target, { recursive: true, force: true, maxRetries: 3 })
|
|
643
665
|
return true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-linked-workspace",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
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",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "node scripts/build-client.mjs",
|
|
75
|
-
"test": "node --test test/linked.test.mjs test/parts.test.mjs test/git.test.mjs && node test/client-smoke.mjs",
|
|
75
|
+
"test": "node --test test/linked.test.mjs test/parts.test.mjs test/git.test.mjs test/file-creation.test.mjs && node test/client-smoke.mjs",
|
|
76
76
|
"prepack": "node scripts/build-client.mjs"
|
|
77
77
|
},
|
|
78
78
|
"repository": {
|
package/scripts/doc_tool.py
CHANGED
|
@@ -106,18 +106,25 @@ def read_xlsx(path):
|
|
|
106
106
|
sheets = []
|
|
107
107
|
for sheet in workbook.worksheets[:20]:
|
|
108
108
|
rows = []
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
total_row = sheet.max_row or 0
|
|
110
|
+
total_col = sheet.max_column or 0
|
|
111
|
+
max_row = min(total_row, MAX_SHEET_ROWS)
|
|
112
|
+
max_col = min(total_col, MAX_SHEET_COLS)
|
|
111
113
|
for row in sheet.iter_rows(min_row=1, max_row=max_row, min_col=1, max_col=max_col):
|
|
112
114
|
rows.append([json_value(cell.value) for cell in row])
|
|
113
115
|
sheets.append({
|
|
114
116
|
"name": sheet.title,
|
|
115
117
|
"rows": rows,
|
|
118
|
+
# maxRow/maxCol stay the rendered window so the client can size the
|
|
119
|
+
# grid; totalRow/totalCol/truncated tell it the sheet is larger.
|
|
116
120
|
"maxRow": max_row,
|
|
117
121
|
"maxCol": max_col,
|
|
122
|
+
"totalRow": total_row,
|
|
123
|
+
"totalCol": total_col,
|
|
124
|
+
"truncated": total_row > max_row or total_col > max_col,
|
|
118
125
|
"frozen": bool(sheet.freeze_panes),
|
|
119
126
|
})
|
|
120
|
-
emit({"kind": "xlsx", "sheets": sheets, "sheetCount": len(workbook.worksheets)})
|
|
127
|
+
emit({"kind": "xlsx", "sheets": sheets, "sheetCount": len(workbook.worksheets), "sheetLimit": 20})
|
|
121
128
|
|
|
122
129
|
|
|
123
130
|
def write_xlsx(source, data, out):
|