dsh-remote-workspaces 0.1.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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Local filesystem backend (the "local path" half of the routing provider).
3
+ *
4
+ * Self-contained over `node:fs` so the plugin does not depend on the
5
+ * `deepseek-harness` local backend (which would register `ctx.fs` and conflict
6
+ * with the routing provider). Implements the same 12 operations as the
7
+ * `@deepseek-ai/dsh-fs` `FileSystem` seam, on plain `{ targetKey, displayPath }`
8
+ * targets and plain version strings.
9
+ */
10
+
11
+ import { readFile, readdir, rename, rm, stat, lstat, realpath, writeFile } from 'node:fs/promises'
12
+ import { isAbsolute, join, relative, resolve, dirname, basename, sep } from 'node:path'
13
+ import { pathToFileURL } from 'node:url'
14
+ import { fsError } from './errors.js'
15
+ import { writableRoots, isPathUnder } from './containment.js'
16
+
17
+ function versionOf(st) {
18
+ return `mtime:${Math.round(st.mtimeMs)}:size:${st.size}`
19
+ }
20
+
21
+ function typeOf(st) {
22
+ if (st.isDirectory()) return 'directory'
23
+ if (st.isFile()) return 'file'
24
+ return 'other'
25
+ }
26
+
27
+ export class LocalBackend {
28
+ constructor({ cwd = process.cwd(), getPolicy } = {}) {
29
+ this.cwd = cwd
30
+ this.getPolicy = getPolicy
31
+ }
32
+
33
+ async resolve(path, opts = {}) {
34
+ const base = opts.cwd ?? this.cwd
35
+ const displayPath = isAbsolute(path) ? resolve(path) : resolve(base, path)
36
+ let targetKey = displayPath
37
+ try {
38
+ targetKey = await realpath(displayPath)
39
+ } catch {
40
+ // A path about to be created has no canonical form yet; keep the resolved path.
41
+ }
42
+ return { targetKey, displayPath }
43
+ }
44
+
45
+ processPath(target) {
46
+ return target.targetKey
47
+ }
48
+
49
+ fileUrl(target) {
50
+ return pathToFileURL(target.targetKey).href
51
+ }
52
+
53
+ contains(parent, child) {
54
+ const rel = relative(parent.targetKey, child.targetKey)
55
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
56
+ }
57
+
58
+ async stat(target) {
59
+ try {
60
+ const st = await stat(target.targetKey)
61
+ return { version: versionOf(st), type: typeOf(st), ...(st.isFile() ? { size: st.size } : {}) }
62
+ } catch (error) {
63
+ if (error && error.code === 'ENOENT') return undefined
64
+ throw fsError('FS_IO_ERROR', `cannot stat "${target.displayPath}": ${error.message}`, error)
65
+ }
66
+ }
67
+
68
+ async lstat(path, opts = {}) {
69
+ const base = opts.cwd ?? this.cwd
70
+ const full = isAbsolute(path) ? path : resolve(base, path)
71
+ try {
72
+ const st = await lstat(full)
73
+ const type = st.isSymbolicLink() ? 'symlink' : typeOf(st)
74
+ return { version: versionOf(st), type, ...(st.isFile() ? { size: st.size } : {}) }
75
+ } catch (error) {
76
+ if (error && error.code === 'ENOENT') return undefined
77
+ throw fsError('FS_IO_ERROR', `cannot lstat "${full}": ${error.message}`, error)
78
+ }
79
+ }
80
+
81
+ async readText(target) {
82
+ try {
83
+ const text = await readFile(target.targetKey, 'utf8')
84
+ if (text.includes('\0')) throw fsError('FS_NOT_TEXT', `cannot read "${target.displayPath}": binary file`)
85
+ return text
86
+ } catch (error) {
87
+ if (error && error.code) throw error
88
+ throw fsError('FS_IO_ERROR', `cannot read "${target.displayPath}": ${error.message}`, error)
89
+ }
90
+ }
91
+
92
+ async streamText(target) {
93
+ const text = await this.readText(target)
94
+ return {
95
+ async *[Symbol.asyncIterator]() {
96
+ yield text
97
+ },
98
+ }
99
+ }
100
+
101
+ async readBytes(target, signal, maxBytes) {
102
+ const info = await this.stat(target)
103
+ if (info === undefined) throw fsError('FS_NOT_FOUND', `cannot read "${target.displayPath}": not found`)
104
+ if (info.type !== 'file') throw fsError('FS_NOT_REGULAR_FILE', `cannot read "${target.displayPath}": not a regular file`)
105
+ if (info.size !== undefined && info.size > maxBytes) {
106
+ throw fsError('FS_TOO_LARGE', `cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`)
107
+ }
108
+ try {
109
+ return await readFile(target.targetKey)
110
+ } catch (error) {
111
+ throw fsError('FS_IO_ERROR', `cannot read "${target.displayPath}": ${error.message}`, error)
112
+ }
113
+ }
114
+
115
+ async listDir(target) {
116
+ try {
117
+ const entries = await readdir(target.targetKey, { withFileTypes: true })
118
+ return entries
119
+ .map((entry) => ({
120
+ name: entry.name,
121
+ type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
122
+ target: {
123
+ targetKey: join(target.targetKey, entry.name),
124
+ displayPath: join(target.displayPath, entry.name),
125
+ },
126
+ }))
127
+ .sort((a, b) => a.name.localeCompare(b.name))
128
+ } catch (error) {
129
+ if (error && error.code === 'ENOENT') throw fsError('FS_NOT_FOUND', `cannot list "${target.displayPath}": not found`, error)
130
+ throw fsError('FS_IO_ERROR', `cannot list "${target.displayPath}": ${error.message}`, error)
131
+ }
132
+ }
133
+
134
+ async writeText(target, content, expected, signal, sandboxPolicy) {
135
+ const t = await this.checkedTarget(target, sandboxPolicy)
136
+ const existing = await this.stat(t)
137
+ if (existing !== undefined && existing.type !== 'file') {
138
+ throw fsError('FS_NOT_REGULAR_FILE', `cannot write "${t.displayPath}": not a regular file`)
139
+ }
140
+ if (expected && expected.kind === 'createIfAbsent' && existing !== undefined) {
141
+ throw fsError('FS_NOT_OBSERVED', `cannot overwrite existing "${t.displayPath}" without reading it first`)
142
+ }
143
+ if (expected && expected.kind === 'replaceIfVersion') {
144
+ if (existing === undefined || existing.version !== expected.version) {
145
+ throw fsError('FS_STALE_VERSION', `cannot write "${t.displayPath}": file changed since it was read`)
146
+ }
147
+ }
148
+ let before = null
149
+ if (existing !== undefined && existing.type === 'file') {
150
+ try { before = await this.readText(t) } catch { before = null }
151
+ }
152
+ await this.atomicWrite(t.targetKey, content)
153
+ const after = await this.stat(t)
154
+ return { operation: existing === undefined ? 'create' : 'update', version: after.version, before, after: content }
155
+ }
156
+
157
+ async editText(target, edit, expected, signal, sandboxPolicy) {
158
+ const t = await this.checkedTarget(target, sandboxPolicy)
159
+ const existing = await this.stat(t)
160
+ if (existing === undefined) throw fsError('FS_STALE_VERSION', `cannot edit "${t.displayPath}": file changed since it was read`)
161
+ if (existing.type !== 'file') throw fsError('FS_NOT_REGULAR_FILE', `cannot edit "${t.displayPath}": not a regular file`)
162
+ if (expected && existing.version !== expected.version) {
163
+ throw fsError('FS_STALE_VERSION', `cannot edit "${t.displayPath}": file changed since it was read`)
164
+ }
165
+ const content = await this.readText(t)
166
+ const oldString = edit.oldString
167
+ if (!oldString) throw fsError('FS_EDIT_NOT_FOUND', `cannot edit "${t.displayPath}": old_string must be non-empty`)
168
+ let matches = 0
169
+ let offset = 0
170
+ while (true) {
171
+ const found = content.indexOf(oldString, offset)
172
+ if (found < 0) break
173
+ matches += 1
174
+ offset = found + oldString.length
175
+ }
176
+ if (matches === 0) throw fsError('FS_EDIT_NOT_FOUND', `cannot edit "${t.displayPath}": old_string was not found`)
177
+ if (!edit.replaceAll && matches !== 1) {
178
+ throw fsError('FS_AMBIGUOUS_EDIT', `cannot edit "${t.displayPath}": old_string matched ${matches} times`)
179
+ }
180
+ const next = edit.replaceAll ? content.split(oldString).join(edit.newString) : content.replace(oldString, edit.newString)
181
+ await this.atomicWrite(t.targetKey, next)
182
+ const after = await this.stat(t)
183
+ return { version: after.version, before: content, after: next }
184
+ }
185
+
186
+ /**
187
+ * Enforce the per-call sandbox policy against `target` (mirrors the harness's
188
+ * `@deepseek-ai/dsh-fs-sandbox` fence). No policy service or
189
+ * `danger-full-access` → target unchanged; `read-only` → deny;
190
+ * `workspace-write` → re-canonicalize and require containment under a
191
+ * writable root.
192
+ */
193
+ async checkedTarget(target, sandboxPolicy) {
194
+ const policy = sandboxPolicy ?? this.getPolicy?.()?.resolve?.()
195
+ if (policy === undefined) return target
196
+ const { mode } = policy
197
+ if (mode === 'danger-full-access') return target
198
+ if (mode === 'read-only') {
199
+ throw fsError('FS_SANDBOX_DENIED', `cannot write "${target.displayPath}": file access denied under read-only mode`)
200
+ }
201
+ const fresh = await this.resolve(target.displayPath)
202
+ let contained = false
203
+ for (const root of writableRoots(policy)) {
204
+ if (await isPathUnder(fresh.targetKey, root)) { contained = true; break }
205
+ }
206
+ if (!contained) {
207
+ throw fsError('FS_SANDBOX_DENIED', `cannot write "${target.displayPath}": file access denied under workspace-write mode`)
208
+ }
209
+ return fresh
210
+ }
211
+
212
+ async atomicWrite(targetKey, content) {
213
+ const tmp = join(dirname(targetKey), `.dsh-${basename(targetKey)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
214
+ try {
215
+ await writeFile(tmp, content, 'utf8')
216
+ await rename(tmp, targetKey)
217
+ } catch (error) {
218
+ await rm(tmp, { force: true }).catch(() => {})
219
+ throw fsError('FS_IO_ERROR', `cannot write "${targetKey}": ${error.message}`, error)
220
+ }
221
+ }
222
+ }
223
+
224
+ export default LocalBackend
@@ -0,0 +1,238 @@
1
+ import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join, resolve } from 'node:path'
5
+
6
+ /**
7
+ * Persistent multi-machine SSH registry, stored as a local JSON file under
8
+ * the DSH home (matching the harness's own data location):
9
+ *
10
+ * <dsh-home>/remote-workspaces/machines.json
11
+ *
12
+ * Secrets (`password`, `passphrase`) are AES-256-GCM encrypted at rest with a
13
+ * key kept in a separate file (`<dsh-home>/remote-workspaces/.secret-key`), so
14
+ * a plaintext dump of machines.json never exposes them. They are decrypted only
15
+ * in memory and never sent back to the browser — `sanitizeMachine` strips them,
16
+ * exposing only a `hasPassword`/`hasPassphrase` flag. On POSIX both files are
17
+ * chmod'd 0600; on Windows the OS ACL applies (the encrypted form still stops
18
+ * casual/grep-style disclosure). Legacy plaintext secrets are read transparently
19
+ * and re-encrypted on the next write (see `ensureSecretsEncrypted`).
20
+ */
21
+
22
+ const SECRET_PREFIX = 'enc:v1:'
23
+ const KEY_NAME = '.secret-key'
24
+
25
+ function expandHomePath(value) {
26
+ if (value === '~') return homedir()
27
+ if (value.startsWith('~/') || value.startsWith('~\\')) return join(homedir(), value.slice(2))
28
+ return value
29
+ }
30
+
31
+ /** Mirror of the harness's `resolveDshHome()`: `$DSH_HOME` (non-blank) else `~/.dsh`. */
32
+ export function machinesRoot() {
33
+ const fromEnv = process.env.DSH_HOME
34
+ const selected = fromEnv !== undefined && String(fromEnv).trim().length > 0
35
+ ? fromEnv
36
+ : join(homedir(), '.dsh')
37
+ return resolve(expandHomePath(selected))
38
+ }
39
+
40
+ export function machinesPath() {
41
+ return join(machinesRoot(), 'remote-workspaces', 'machines.json')
42
+ }
43
+
44
+ function keyPath() {
45
+ return join(machinesRoot(), 'remote-workspaces', KEY_NAME)
46
+ }
47
+
48
+ /**
49
+ * Load (or create) the local 32-byte encryption key. Creating it on first use
50
+ * keeps it out of the registry file itself; a machine that loses the key file
51
+ * simply cannot decrypt old secrets (a fresh key is created and the user
52
+ * re-enters the credentials).
53
+ */
54
+ function loadKey() {
55
+ const path = keyPath()
56
+ try {
57
+ const hex = readFileSync(path, 'utf8').trim()
58
+ const buf = Buffer.from(hex, 'hex')
59
+ if (buf.length === 32) return buf
60
+ } catch {}
61
+ const key = randomBytes(32)
62
+ try {
63
+ mkdirSync(dirname(path), { recursive: true })
64
+ writeFileSync(path, key.toString('hex') + '\n', { encoding: 'utf8', mode: 0o600 })
65
+ try { chmodSync(path, 0o600) } catch {}
66
+ } catch {}
67
+ return key
68
+ }
69
+
70
+ /** `enc:v1:<iv><tag><ciphertext>` (base64) for a non-empty, not-yet-encrypted secret. */
71
+ function encryptSecret(value) {
72
+ if (value === undefined || value === null) return value
73
+ const s = String(value)
74
+ if (s === '' || s.startsWith(SECRET_PREFIX)) return s
75
+ const key = loadKey()
76
+ const iv = randomBytes(12)
77
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
78
+ const enc = Buffer.concat([cipher.update(s, 'utf8'), cipher.final()])
79
+ const tag = cipher.getAuthTag()
80
+ return SECRET_PREFIX + Buffer.concat([iv, tag, enc]).toString('base64')
81
+ }
82
+
83
+ /** Decrypt an `enc:` secret; pass legacy plaintext through; `undefined` on failure. */
84
+ function decryptSecret(value) {
85
+ if (typeof value !== 'string' || !value.startsWith(SECRET_PREFIX)) return value
86
+ try {
87
+ const key = loadKey()
88
+ const raw = Buffer.from(value.slice(SECRET_PREFIX.length), 'base64')
89
+ if (raw.length < 28) return undefined
90
+ const iv = raw.subarray(0, 12)
91
+ const tag = raw.subarray(12, 28)
92
+ const enc = raw.subarray(28)
93
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
94
+ decipher.setAuthTag(tag)
95
+ return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
96
+ } catch {
97
+ return undefined
98
+ }
99
+ }
100
+
101
+ function encryptMachineSecrets(machine) {
102
+ const out = { ...machine }
103
+ for (const field of ['password', 'passphrase']) {
104
+ if (out[field] === undefined) continue
105
+ out[field] = encryptSecret(out[field])
106
+ }
107
+ return out
108
+ }
109
+
110
+ function decryptMachineSecrets(machine) {
111
+ const out = { ...machine }
112
+ for (const field of ['password', 'passphrase']) {
113
+ if (out[field] === undefined || out[field] === null) continue
114
+ const plain = decryptSecret(out[field])
115
+ if (plain === undefined) delete out[field]
116
+ else out[field] = plain
117
+ }
118
+ return out
119
+ }
120
+
121
+ function isPlainSecret(value) {
122
+ return typeof value === 'string' && value.length > 0 && !value.startsWith(SECRET_PREFIX)
123
+ }
124
+
125
+ export function loadMachines() {
126
+ const path = machinesPath()
127
+ if (!existsSync(path)) return []
128
+ try {
129
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
130
+ const machines = parsed && typeof parsed === 'object' ? parsed.machines : undefined
131
+ return Array.isArray(machines)
132
+ ? machines.filter((m) => m && typeof m === 'object').map(decryptMachineSecrets)
133
+ : []
134
+ } catch {
135
+ return []
136
+ }
137
+ }
138
+
139
+ function persistMachines(machines) {
140
+ const path = machinesPath()
141
+ mkdirSync(dirname(path), { recursive: true })
142
+ writeFileSync(path, JSON.stringify({ machines: machines.map(encryptMachineSecrets) }, null, 2) + '\n', 'utf8')
143
+ try { chmodSync(path, 0o600) } catch {}
144
+ }
145
+
146
+ /**
147
+ * One-time migration: if the on-disk registry still holds plaintext secrets,
148
+ * re-persist it (which encrypts them). Called once at plugin startup so an
149
+ * existing install becomes secure without the user having to edit a host.
150
+ * Returns true when a rewrite happened.
151
+ */
152
+ export function ensureSecretsEncrypted() {
153
+ const path = machinesPath()
154
+ if (!existsSync(path)) return false
155
+ try {
156
+ const raw = JSON.parse(readFileSync(path, 'utf8'))
157
+ const machines = raw && typeof raw === 'object' ? raw.machines : undefined
158
+ if (!Array.isArray(machines)) return false
159
+ const hasPlain = machines.some((m) => m && typeof m === 'object' && (
160
+ isPlainSecret(m.password) || isPlainSecret(m.passphrase)
161
+ ))
162
+ if (!hasPlain) return false
163
+ persistMachines(machines)
164
+ return true
165
+ } catch {
166
+ return false
167
+ }
168
+ }
169
+
170
+ /** Projection safe to cross the Remote boundary (no password, no passphrase). */
171
+ export function sanitizeMachine(machine) {
172
+ return {
173
+ id: machine.id,
174
+ alias: machine.alias ?? '',
175
+ host: machine.host ?? '',
176
+ port: machine.port ?? null,
177
+ user: machine.user ?? null,
178
+ identityFile: machine.identityFile ?? null,
179
+ hasPassword: Boolean(machine.password),
180
+ hasPassphrase: Boolean(machine.passphrase),
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Insert or update one machine by id. A missing id creates a new machine.
186
+ * `password`/`passphrase` are one-way writes: an empty string clears the stored
187
+ * secret, and `undefined`/absent leaves the existing value untouched.
188
+ */
189
+ export function upsertMachine(input) {
190
+ const machines = loadMachines()
191
+ let machine = input.id === undefined ? undefined : machines.find((m) => m.id === input.id)
192
+ if (machine === undefined) {
193
+ machine = { id: randomUUID() }
194
+ machines.push(machine)
195
+ }
196
+ if (input.alias !== undefined) machine.alias = String(input.alias ?? '')
197
+ if (input.host !== undefined) machine.host = String(input.host ?? '')
198
+ if (input.port !== undefined) machine.port = input.port === null || input.port === '' ? null : Number(input.port) || null
199
+ if (input.user !== undefined) machine.user = input.user === null || input.user === '' ? null : String(input.user)
200
+ if (input.identityFile !== undefined) {
201
+ machine.identityFile = input.identityFile === null || input.identityFile === '' ? null : String(input.identityFile)
202
+ }
203
+ if (input.password === '') delete machine.password
204
+ else if (input.password !== undefined) machine.password = String(input.password)
205
+ if (input.passphrase === '') delete machine.passphrase
206
+ else if (input.passphrase !== undefined) machine.passphrase = String(input.passphrase)
207
+ persistMachines(machines)
208
+ return sanitizeMachine(machine)
209
+ }
210
+
211
+ export function removeMachine(id) {
212
+ const machines = loadMachines()
213
+ const next = machines.filter((m) => m.id !== id)
214
+ persistMachines(next)
215
+ return next.length !== machines.length
216
+ }
217
+
218
+ /** The raw record (with secrets) used to build an SSH connection. */
219
+ export function machineById(id) {
220
+ return loadMachines().find((m) => m.id === id)
221
+ }
222
+
223
+ /**
224
+ * Find a saved machine by its connection identity (host + user + effective
225
+ * port). Mirrors record only these fields in their `.dsh-remote-meta.json`,
226
+ * so reconnecting for sync matches them back to a stored machine (and its
227
+ * secret) this way, surviving machine id churn.
228
+ */
229
+ export function machineForRemote({ host, port, user }) {
230
+ const h = String(host ?? '')
231
+ const u = String(user ?? '')
232
+ const p = Number(port) || 22
233
+ return loadMachines().find((m) => {
234
+ return String(m.host ?? '') === h
235
+ && String(m.user ?? '') === u
236
+ && (Number(m.port) || 22) === p
237
+ })
238
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Remote-workspace registry: the durable map from a LOCAL anchor directory to
3
+ * its remote origin (machine + remote path). This is the routing key the
4
+ * `RoutingFileSystem` and `SshShellExecutor` consult per session cwd.
5
+ *
6
+ * An anchor is an EMPTY local directory adopted by the harness as the
7
+ * workspace identity (`fs.realpath` must resolve it); all file/command I/O is
8
+ * routed to the remote, never through the anchor's contents.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { join, sep } from 'node:path'
13
+ import { remoteWorkspacesRoot } from './anchor.js'
14
+
15
+ function anchorsPath() {
16
+ return join(remoteWorkspacesRoot(), 'anchors.json')
17
+ }
18
+
19
+ /** Load the anchor map (anchorPath → record). Returns {} when absent. */
20
+ export function loadAnchors() {
21
+ const file = anchorsPath()
22
+ if (!existsSync(file)) return {}
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
25
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
26
+ } catch {
27
+ return {}
28
+ }
29
+ }
30
+
31
+ function saveAnchors(anchors) {
32
+ const root = remoteWorkspacesRoot()
33
+ try { mkdirSync(root, { recursive: true }) } catch {}
34
+ writeFileSync(anchorsPath(), JSON.stringify(anchors, null, 2) + '\n', 'utf8')
35
+ }
36
+
37
+ /** Record an anchor (idempotent by anchorPath). Returns the stored record. */
38
+ export function registerAnchor({ anchorPath, machineId, host, port, user, remotePath }) {
39
+ const anchors = loadAnchors()
40
+ anchors[anchorPath] = { machineId, host, port, user, remotePath, registeredAt: new Date().toISOString() }
41
+ saveAnchors(anchors)
42
+ return anchors[anchorPath]
43
+ }
44
+
45
+ /** Remove one anchor by its local path. */
46
+ export function unregisterAnchor(anchorPath) {
47
+ const anchors = loadAnchors()
48
+ delete anchors[anchorPath]
49
+ saveAnchors(anchors)
50
+ }
51
+
52
+ /**
53
+ * Resolve a session cwd (the anchor path, or any descendant) to its remote
54
+ * origin. Returns `{ anchorPath, remotePath, remoteSubpath, host, port, user, machineId }`
55
+ * or `undefined` when the cwd is not under any registered anchor.
56
+ */
57
+ export function findByCwd(cwd) {
58
+ if (typeof cwd !== 'string' || cwd === '') return undefined
59
+ const anchors = loadAnchors()
60
+ let best
61
+ let bestLen = -1
62
+ for (const [anchorPath, rec] of Object.entries(anchors)) {
63
+ const base = anchorPath.endsWith(sep) ? anchorPath : anchorPath + sep
64
+ if (cwd === anchorPath || cwd.startsWith(base)) {
65
+ if (anchorPath.length > bestLen) {
66
+ bestLen = anchorPath.length
67
+ best = { anchorPath, ...rec }
68
+ }
69
+ }
70
+ }
71
+ if (best === undefined) return undefined
72
+ const rel = cwd === best.anchorPath ? '' : cwd.slice(best.anchorPath.length + sep.length)
73
+ return { ...best, remoteSubpath: rel === '' ? '' : rel.split(sep).join('/') }
74
+ }
75
+
76
+ export default { loadAnchors, registerAnchor, unregisterAnchor, findByCwd, remoteWorkspacesRoot }