dsh-linked-workspace 0.8.0 → 0.8.2
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 +26 -0
- package/README.en.md +16 -1
- package/README.md +16 -1
- package/docs/API.md +6 -6
- package/docs/VERIFY.md +22 -0
- package/lib/client.js +535 -65
- package/lib/index.js +138 -40
- 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,17 @@ 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
|
+
|
|
91
|
+
/** Clamp a requested page window to the listing limits. */
|
|
92
|
+
const pageWindow = (params = {}, total = 0) => {
|
|
93
|
+
const limit = Math.min(Math.max(Number(params.limit) || LIST_PAGE_SIZE, 1), LIST_PAGE_MAX)
|
|
94
|
+
const offset = Math.min(Math.max(Number(params.offset) || 0, 0), Math.max(total, 0))
|
|
95
|
+
return { limit, offset }
|
|
96
|
+
}
|
|
85
97
|
|
|
86
98
|
const LOG_PREFIX = '[dsh-linked-workspace]'
|
|
87
99
|
|
|
@@ -505,10 +517,31 @@ function mapLocalFsError(error, fallback = 'INTERNAL') {
|
|
|
505
517
|
if (code === 'ENOENT') return new ApiError('FS_NOT_FOUND', '本地路径不存在')
|
|
506
518
|
if (code === 'EACCES' || code === 'EPERM') return new ApiError('FS_PERMISSION', '没有权限访问本地路径')
|
|
507
519
|
if (code === 'ENOTDIR') return new ApiError('FS_NOT_DIR', '本地路径不是目录')
|
|
508
|
-
if (code === 'EEXIST') return new ApiError('
|
|
520
|
+
if (code === 'EEXIST') return new ApiError('FS_EXISTS', '同名文件或文件夹已存在')
|
|
509
521
|
return new ApiError(fallback, error && error.message ? error.message : String(error))
|
|
510
522
|
}
|
|
511
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Read at most `maxBytes` from the start of a local file.
|
|
526
|
+
* Unlike readFileSync this never maps the whole file, so a peek at a huge
|
|
527
|
+
* (or larger-than-2-GiB) file stays cheap.
|
|
528
|
+
*/
|
|
529
|
+
function readHeadSync(absolute, maxBytes) {
|
|
530
|
+
const fd = openSync(absolute, 'r')
|
|
531
|
+
try {
|
|
532
|
+
const buffer = Buffer.allocUnsafe(Math.max(1, Math.min(maxBytes, 16 * 1024 * 1024)))
|
|
533
|
+
let filled = 0
|
|
534
|
+
while (filled < buffer.length) {
|
|
535
|
+
const read = readSync(fd, buffer, filled, buffer.length - filled, filled)
|
|
536
|
+
if (read <= 0) break
|
|
537
|
+
filled += read
|
|
538
|
+
}
|
|
539
|
+
return buffer.subarray(0, filled)
|
|
540
|
+
} finally {
|
|
541
|
+
closeSync(fd)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
512
545
|
function localDrives() {
|
|
513
546
|
if (process.platform !== 'win32') return []
|
|
514
547
|
const drives = []
|
|
@@ -670,6 +703,7 @@ class HostPool {
|
|
|
670
703
|
this.knownHostsFile = knownHostsFile
|
|
671
704
|
this.records = new Map() // hostId -> HostRecord
|
|
672
705
|
this.connections = new Map() // hostId -> { authority, remote: RemoteHost }
|
|
706
|
+
this.connecting = new Map() // hostId -> in-flight connect promise
|
|
673
707
|
this.statuses = new Map() // hostId -> { state, message?, latencyMs?, checkedAt }
|
|
674
708
|
}
|
|
675
709
|
|
|
@@ -703,8 +737,8 @@ class HostPool {
|
|
|
703
737
|
}
|
|
704
738
|
|
|
705
739
|
async remote(hostRecord) {
|
|
706
|
-
const existing = this.connections.get(hostRecord.id)
|
|
707
740
|
const authority = `${hostRecord.host}:${hostRecord.port}`
|
|
741
|
+
const existing = this.connections.get(hostRecord.id)
|
|
708
742
|
if (existing && existing.authority === authority) {
|
|
709
743
|
try {
|
|
710
744
|
await existing.remote.ensureConnected()
|
|
@@ -715,15 +749,33 @@ class HostPool {
|
|
|
715
749
|
throw error
|
|
716
750
|
}
|
|
717
751
|
}
|
|
752
|
+
// Two concurrent first requests for one host must share one connection;
|
|
753
|
+
// otherwise the second RemoteHost overwrites the map entry and the first
|
|
754
|
+
// SSH socket leaks.
|
|
755
|
+
const pending = this.connecting.get(hostRecord.id)
|
|
756
|
+
if (pending) return pending
|
|
718
757
|
const remote = new RemoteHost({
|
|
719
758
|
host: hostRecord,
|
|
720
759
|
knownHostsFile: this.knownHostsFile,
|
|
721
760
|
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
|
722
761
|
logger: log,
|
|
723
762
|
})
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
763
|
+
const entry = { authority, remote }
|
|
764
|
+
this.connections.set(hostRecord.id, entry)
|
|
765
|
+
const attempt = (async () => {
|
|
766
|
+
try {
|
|
767
|
+
await remote.connect()
|
|
768
|
+
return remote
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (this.connections.get(hostRecord.id) === entry) this.connections.delete(hostRecord.id)
|
|
771
|
+
remote.dispose().catch(() => {})
|
|
772
|
+
throw error
|
|
773
|
+
} finally {
|
|
774
|
+
this.connecting.delete(hostRecord.id)
|
|
775
|
+
}
|
|
776
|
+
})()
|
|
777
|
+
this.connecting.set(hostRecord.id, attempt)
|
|
778
|
+
return attempt
|
|
727
779
|
}
|
|
728
780
|
|
|
729
781
|
async disposeAll() {
|
|
@@ -1107,8 +1159,11 @@ export function apply(ctx) {
|
|
|
1107
1159
|
const seen = new Set()
|
|
1108
1160
|
const shouldStop = () => job.cancel || job.files >= job.maxFiles || job.bytes >= job.maxBytes
|
|
1109
1161
|
const visit = async (directory, depth) => {
|
|
1110
|
-
|
|
1111
|
-
|
|
1162
|
+
// Hitting the depth limit makes the scan incomplete, exactly like the
|
|
1163
|
+
// file/byte caps: flag it so the removal pass is skipped and the UI
|
|
1164
|
+
// can say the archive is partial.
|
|
1165
|
+
if (depth > LINKED_SYNC_MAX_DEPTH || shouldStop()) {
|
|
1166
|
+
if (!job.cancel) job.truncated = true
|
|
1112
1167
|
return
|
|
1113
1168
|
}
|
|
1114
1169
|
let listing
|
|
@@ -1325,7 +1380,15 @@ export function apply(ctx) {
|
|
|
1325
1380
|
const remoteCopyRecursive = async (remote, source, target) => {
|
|
1326
1381
|
const stat = await remote.stat(source)
|
|
1327
1382
|
if (stat.type !== 'dir') {
|
|
1383
|
+
// readFile truncates silently, so an oversized source has to be refused
|
|
1384
|
+
// here: copying only its first 64 MiB would corrupt the destination.
|
|
1385
|
+
if (stat.size > LINKED_OPEN_FILE_CAP) {
|
|
1386
|
+
throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 超过 64 MB,无法复制`)
|
|
1387
|
+
}
|
|
1328
1388
|
const read = await remote.readFile(source, { maxBytes: LINKED_OPEN_FILE_CAP })
|
|
1389
|
+
if (read.truncated || read.data.length !== stat.size) {
|
|
1390
|
+
throw new ApiError('FS_TOO_LARGE', `${posix.basename(source)} 读取不完整,已取消复制`)
|
|
1391
|
+
}
|
|
1329
1392
|
await remote.writeFileAtomic(target, read.data)
|
|
1330
1393
|
return 1
|
|
1331
1394
|
}
|
|
@@ -1805,16 +1868,22 @@ export function apply(ctx) {
|
|
|
1805
1868
|
? scope.root
|
|
1806
1869
|
: scopeTarget(scope, params.path)
|
|
1807
1870
|
const listing = await scope.remote.listDir(target)
|
|
1871
|
+
const all = listing.entries.map((entry) => ({
|
|
1872
|
+
name: entry.name,
|
|
1873
|
+
path: entry.path,
|
|
1874
|
+
type: entry.type,
|
|
1875
|
+
size: entry.size,
|
|
1876
|
+
mtime: entry.mtime,
|
|
1877
|
+
relative: scopeRelative(scope, entry.path),
|
|
1878
|
+
}))
|
|
1879
|
+
const page = pageWindow(params, all.length)
|
|
1808
1880
|
return {
|
|
1809
1881
|
path: target,
|
|
1810
|
-
entries:
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
mtime: entry.mtime,
|
|
1816
|
-
relative: scopeRelative(scope, entry.path),
|
|
1817
|
-
})),
|
|
1882
|
+
entries: all.slice(page.offset, page.offset + page.limit),
|
|
1883
|
+
total: all.length,
|
|
1884
|
+
offset: page.offset,
|
|
1885
|
+
limit: page.limit,
|
|
1886
|
+
hasMore: page.offset + page.limit < all.length,
|
|
1818
1887
|
}
|
|
1819
1888
|
},
|
|
1820
1889
|
|
|
@@ -1876,6 +1945,7 @@ export function apply(ctx) {
|
|
|
1876
1945
|
if (apiError.code !== 'FS_NOT_FOUND') throw apiError
|
|
1877
1946
|
current = undefined
|
|
1878
1947
|
}
|
|
1948
|
+
if (params.createOnly === true && current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
|
|
1879
1949
|
if (current && current.type === 'dir') throw new ApiError('FS_IS_DIR', '目标是目录,无法写入')
|
|
1880
1950
|
if (current && typeof params.expectedMtime === 'number' && params.expectedMtime > 0) {
|
|
1881
1951
|
const currentSeconds = Math.floor(current.mtime / 1000)
|
|
@@ -2059,6 +2129,17 @@ export function apply(ctx) {
|
|
|
2059
2129
|
const scope = await requireScope(params)
|
|
2060
2130
|
const target = scopeTarget(scope, params.path)
|
|
2061
2131
|
if (target === scope.root) throw new ApiError('BAD_REQUEST', '不能创建工作区根目录')
|
|
2132
|
+
if (params.createOnly === true) {
|
|
2133
|
+
let current
|
|
2134
|
+
try {
|
|
2135
|
+
current = await scope.remote.stat(target)
|
|
2136
|
+
} catch (error) {
|
|
2137
|
+
const apiError = asApiError(error)
|
|
2138
|
+
if (apiError.code !== 'FS_NOT_FOUND') throw apiError
|
|
2139
|
+
current = undefined
|
|
2140
|
+
}
|
|
2141
|
+
if (current) throw new ApiError('FS_EXISTS', '目标已存在,请使用其他名称')
|
|
2142
|
+
}
|
|
2062
2143
|
await scope.remote.mkdirp(target)
|
|
2063
2144
|
return {}
|
|
2064
2145
|
},
|
|
@@ -2187,32 +2268,43 @@ export function apply(ctx) {
|
|
|
2187
2268
|
throw mapLocalFsError(error)
|
|
2188
2269
|
}
|
|
2189
2270
|
if (!info.isDirectory()) throw new ApiError('FS_NOT_DIR', '本地路径不是目录')
|
|
2190
|
-
|
|
2191
|
-
try {
|
|
2192
|
-
rawEntries = await readdir(target, { withFileTypes: true })
|
|
2193
|
-
} catch (error) {
|
|
2271
|
+
const rawEntries = await readdir(target, { withFileTypes: true }).catch((error) => {
|
|
2194
2272
|
throw mapLocalFsError(error)
|
|
2195
|
-
}
|
|
2273
|
+
})
|
|
2196
2274
|
const entries = []
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2275
|
+
// Stat in small parallel batches: one await per entry is slow for big
|
|
2276
|
+
// folders, but an unbounded Promise.all can exhaust file descriptors.
|
|
2277
|
+
for (let start = 0; start < rawEntries.length; start += 128) {
|
|
2278
|
+
const batch = rawEntries.slice(start, start + 128)
|
|
2279
|
+
const stated = await Promise.all(batch.map(async (entry) => {
|
|
2280
|
+
const full = join(target, entry.name)
|
|
2281
|
+
let stat
|
|
2282
|
+
try {
|
|
2283
|
+
stat = await fsStat(full)
|
|
2284
|
+
} catch {
|
|
2285
|
+
return null
|
|
2286
|
+
}
|
|
2287
|
+
let type = 'file'
|
|
2288
|
+
if (stat.isDirectory()) type = 'dir'
|
|
2289
|
+
else if (stat.isSymbolicLink()) type = 'symlink'
|
|
2290
|
+
return { name: entry.name, path: full, type, size: stat.size, mtime: stat.mtimeMs }
|
|
2291
|
+
}))
|
|
2292
|
+
for (const item of stated) if (item) entries.push(item)
|
|
2209
2293
|
}
|
|
2210
2294
|
entries.sort((left, right) => {
|
|
2211
2295
|
if (left.type === 'dir' && right.type !== 'dir') return -1
|
|
2212
2296
|
if (right.type === 'dir' && left.type !== 'dir') return 1
|
|
2213
2297
|
return left.name.localeCompare(right.name)
|
|
2214
2298
|
})
|
|
2215
|
-
|
|
2299
|
+
const page = pageWindow(params, entries.length)
|
|
2300
|
+
return {
|
|
2301
|
+
path: target,
|
|
2302
|
+
entries: entries.slice(page.offset, page.offset + page.limit),
|
|
2303
|
+
total: entries.length,
|
|
2304
|
+
offset: page.offset,
|
|
2305
|
+
limit: page.limit,
|
|
2306
|
+
hasMore: page.offset + page.limit < entries.length,
|
|
2307
|
+
}
|
|
2216
2308
|
},
|
|
2217
2309
|
|
|
2218
2310
|
async 'local.stat'(params = {}) {
|
|
@@ -2246,13 +2338,15 @@ export function apply(ctx) {
|
|
|
2246
2338
|
if (info.isDirectory()) throw new ApiError('FS_IS_DIR', '目标是目录,无法作为文件读取')
|
|
2247
2339
|
const tooLarge = info.size > EDITABLE_LIMIT
|
|
2248
2340
|
const cap = tooLarge ? PEEK_LIMIT : READ_CAP
|
|
2341
|
+
// Read at most `cap` bytes. Reading the whole file first would pull
|
|
2342
|
+
// multi-gigabyte files into Host memory (and on Windows readFileSync
|
|
2343
|
+
// throws ERR_FS_FILE_TOO_LARGE above 2 GiB even for a peek).
|
|
2249
2344
|
let data
|
|
2250
2345
|
try {
|
|
2251
|
-
data =
|
|
2346
|
+
data = readHeadSync(absolute, cap)
|
|
2252
2347
|
} catch (error) {
|
|
2253
2348
|
throw mapLocalFsError(error)
|
|
2254
2349
|
}
|
|
2255
|
-
if (data.length > cap) data = data.subarray(0, cap)
|
|
2256
2350
|
const binary = looksBinary(data)
|
|
2257
2351
|
return {
|
|
2258
2352
|
path: absolute,
|
|
@@ -2640,7 +2734,6 @@ export function apply(ctx) {
|
|
|
2640
2734
|
if (nextName.length > MAX_NAME_LENGTH) throw new ApiError('BAD_REQUEST', '工作区名称过长')
|
|
2641
2735
|
const nextMirror = typeof params.mirror === 'boolean' ? params.mirror : record.mirror
|
|
2642
2736
|
const renamed = nextName !== '' && nextName !== record.name
|
|
2643
|
-
if (renamed && nextName === '') throw new ApiError('BAD_REQUEST', '名称不能为空')
|
|
2644
2737
|
record.name = nextName !== '' ? nextName : record.name
|
|
2645
2738
|
record.mirror = nextMirror
|
|
2646
2739
|
if (nextMirror === false) {
|
|
@@ -2919,8 +3012,11 @@ export function apply(ctx) {
|
|
|
2919
3012
|
return toolText(`${target} (${stat.size} B, mtime ${stat.mtime}) is a binary file; use list_dir/stat or download it locally.`)
|
|
2920
3013
|
}
|
|
2921
3014
|
const content = decodeUtf8(read.data)
|
|
2922
|
-
const
|
|
2923
|
-
|
|
3015
|
+
const truncated = read.data.length < stat.size
|
|
3016
|
+
const suffix = truncated ? `\n...(truncated at ${read.data.length} of ${stat.size} bytes)` : ''
|
|
3017
|
+
// Only a complete read may be archived: mirroring a truncated prefix
|
|
3018
|
+
// would leave a silently corrupt copy under .remote.
|
|
3019
|
+
if (!truncated) await mirrorWrite(link, target, read.data)
|
|
2924
3020
|
return toolText(`${target} (${stat.size} B, mtime ${stat.mtime})${suffix}\n\n${content}`)
|
|
2925
3021
|
}
|
|
2926
3022
|
|
|
@@ -2993,7 +3089,9 @@ export function apply(ctx) {
|
|
|
2993
3089
|
if (action === 'delete') {
|
|
2994
3090
|
if (target === root) throw new ApiError('BAD_REQUEST', '不能删除联动工作区根目录')
|
|
2995
3091
|
const stat = await remote.stat(target)
|
|
2996
|
-
|
|
3092
|
+
// rmdir only removes empty directories (same as the UI's fs.delete,
|
|
3093
|
+
// which uses this recursive helper).
|
|
3094
|
+
if (stat.type === 'dir') await remoteDeleteRecursive(remote, target)
|
|
2997
3095
|
else await remote.unlink(target)
|
|
2998
3096
|
await linkedFs.removeMirrorPath(link.localPath, linkedRelative(link, target)).catch(() => {})
|
|
2999
3097
|
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.2",
|
|
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):
|