dsh-managed-workspaces 0.0.0-stage → 1.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.
- package/LICENSE +21 -0
- package/README.md +114 -2
- package/client.js +850 -0
- package/context.js +84 -0
- package/cordis.patch.yml +10 -0
- package/icon.svg +14 -0
- package/index.js +50 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +51 -4
- package/routes.js +119 -0
- package/store.js +1085 -0
- package/tool.js +278 -0
package/store.js
ADDED
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
// dsh-managed-workspaces: state + filesystem core.
|
|
2
|
+
//
|
|
3
|
+
// A managed workspace is a directory DSH creates under a configurable base
|
|
4
|
+
// directory. It is registered in the harness workspace registry (the registry
|
|
5
|
+
// itself lives in the host plugin), and additional folders are associated with
|
|
6
|
+
// it as directory links under `<workspace>/_roots/<alias>`.
|
|
7
|
+
//
|
|
8
|
+
// This module has no framework imports: it is usable from the host plugin and
|
|
9
|
+
// from `node --test` directly.
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import { cp, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { dirname, isAbsolute, join, basename, parse, resolve, sep } from 'node:path'
|
|
16
|
+
import { promisify } from 'node:util'
|
|
17
|
+
|
|
18
|
+
const IS_WIN = process.platform === 'win32'
|
|
19
|
+
const execFileAsync = promisify(execFile)
|
|
20
|
+
const MANIFEST_VERSION = 1
|
|
21
|
+
const LINKS_DIRECTORY = '_roots'
|
|
22
|
+
const MAX_SEGMENT = 64
|
|
23
|
+
const RESERVED_SEGMENTS = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
|
|
24
|
+
const INVALID_SEGMENT_CHARS = /[<>:"/\\|?*\u0000-\u001f]/
|
|
25
|
+
|
|
26
|
+
/** An error carrying a stable code that routes and tools can report. */
|
|
27
|
+
export class ManagedWorkspaceError extends Error {
|
|
28
|
+
constructor(code, message, details) {
|
|
29
|
+
super(message)
|
|
30
|
+
this.name = 'ManagedWorkspaceError'
|
|
31
|
+
this.code = code
|
|
32
|
+
if (details !== undefined) this.details = details
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(code, message, details) {
|
|
37
|
+
throw new ManagedWorkspaceError(code, message, details)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalize one absolute-or-relative spelling into an absolute, separator-stable path. */
|
|
41
|
+
export function normalizePath(value) {
|
|
42
|
+
let out = resolve(value)
|
|
43
|
+
while (out.length > 1 && out.endsWith(sep)) out = out.slice(0, -1)
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Compare two path spellings the way the host filesystem does. */
|
|
48
|
+
export function samePath(left, right) {
|
|
49
|
+
const a = normalizePath(left)
|
|
50
|
+
const b = normalizePath(right)
|
|
51
|
+
return IS_WIN ? a.toLowerCase() === b.toLowerCase() : a === b
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whether `child` equals `parent` or lies beneath it (lexical, host case rules). */
|
|
55
|
+
export function isUnder(child, parent) {
|
|
56
|
+
const target = IS_WIN ? normalizePath(child).toLowerCase() : normalizePath(child)
|
|
57
|
+
const root = IS_WIN ? normalizePath(parent).toLowerCase() : normalizePath(parent)
|
|
58
|
+
if (target === root) return true
|
|
59
|
+
const prefix = root.endsWith(sep) ? root : root + sep
|
|
60
|
+
return target.startsWith(prefix)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Expand `~`, `~/…`, and `~\…` against the OS home directory. */
|
|
64
|
+
export function expandHomePath(value) {
|
|
65
|
+
if (value === '~') return homedir()
|
|
66
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return join(homedir(), value.slice(2))
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Validate one directory name used as a workspace folder or a link alias. */
|
|
71
|
+
export function validateSegment(value, field = 'name') {
|
|
72
|
+
if (typeof value !== 'string') fail('invalid-name', `${field} must be a string`)
|
|
73
|
+
const name = value.trim()
|
|
74
|
+
if (name.length === 0) fail('invalid-name', `${field} must not be blank`)
|
|
75
|
+
if (name.length > MAX_SEGMENT) fail('invalid-name', `${field} must be at most ${MAX_SEGMENT} characters`)
|
|
76
|
+
if (name === '.' || name === '..') fail('invalid-name', `${field} must not be "." or ".."`)
|
|
77
|
+
if (INVALID_SEGMENT_CHARS.test(name)) fail('invalid-name', `${field} contains a character that is not allowed in a folder name: ${JSON.stringify(value)}`)
|
|
78
|
+
if (/[. ]$/.test(name)) fail('invalid-name', `${field} must not end with a dot or a space`)
|
|
79
|
+
if (RESERVED_SEGMENTS.test(name)) fail('invalid-name', `${field} is a reserved Windows device name: ${JSON.stringify(name)}`)
|
|
80
|
+
return name
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Derive a safe alias from a folder's final path segment. */
|
|
84
|
+
export function defaultAlias(target) {
|
|
85
|
+
const base = normalizePath(target).split(sep).filter(Boolean).pop() ?? 'folder'
|
|
86
|
+
const cleaned = base.replace(INVALID_SEGMENT_CHARS, '-').replace(/[. ]+$/g, '').trim().slice(0, MAX_SEGMENT)
|
|
87
|
+
return cleaned.length > 0 && cleaned !== '.' && cleaned !== '..' && !RESERVED_SEGMENTS.test(cleaned) ? cleaned : 'folder'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Pick the first alias not already used in the workspace. */
|
|
91
|
+
export function uniqueAlias(used, preferred) {
|
|
92
|
+
const taken = new Set(used)
|
|
93
|
+
if (!taken.has(preferred)) return preferred
|
|
94
|
+
for (let index = 2; index < 1000; index += 1) {
|
|
95
|
+
const candidate = `${preferred}-${index}`
|
|
96
|
+
if (!taken.has(candidate)) return candidate
|
|
97
|
+
}
|
|
98
|
+
return `${preferred}-${randomUUID().slice(0, 8)}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resolve the harness home used for the plugin manifest. */
|
|
102
|
+
export function resolveDshHome() {
|
|
103
|
+
const fromEnv = process.env.DSH_HOME?.trim()
|
|
104
|
+
return fromEnv && fromEnv.length > 0 ? normalizePath(fromEnv) : join(homedir(), '.dsh')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Default base directory for new managed workspaces. */
|
|
108
|
+
export function defaultBaseDirectory() {
|
|
109
|
+
return join(homedir(), 'DSH-Workspaces')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function lstatOrNull(path) {
|
|
113
|
+
try {
|
|
114
|
+
return await lstat(path)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function statOrNull(path) {
|
|
122
|
+
try {
|
|
123
|
+
return await stat(path)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null
|
|
126
|
+
throw error
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function isDirectory(path) {
|
|
131
|
+
const info = await statOrNull(path)
|
|
132
|
+
return info !== null && info.isDirectory()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function exists(path) {
|
|
136
|
+
return (await lstatOrNull(path)) !== null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function resolveLink(linkPath) {
|
|
140
|
+
const raw = await readlink(linkPath)
|
|
141
|
+
return normalizePath(isAbsolute(raw) ? raw : resolve(dirname(linkPath), raw))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function rejectUnsupportedTarget(target) {
|
|
145
|
+
if (IS_WIN && (target.startsWith('\\\\') || target.startsWith('//'))) {
|
|
146
|
+
fail('unsupported-target', `a network (UNC) folder cannot be linked as a junction: ${target}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function createDirectoryLink(target, linkPath) {
|
|
151
|
+
if (IS_WIN) {
|
|
152
|
+
try {
|
|
153
|
+
await symlink(target, linkPath, 'junction')
|
|
154
|
+
return
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error.code !== 'EPERM' && error.code !== 'UNKNOWN' && error.code !== 'EACCES') throw error
|
|
157
|
+
await execFileAsync('cmd.exe', ['/d', '/s', '/c', 'mklink', '/J', linkPath, target], { windowsHide: true })
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await symlink(target, linkPath, 'dir')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Remove one directory link, refusing to touch a real directory or a link to another target. */
|
|
165
|
+
async function detachLinkNode(linkPath, expectedTarget) {
|
|
166
|
+
const node = await lstatOrNull(linkPath)
|
|
167
|
+
if (node === null) return 'missing'
|
|
168
|
+
if (!node.isSymbolicLink()) fail('conflict', `refusing to remove "${linkPath}": it is a real directory or file, not a link`)
|
|
169
|
+
const actual = await resolveLink(linkPath)
|
|
170
|
+
if (!samePath(actual, expectedTarget)) {
|
|
171
|
+
fail('conflict', `refusing to remove "${linkPath}": it points to ${actual}, not ${expectedTarget}`)
|
|
172
|
+
}
|
|
173
|
+
await unlink(linkPath)
|
|
174
|
+
return 'removed'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Ensure a directory link exists at `linkPath`, adopting an equivalent one and refusing conflicts. */
|
|
178
|
+
async function ensureLinkNode(linkPath, target) {
|
|
179
|
+
const node = await lstatOrNull(linkPath)
|
|
180
|
+
if (node === null) {
|
|
181
|
+
await createDirectoryLink(target, linkPath)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (!node.isSymbolicLink()) fail('conflict', `path already exists and is not a link: ${linkPath}`)
|
|
185
|
+
const actual = await resolveLink(linkPath)
|
|
186
|
+
if (!samePath(actual, target)) fail('conflict', `link points to ${actual}, not ${target}`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Count files, directories, links, and bytes below one directory for copy verification. */
|
|
190
|
+
async function treeSummary(root) {
|
|
191
|
+
let files = 0
|
|
192
|
+
let directories = 0
|
|
193
|
+
let symlinks = 0
|
|
194
|
+
let bytes = 0
|
|
195
|
+
const stack = [root]
|
|
196
|
+
while (stack.length > 0) {
|
|
197
|
+
const directory = stack.pop()
|
|
198
|
+
const entries = await readdir(directory, { withFileTypes: true })
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
const full = join(directory, entry.name)
|
|
201
|
+
if (entry.isSymbolicLink()) {
|
|
202
|
+
symlinks += 1
|
|
203
|
+
} else if (entry.isDirectory()) {
|
|
204
|
+
directories += 1
|
|
205
|
+
stack.push(full)
|
|
206
|
+
} else {
|
|
207
|
+
files += 1
|
|
208
|
+
bytes += (await stat(full)).size
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { files, directories, symlinks, bytes }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Move a directory: rename when both paths share a volume, otherwise copy,
|
|
217
|
+
* verify the copy against the source, and only then remove the source.
|
|
218
|
+
* @param {string} source - existing directory.
|
|
219
|
+
* @param {string} destination - path that must not exist yet.
|
|
220
|
+
* @returns {Promise<'rename' | 'copy'>} which mechanism moved the tree.
|
|
221
|
+
*/
|
|
222
|
+
async function moveDirectory(source, destination) {
|
|
223
|
+
try {
|
|
224
|
+
await rename(source, destination)
|
|
225
|
+
return 'rename'
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (error.code !== 'EXDEV') throw error
|
|
228
|
+
}
|
|
229
|
+
await cp(source, destination, {
|
|
230
|
+
recursive: true,
|
|
231
|
+
errorOnExist: true,
|
|
232
|
+
force: false,
|
|
233
|
+
preserveTimestamps: true,
|
|
234
|
+
})
|
|
235
|
+
const left = await treeSummary(source)
|
|
236
|
+
const right = await treeSummary(destination)
|
|
237
|
+
const matches =
|
|
238
|
+
left.files === right.files &&
|
|
239
|
+
left.directories === right.directories &&
|
|
240
|
+
left.symlinks === right.symlinks &&
|
|
241
|
+
left.bytes === right.bytes
|
|
242
|
+
if (!matches) {
|
|
243
|
+
await rm(destination, { recursive: true, force: true })
|
|
244
|
+
fail(
|
|
245
|
+
'copy-mismatch',
|
|
246
|
+
`copied tree does not match the source (${JSON.stringify(left)} vs ${JSON.stringify(right)}); the source was kept`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
await rm(source, { recursive: true, force: true })
|
|
250
|
+
return 'copy'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function freshManifest() {
|
|
254
|
+
return {
|
|
255
|
+
schemaVersion: MANIFEST_VERSION,
|
|
256
|
+
baseDirectory: defaultBaseDirectory(),
|
|
257
|
+
linksDirectoryName: LINKS_DIRECTORY,
|
|
258
|
+
workspaces: [],
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeManifest(raw, warnings) {
|
|
263
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
264
|
+
warnings.push('manifest root was not an object')
|
|
265
|
+
return freshManifest()
|
|
266
|
+
}
|
|
267
|
+
const manifest = freshManifest()
|
|
268
|
+
if (typeof raw.baseDirectory === 'string' && raw.baseDirectory.trim().length > 0) {
|
|
269
|
+
const expanded = expandHomePath(raw.baseDirectory.trim())
|
|
270
|
+
if (isAbsolute(expanded)) manifest.baseDirectory = normalizePath(expanded)
|
|
271
|
+
else warnings.push(`ignored a non-absolute baseDirectory: ${JSON.stringify(raw.baseDirectory)}`)
|
|
272
|
+
}
|
|
273
|
+
if (Array.isArray(raw.workspaces)) {
|
|
274
|
+
for (const entry of raw.workspaces) {
|
|
275
|
+
if (entry === null || typeof entry !== 'object') {
|
|
276
|
+
warnings.push('ignored a malformed workspace record')
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
if (typeof entry.id !== 'string' || typeof entry.name !== 'string' || typeof entry.path !== 'string') {
|
|
280
|
+
warnings.push('ignored a workspace record without id/name/path')
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
if (!isAbsolute(entry.path)) {
|
|
284
|
+
warnings.push(`ignored a workspace with a non-absolute path: ${JSON.stringify(entry.path)}`)
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
const links = []
|
|
288
|
+
if (Array.isArray(entry.links)) {
|
|
289
|
+
for (const link of entry.links) {
|
|
290
|
+
if (link === null || typeof link !== 'object') continue
|
|
291
|
+
if (typeof link.alias !== 'string' || typeof link.target !== 'string') continue
|
|
292
|
+
if (!isAbsolute(link.target)) continue
|
|
293
|
+
links.push({
|
|
294
|
+
alias: link.alias,
|
|
295
|
+
target: normalizePath(link.target),
|
|
296
|
+
addedAt: typeof link.addedAt === 'string' ? link.addedAt : new Date(0).toISOString(),
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
manifest.workspaces.push({
|
|
301
|
+
id: entry.id,
|
|
302
|
+
name: entry.name,
|
|
303
|
+
path: normalizePath(entry.path),
|
|
304
|
+
...(typeof entry.relocatedTo === 'string' && isAbsolute(entry.relocatedTo)
|
|
305
|
+
? { relocatedTo: normalizePath(entry.relocatedTo) }
|
|
306
|
+
: {}),
|
|
307
|
+
createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date(0).toISOString(),
|
|
308
|
+
origin: entry.origin === 'adopted' ? 'adopted' : 'created',
|
|
309
|
+
links,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return manifest
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Owns the managed-workspace manifest, the workspace folder layout, and the
|
|
318
|
+
* folder links. All mutations run on one promise chain and persist atomically.
|
|
319
|
+
*/
|
|
320
|
+
export class ManagedWorkspaceStore {
|
|
321
|
+
constructor(options = {}) {
|
|
322
|
+
this.home = normalizePath(options.home ?? resolveDshHome())
|
|
323
|
+
this.manifestPath = join(this.home, 'managed-workspaces', 'manifest.json')
|
|
324
|
+
this.now = options.now ?? (() => new Date().toISOString())
|
|
325
|
+
this.logger = options.logger ?? (() => {})
|
|
326
|
+
this.registry = null
|
|
327
|
+
this.manifest = null
|
|
328
|
+
this.warnings = []
|
|
329
|
+
this.queue = Promise.resolve()
|
|
330
|
+
this.loadPromise = null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Attach the host workspace registry (optional; the filesystem stays usable without it). */
|
|
334
|
+
setRegistry(registry) {
|
|
335
|
+
this.registry = registry ?? null
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Dispose transient state; files and manifest stay on disk. */
|
|
339
|
+
dispose() {
|
|
340
|
+
this.registry = null
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Load the manifest once. Repeated calls share the same promise. */
|
|
344
|
+
load() {
|
|
345
|
+
if (this.loadPromise === null) this.loadPromise = this.loadOnce()
|
|
346
|
+
return this.loadPromise
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async loadOnce() {
|
|
350
|
+
let raw = null
|
|
351
|
+
try {
|
|
352
|
+
raw = await readFile(this.manifestPath, 'utf8')
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error.code !== 'ENOENT') throw error
|
|
355
|
+
}
|
|
356
|
+
if (raw === null) {
|
|
357
|
+
this.manifest = freshManifest()
|
|
358
|
+
return this.manifest
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
const parsed = JSON.parse(raw)
|
|
362
|
+
const warnings = []
|
|
363
|
+
this.manifest = normalizeManifest(parsed, warnings)
|
|
364
|
+
this.warnings.push(...warnings)
|
|
365
|
+
} catch (error) {
|
|
366
|
+
const quarantine = `${this.manifestPath}.corrupt-${Date.now()}`
|
|
367
|
+
let moved = false
|
|
368
|
+
try {
|
|
369
|
+
await rename(this.manifestPath, quarantine)
|
|
370
|
+
moved = true
|
|
371
|
+
} catch (moveError) {
|
|
372
|
+
this.warnings.push(`manifest was unreadable and could not be moved aside: ${errorMessage(moveError)}`)
|
|
373
|
+
}
|
|
374
|
+
this.warnings.push(
|
|
375
|
+
moved
|
|
376
|
+
? `manifest was unreadable and was moved to ${quarantine}: ${errorMessage(error)}`
|
|
377
|
+
: `manifest was unreadable: ${errorMessage(error)}`,
|
|
378
|
+
)
|
|
379
|
+
this.manifest = freshManifest()
|
|
380
|
+
}
|
|
381
|
+
return this.manifest
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Run one mutation after the previous one settled. */
|
|
385
|
+
run(operation) {
|
|
386
|
+
const result = this.queue.then(() => operation())
|
|
387
|
+
this.queue = result.then(
|
|
388
|
+
() => undefined,
|
|
389
|
+
() => undefined,
|
|
390
|
+
)
|
|
391
|
+
return result
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async persist() {
|
|
395
|
+
const directory = dirname(this.manifestPath)
|
|
396
|
+
await mkdir(directory, { recursive: true })
|
|
397
|
+
const temporary = `${this.manifestPath}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`
|
|
398
|
+
await writeFile(temporary, `${JSON.stringify(this.manifest, null, 2)}\n`, 'utf8')
|
|
399
|
+
await rename(temporary, this.manifestPath)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
requireManifest() {
|
|
403
|
+
if (this.manifest === null) {
|
|
404
|
+
fail('not-ready', 'managed workspace state has not been loaded yet')
|
|
405
|
+
}
|
|
406
|
+
return this.manifest
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
mustFind(id) {
|
|
410
|
+
const manifest = this.requireManifest()
|
|
411
|
+
const record = manifest.workspaces.find((entry) => entry.id === id)
|
|
412
|
+
if (record === undefined) fail('not-found', `unknown managed workspace: ${JSON.stringify(id)}`)
|
|
413
|
+
return record
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
linksDirectory(record) {
|
|
417
|
+
return join(record.path, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
linksDirectoryAt(base) {
|
|
421
|
+
return join(base, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The registered workspace entity for a record (anchor or migrated location), or null. */
|
|
425
|
+
registryEntity(record) {
|
|
426
|
+
if (this.registry === null) return null
|
|
427
|
+
const match = this.registry.list().find(
|
|
428
|
+
(workspace) =>
|
|
429
|
+
samePath(workspace.path, record.path) ||
|
|
430
|
+
(typeof record.relocatedTo === 'string' && samePath(workspace.path, record.relocatedTo)),
|
|
431
|
+
)
|
|
432
|
+
return match ?? null
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The registered workspace id for a record, or null. */
|
|
436
|
+
registryWorkspaceId(record) {
|
|
437
|
+
const entity = this.registryEntity(record)
|
|
438
|
+
return entity === null ? null : String(entity.id)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** How many sessions the registry accounts to this workspace. */
|
|
442
|
+
sessionCountOf(record) {
|
|
443
|
+
const entity = this.registryEntity(record)
|
|
444
|
+
const ids = entity?.sessionIds
|
|
445
|
+
return Array.isArray(ids) ? ids.length : 0
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The physical directory holding the workspace content (anchor resolved). */
|
|
449
|
+
async realPathOf(record) {
|
|
450
|
+
return normalizePath(await realpath(record.path))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** All managed workspace records (loaded state only). */
|
|
454
|
+
listSync() {
|
|
455
|
+
return this.manifest === null ? [] : [...this.manifest.workspaces]
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Whether a path is a filesystem root, which cannot become a workspace. */
|
|
459
|
+
isFilesystemRoot(path) {
|
|
460
|
+
const root = parse(normalizePath(path)).root
|
|
461
|
+
return root.length > 0 && samePath(root, path)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The display title the workspace registry holds for a path, when registered. */
|
|
465
|
+
registryTitle(path) {
|
|
466
|
+
if (this.registry === null) return null
|
|
467
|
+
const match = this.registry.list().find((workspace) => samePath(workspace.path, path))
|
|
468
|
+
if (match === undefined) return null
|
|
469
|
+
const title = typeof match.title === 'string' ? match.title.trim() : ''
|
|
470
|
+
return title.length > 0 ? title : null
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A display name not yet used by another managed workspace. */
|
|
474
|
+
uniqueName(preferred) {
|
|
475
|
+
const taken = new Set(this.listSync().map((record) => record.name.toLowerCase()))
|
|
476
|
+
if (!taken.has(preferred.toLowerCase())) return preferred
|
|
477
|
+
for (let index = 2; index < 1000; index += 1) {
|
|
478
|
+
const candidate = `${preferred} (${index})`
|
|
479
|
+
if (!taken.has(candidate.toLowerCase())) return candidate
|
|
480
|
+
}
|
|
481
|
+
return `${preferred} (${randomUUID().slice(0, 4)})`
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Registry workspaces that are not managed by this plugin yet. */
|
|
485
|
+
candidates() {
|
|
486
|
+
if (this.registry === null || this.manifest === null) return []
|
|
487
|
+
const out = []
|
|
488
|
+
for (const workspace of this.registry.list()) {
|
|
489
|
+
if (typeof workspace.path !== 'string') continue
|
|
490
|
+
if (this.isFilesystemRoot(workspace.path)) continue
|
|
491
|
+
if (this.manifest.workspaces.some((record) => samePath(record.path, workspace.path))) continue
|
|
492
|
+
if (
|
|
493
|
+
this.manifest.workspaces.some(
|
|
494
|
+
(record) => typeof record.relocatedTo === 'string' && samePath(record.relocatedTo, workspace.path),
|
|
495
|
+
)
|
|
496
|
+
) {
|
|
497
|
+
continue
|
|
498
|
+
}
|
|
499
|
+
const title = typeof workspace.title === 'string' && workspace.title.trim().length > 0 ? workspace.title.trim() : basename(workspace.path)
|
|
500
|
+
out.push({ path: normalizePath(workspace.path), title, workspaceId: String(workspace.id) })
|
|
501
|
+
}
|
|
502
|
+
return out
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Resolve the managed workspace that owns an absolute path. */
|
|
506
|
+
findWorkspaceForPath(path) {
|
|
507
|
+
if (this.manifest === null || typeof path !== 'string') return null
|
|
508
|
+
for (const workspace of this.manifest.workspaces) {
|
|
509
|
+
if (isUnder(path, workspace.path)) return { workspace, via: 'home' }
|
|
510
|
+
if (typeof workspace.relocatedTo === 'string' && isUnder(path, workspace.relocatedTo)) {
|
|
511
|
+
return { workspace, via: 'relocated' }
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
for (const workspace of this.manifest.workspaces) {
|
|
515
|
+
for (const link of workspace.links) {
|
|
516
|
+
if (isUnder(path, link.target)) return { workspace, via: 'link', alias: link.alias, link }
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return null
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async linkView(record, link) {
|
|
523
|
+
const linkPath = join(this.linksDirectory(record), link.alias)
|
|
524
|
+
const node = await lstatOrNull(linkPath)
|
|
525
|
+
let health
|
|
526
|
+
if (node === null) {
|
|
527
|
+
health = 'link-missing'
|
|
528
|
+
} else if (!node.isSymbolicLink()) {
|
|
529
|
+
health = 'conflict'
|
|
530
|
+
} else {
|
|
531
|
+
const actual = await resolveLink(linkPath)
|
|
532
|
+
if (!samePath(actual, link.target)) health = 'conflict'
|
|
533
|
+
else health = (await isDirectory(link.target)) ? 'ok' : 'target-missing'
|
|
534
|
+
}
|
|
535
|
+
return { alias: link.alias, target: link.target, linkPath, health }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async view(record) {
|
|
539
|
+
const links = []
|
|
540
|
+
for (const link of record.links) links.push(await this.linkView(record, link))
|
|
541
|
+
const workspaceId = this.registryWorkspaceId(record)
|
|
542
|
+
return {
|
|
543
|
+
id: record.id,
|
|
544
|
+
name: record.name,
|
|
545
|
+
path: record.path,
|
|
546
|
+
relocatedTo: typeof record.relocatedTo === 'string' ? record.relocatedTo : null,
|
|
547
|
+
createdAt: record.createdAt,
|
|
548
|
+
origin: record.origin === 'adopted' ? 'adopted' : 'created',
|
|
549
|
+
directory: (await isDirectory(record.path)) ? 'ok' : 'missing',
|
|
550
|
+
registered: workspaceId !== null,
|
|
551
|
+
workspaceId,
|
|
552
|
+
sessionCount: this.sessionCountOf(record),
|
|
553
|
+
links,
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async buildState() {
|
|
558
|
+
const manifest = this.requireManifest()
|
|
559
|
+
const workspaces = []
|
|
560
|
+
for (const record of manifest.workspaces) workspaces.push(await this.view(record))
|
|
561
|
+
return {
|
|
562
|
+
baseDirectory: manifest.baseDirectory,
|
|
563
|
+
linksDirectoryName: manifest.linksDirectoryName ?? LINKS_DIRECTORY,
|
|
564
|
+
manifestPath: this.manifestPath,
|
|
565
|
+
warnings: [...this.warnings],
|
|
566
|
+
workspaces,
|
|
567
|
+
candidates: this.candidates(),
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async state() {
|
|
572
|
+
await this.load()
|
|
573
|
+
return this.run(() => this.buildState())
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async ensureBase() {
|
|
577
|
+
const manifest = this.requireManifest()
|
|
578
|
+
const base = manifest.baseDirectory
|
|
579
|
+
await mkdir(base, { recursive: true })
|
|
580
|
+
return base
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async setBaseDirectory(value) {
|
|
584
|
+
await this.load()
|
|
585
|
+
return this.run(async () => {
|
|
586
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
587
|
+
fail('invalid-base', 'baseDirectory must be a non-blank absolute path')
|
|
588
|
+
}
|
|
589
|
+
const expanded = expandHomePath(value.trim())
|
|
590
|
+
if (!isAbsolute(expanded)) fail('invalid-base', `baseDirectory must be absolute: ${JSON.stringify(value)}`)
|
|
591
|
+
const normalized = normalizePath(expanded)
|
|
592
|
+
const info = await statOrNull(normalized)
|
|
593
|
+
if (info !== null && !info.isDirectory()) fail('invalid-base', `baseDirectory is a file: ${normalized}`)
|
|
594
|
+
this.requireManifest().baseDirectory = normalized
|
|
595
|
+
await this.persist()
|
|
596
|
+
return { baseDirectory: normalized }
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async registerRecord(record) {
|
|
601
|
+
if (this.registry === null) fail('registry-unavailable', 'the harness workspace registry is not available')
|
|
602
|
+
const workspace = await this.registry.create(record.path, record.name)
|
|
603
|
+
return String(workspace.id)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async create(name) {
|
|
607
|
+
await this.load()
|
|
608
|
+
return this.run(async () => {
|
|
609
|
+
const value = validateSegment(name)
|
|
610
|
+
const base = await this.ensureBase()
|
|
611
|
+
const directory = join(base, value)
|
|
612
|
+
if (await exists(directory)) fail('exists', `workspace folder already exists: ${directory}`)
|
|
613
|
+
await mkdir(join(directory, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY), { recursive: true })
|
|
614
|
+
const canonical = normalizePath(await realpath(directory))
|
|
615
|
+
const record = {
|
|
616
|
+
id: `mws-${randomUUID()}`,
|
|
617
|
+
name: value,
|
|
618
|
+
path: canonical,
|
|
619
|
+
createdAt: this.now(),
|
|
620
|
+
origin: 'created',
|
|
621
|
+
links: [],
|
|
622
|
+
}
|
|
623
|
+
this.requireManifest().workspaces.push(record)
|
|
624
|
+
await this.persist()
|
|
625
|
+
let registered = false
|
|
626
|
+
let warning
|
|
627
|
+
try {
|
|
628
|
+
await this.registerRecord(record)
|
|
629
|
+
registered = true
|
|
630
|
+
} catch (error) {
|
|
631
|
+
warning = errorMessage(error)
|
|
632
|
+
this.logger(`workspace "${record.name}" was created but not registered: ${warning}`)
|
|
633
|
+
}
|
|
634
|
+
return { workspace: await this.view(record), registered, ...(warning === undefined ? {} : { warning }) }
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Adopt an existing folder as a managed workspace without creating or moving
|
|
640
|
+
* anything inside it. `_roots` appears only when the first link is attached.
|
|
641
|
+
*/
|
|
642
|
+
async adopt(folder) {
|
|
643
|
+
await this.load()
|
|
644
|
+
return this.run(async () => this.adoptOne(folder))
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Adopt every registered workspace that this plugin does not manage yet. */
|
|
648
|
+
async adoptAll() {
|
|
649
|
+
await this.load()
|
|
650
|
+
return this.run(async () => {
|
|
651
|
+
const adopted = []
|
|
652
|
+
const skipped = []
|
|
653
|
+
const failed = []
|
|
654
|
+
for (const candidate of this.candidates()) {
|
|
655
|
+
try {
|
|
656
|
+
const result = await this.adoptOne(candidate.path)
|
|
657
|
+
if (result.adopted) adopted.push(result.workspace.name)
|
|
658
|
+
else skipped.push(result.workspace.name)
|
|
659
|
+
} catch (error) {
|
|
660
|
+
failed.push(`${candidate.path}: ${errorMessage(error)}`)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return { adopted, skipped, failed, state: await this.buildState() }
|
|
664
|
+
})
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async adoptOne(folder) {
|
|
668
|
+
if (typeof folder !== 'string' || folder.trim().length === 0) {
|
|
669
|
+
fail('invalid-target', 'folder must be a non-blank absolute path')
|
|
670
|
+
}
|
|
671
|
+
const expanded = expandHomePath(folder.trim())
|
|
672
|
+
if (!isAbsolute(expanded)) fail('invalid-target', `folder must be absolute: ${JSON.stringify(folder)}`)
|
|
673
|
+
const info = await statOrNull(expanded)
|
|
674
|
+
if (info === null) fail('target-missing', `folder does not exist: ${expanded}`)
|
|
675
|
+
if (!info.isDirectory()) fail('invalid-target', `not a directory: ${expanded}`)
|
|
676
|
+
const target = normalizePath(await realpath(expanded))
|
|
677
|
+
if (this.isFilesystemRoot(target)) fail('invalid-target', `a filesystem root cannot become a workspace: ${target}`)
|
|
678
|
+
if (samePath(target, this.requireManifest().baseDirectory)) {
|
|
679
|
+
fail('invalid-target', 'the workspace base directory cannot itself be a workspace')
|
|
680
|
+
}
|
|
681
|
+
const existing = this.requireManifest().workspaces.find((record) => samePath(record.path, target))
|
|
682
|
+
if (existing !== undefined) {
|
|
683
|
+
return { workspace: await this.view(existing), adopted: false }
|
|
684
|
+
}
|
|
685
|
+
const relocated = this.requireManifest().workspaces.find(
|
|
686
|
+
(record) => typeof record.relocatedTo === 'string' && samePath(record.relocatedTo, target),
|
|
687
|
+
)
|
|
688
|
+
if (relocated !== undefined) {
|
|
689
|
+
fail('already-managed', `this folder is already the migrated location of workspace "${relocated.name}"`)
|
|
690
|
+
}
|
|
691
|
+
const preferred = this.registryTitle(target) ?? basename(target)
|
|
692
|
+
const record = {
|
|
693
|
+
id: `mws-${randomUUID()}`,
|
|
694
|
+
name: this.uniqueName(preferred),
|
|
695
|
+
path: target,
|
|
696
|
+
createdAt: this.now(),
|
|
697
|
+
origin: 'adopted',
|
|
698
|
+
links: [],
|
|
699
|
+
}
|
|
700
|
+
this.requireManifest().workspaces.push(record)
|
|
701
|
+
await this.persist()
|
|
702
|
+
let registered = false
|
|
703
|
+
let warning
|
|
704
|
+
try {
|
|
705
|
+
await this.registerRecord(record)
|
|
706
|
+
registered = true
|
|
707
|
+
} catch (error) {
|
|
708
|
+
warning = errorMessage(error)
|
|
709
|
+
this.logger(`workspace "${record.name}" was adopted but not registered: ${warning}`)
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
workspace: await this.view(record),
|
|
713
|
+
adopted: true,
|
|
714
|
+
registered,
|
|
715
|
+
...(warning === undefined ? {} : { warning }),
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Recreate a set of recorded links under one base directory (best effort). */
|
|
720
|
+
async restoreLinks(links, base) {
|
|
721
|
+
const errors = []
|
|
722
|
+
if (links.length === 0) return errors
|
|
723
|
+
const roots = this.linksDirectoryAt(base)
|
|
724
|
+
await mkdir(roots, { recursive: true })
|
|
725
|
+
for (const link of links) {
|
|
726
|
+
try {
|
|
727
|
+
await ensureLinkNode(join(roots, link.alias), link.target)
|
|
728
|
+
} catch (error) {
|
|
729
|
+
errors.push(`${link.alias}: ${errorMessage(error)}`)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return errors
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Remove every recorded link node below a base directory, returning the removed ones. */
|
|
736
|
+
async detachRecordLinks(record, base) {
|
|
737
|
+
const roots = this.linksDirectoryAt(base)
|
|
738
|
+
const detached = []
|
|
739
|
+
for (const link of record.links) {
|
|
740
|
+
const result = await detachLinkNode(join(roots, link.alias), link.target)
|
|
741
|
+
if (result === 'removed') detached.push(link)
|
|
742
|
+
}
|
|
743
|
+
return detached
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Point the stable anchor path at the migrated directory, replacing a stale link. */
|
|
747
|
+
async ensureAnchorLink(record, destination, previous) {
|
|
748
|
+
const anchor = record.path
|
|
749
|
+
const node = await lstatOrNull(anchor)
|
|
750
|
+
if (node === null) {
|
|
751
|
+
await mkdir(dirname(anchor), { recursive: true })
|
|
752
|
+
await createDirectoryLink(destination, anchor)
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
if (!node.isSymbolicLink()) {
|
|
756
|
+
fail('conflict', `refusing to replace "${anchor}": it is a real directory or file`)
|
|
757
|
+
}
|
|
758
|
+
const actual = await resolveLink(anchor)
|
|
759
|
+
if (samePath(actual, destination)) return
|
|
760
|
+
if (!samePath(actual, previous) && !isUnder(actual, previous)) {
|
|
761
|
+
fail('conflict', `refusing to replace "${anchor}": it points to ${actual}`)
|
|
762
|
+
}
|
|
763
|
+
await unlink(anchor)
|
|
764
|
+
await createDirectoryLink(destination, anchor)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Move a workspace's directory to a new location.
|
|
769
|
+
*
|
|
770
|
+
* A session records its original working directory immutably, and the
|
|
771
|
+
* workspace registry groups sessions by that recorded path. Moving the
|
|
772
|
+
* folder would strand every conversation, so a workspace that still has
|
|
773
|
+
* sessions keeps its original path as a directory link to the new location
|
|
774
|
+
* (`anchor` mode). A workspace without sessions is re-registered at the new
|
|
775
|
+
* path instead (`clean` mode), leaving no link behind.
|
|
776
|
+
*/
|
|
777
|
+
async migrate(id, destination, options = {}) {
|
|
778
|
+
await this.load()
|
|
779
|
+
return this.run(async () => {
|
|
780
|
+
const record = this.mustFind(id)
|
|
781
|
+
const raw = typeof destination === 'string' ? destination.trim() : ''
|
|
782
|
+
if (raw.length === 0) fail('invalid-target', 'destination must be a non-blank absolute path')
|
|
783
|
+
const expanded = expandHomePath(raw)
|
|
784
|
+
if (!isAbsolute(expanded)) fail('invalid-target', `destination must be absolute: ${JSON.stringify(destination)}`)
|
|
785
|
+
const target = normalizePath(expanded)
|
|
786
|
+
if (this.isFilesystemRoot(target)) fail('invalid-target', `a filesystem root cannot be a workspace: ${target}`)
|
|
787
|
+
if (samePath(target, this.requireManifest().baseDirectory)) {
|
|
788
|
+
fail('invalid-target', 'the workspace base directory cannot itself be a workspace')
|
|
789
|
+
}
|
|
790
|
+
if ((await lstatOrNull(target)) !== null) fail('exists', `destination already exists: ${target}`)
|
|
791
|
+
|
|
792
|
+
const source = await this.realPathOf(record)
|
|
793
|
+
if (samePath(target, source)) fail('invalid-target', 'destination is the current workspace location')
|
|
794
|
+
if (isUnder(target, source)) fail('invalid-target', 'cannot move a workspace into itself')
|
|
795
|
+
if (isUnder(source, target)) fail('invalid-target', 'cannot move a workspace onto its own parent')
|
|
796
|
+
for (const link of record.links) {
|
|
797
|
+
if (isUnder(target, link.target)) fail('invalid-target', 'cannot move a workspace into one of its linked folders')
|
|
798
|
+
}
|
|
799
|
+
if (this.registry !== null) {
|
|
800
|
+
const clash = this.registry
|
|
801
|
+
.list()
|
|
802
|
+
.find(
|
|
803
|
+
(workspace) =>
|
|
804
|
+
samePath(workspace.path, target) &&
|
|
805
|
+
!samePath(workspace.path, record.path) &&
|
|
806
|
+
!(typeof record.relocatedTo === 'string' && samePath(workspace.path, record.relocatedTo)),
|
|
807
|
+
)
|
|
808
|
+
if (clash !== undefined) fail('exists', `another registered workspace already uses ${target}`)
|
|
809
|
+
}
|
|
810
|
+
if (
|
|
811
|
+
this.listSync().some(
|
|
812
|
+
(other) =>
|
|
813
|
+
other.id !== record.id &&
|
|
814
|
+
(samePath(other.path, target) ||
|
|
815
|
+
(typeof other.relocatedTo === 'string' && samePath(other.relocatedTo, target))),
|
|
816
|
+
)
|
|
817
|
+
) {
|
|
818
|
+
fail('exists', `another managed workspace already uses ${target}`)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const sessionCount = this.sessionCountOf(record)
|
|
822
|
+
const requested = options.mode === undefined ? 'auto' : String(options.mode)
|
|
823
|
+
if (!['auto', 'anchor', 'clean'].includes(requested)) fail('invalid-mode', `unknown migration mode: ${requested}`)
|
|
824
|
+
const mode = requested === 'auto' ? (sessionCount === 0 ? 'clean' : 'anchor') : requested
|
|
825
|
+
if (mode === 'clean' && sessionCount > 0) {
|
|
826
|
+
fail(
|
|
827
|
+
'sessions-present',
|
|
828
|
+
`this workspace still has ${sessionCount} session(s); a clean migration would detach them. Use the anchor mode instead.`,
|
|
829
|
+
)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
let detached = []
|
|
833
|
+
try {
|
|
834
|
+
detached = await this.detachRecordLinks(record, source)
|
|
835
|
+
} catch (error) {
|
|
836
|
+
const errors = await this.restoreLinks(detached, source)
|
|
837
|
+
fail(
|
|
838
|
+
'conflict',
|
|
839
|
+
`cannot prepare links for the move: ${errorMessage(error)}${errors.length > 0 ? `; restoring them also failed: ${errors.join('; ')}` : ''}`,
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
let movedBy
|
|
844
|
+
try {
|
|
845
|
+
await mkdir(dirname(target), { recursive: true })
|
|
846
|
+
movedBy = await moveDirectory(source, target)
|
|
847
|
+
} catch (error) {
|
|
848
|
+
const errors = await this.restoreLinks(detached, source)
|
|
849
|
+
fail(
|
|
850
|
+
'move-failed',
|
|
851
|
+
`${errorMessage(error)}${errors.length > 0 ? `; restoring links also failed: ${errors.join('; ')}` : ''}`,
|
|
852
|
+
)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const linksDirName = this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY
|
|
856
|
+
const linkErrors = record.links.length > 0 ? await this.restoreLinks(record.links, target) : []
|
|
857
|
+
if (!(await isDirectory(join(target, linksDirName))) && record.origin !== 'adopted') {
|
|
858
|
+
await mkdir(join(target, linksDirName), { recursive: true })
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (mode === 'clean') {
|
|
862
|
+
try {
|
|
863
|
+
const entity = this.registryEntity(record)
|
|
864
|
+
if (entity !== null) await this.registry.delete(String(entity.id))
|
|
865
|
+
if (this.registry !== null) await this.registry.create(target, record.name)
|
|
866
|
+
} catch (error) {
|
|
867
|
+
record.relocatedTo = target
|
|
868
|
+
await this.persist()
|
|
869
|
+
fail(
|
|
870
|
+
'registration-failed',
|
|
871
|
+
`the directory moved to ${target}, but re-registering it failed: ${errorMessage(error)}. Run repair to re-anchor the original path.`,
|
|
872
|
+
)
|
|
873
|
+
}
|
|
874
|
+
record.path = target
|
|
875
|
+
delete record.relocatedTo
|
|
876
|
+
await this.persist()
|
|
877
|
+
return { workspace: await this.view(record), mode, source, destination: target, sessionCount, linkErrors, movedBy }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
try {
|
|
881
|
+
await this.ensureAnchorLink(record, target, source)
|
|
882
|
+
} catch (anchorError) {
|
|
883
|
+
try {
|
|
884
|
+
await moveDirectory(target, source)
|
|
885
|
+
await this.restoreLinks(record.links, source)
|
|
886
|
+
if (typeof record.relocatedTo === 'string') await this.ensureAnchorLink(record, record.relocatedTo, source)
|
|
887
|
+
} catch (rollbackError) {
|
|
888
|
+
record.relocatedTo = target
|
|
889
|
+
await this.persist()
|
|
890
|
+
fail(
|
|
891
|
+
'migration-incomplete',
|
|
892
|
+
`moved to ${target}, but the original path ${record.path} is unusable (${errorMessage(anchorError)}) and rolling back failed (${errorMessage(rollbackError)}). Create a link from ${record.path} to ${target} manually, then run repair.`,
|
|
893
|
+
)
|
|
894
|
+
}
|
|
895
|
+
fail(
|
|
896
|
+
'migration-rollback',
|
|
897
|
+
`could not keep the original path usable (${errorMessage(anchorError)}); the directory was moved back to ${source}`,
|
|
898
|
+
)
|
|
899
|
+
}
|
|
900
|
+
record.relocatedTo = target
|
|
901
|
+
await this.persist()
|
|
902
|
+
return { workspace: await this.view(record), mode, source, destination: target, sessionCount, linkErrors, movedBy }
|
|
903
|
+
})
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async attach(id, folder, alias) {
|
|
907
|
+
await this.load()
|
|
908
|
+
return this.run(async () => {
|
|
909
|
+
const record = this.mustFind(id)
|
|
910
|
+
if (typeof folder !== 'string' || folder.trim().length === 0) fail('invalid-target', 'folder must be a non-blank absolute path')
|
|
911
|
+
const expanded = expandHomePath(folder.trim())
|
|
912
|
+
if (!isAbsolute(expanded)) fail('invalid-target', `folder must be absolute: ${JSON.stringify(folder)}`)
|
|
913
|
+
const info = await statOrNull(expanded)
|
|
914
|
+
if (info === null) fail('target-missing', `folder does not exist: ${expanded}`)
|
|
915
|
+
if (!info.isDirectory()) fail('invalid-target', `not a directory: ${expanded}`)
|
|
916
|
+
const target = normalizePath(await realpath(expanded))
|
|
917
|
+
rejectUnsupportedTarget(target)
|
|
918
|
+
if (isUnder(target, record.path)) fail('invalid-link', 'cannot link a folder that lies inside the workspace')
|
|
919
|
+
if (isUnder(record.path, target)) fail('invalid-link', 'cannot link a folder that contains the workspace')
|
|
920
|
+
|
|
921
|
+
const existing = record.links.find((link) => samePath(link.target, target))
|
|
922
|
+
if (existing !== undefined) {
|
|
923
|
+
const requested = typeof alias === 'string' && alias.trim().length > 0 ? validateSegment(alias, 'alias') : existing.alias
|
|
924
|
+
if (requested !== existing.alias) {
|
|
925
|
+
fail('already-linked', `folder is already linked as "${existing.alias}"`, { alias: existing.alias })
|
|
926
|
+
}
|
|
927
|
+
return { workspace: await this.view(record), link: await this.linkView(record, existing), created: false }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const preferred = typeof alias === 'string' && alias.trim().length > 0 ? validateSegment(alias, 'alias') : defaultAlias(target)
|
|
931
|
+
const name = uniqueAlias(record.links.map((link) => link.alias), preferred)
|
|
932
|
+
const roots = this.linksDirectory(record)
|
|
933
|
+
await mkdir(roots, { recursive: true })
|
|
934
|
+
const linkPath = join(roots, name)
|
|
935
|
+
const node = await lstatOrNull(linkPath)
|
|
936
|
+
if (node !== null) {
|
|
937
|
+
if (node.isSymbolicLink() && samePath(await resolveLink(linkPath), target)) {
|
|
938
|
+
const link = { alias: name, target, addedAt: this.now() }
|
|
939
|
+
record.links.push(link)
|
|
940
|
+
await this.persist()
|
|
941
|
+
return { workspace: await this.view(record), link: await this.linkView(record, link), created: false }
|
|
942
|
+
}
|
|
943
|
+
fail('conflict', `path already exists and is not a link to this folder: ${linkPath}`)
|
|
944
|
+
}
|
|
945
|
+
await createDirectoryLink(target, linkPath)
|
|
946
|
+
const link = { alias: name, target, addedAt: this.now() }
|
|
947
|
+
record.links.push(link)
|
|
948
|
+
await this.persist()
|
|
949
|
+
return { workspace: await this.view(record), link: await this.linkView(record, link), created: true }
|
|
950
|
+
})
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async detach(id, alias) {
|
|
954
|
+
await this.load()
|
|
955
|
+
return this.run(async () => {
|
|
956
|
+
const record = this.mustFind(id)
|
|
957
|
+
const name = validateSegment(alias, 'alias')
|
|
958
|
+
const index = record.links.findIndex((link) => link.alias === name)
|
|
959
|
+
if (index < 0) fail('not-found', `workspace "${record.name}" has no link named "${name}"`)
|
|
960
|
+
const link = record.links[index]
|
|
961
|
+
const linkPath = join(this.linksDirectory(record), name)
|
|
962
|
+
const node = await lstatOrNull(linkPath)
|
|
963
|
+
if (node !== null) {
|
|
964
|
+
if (!node.isSymbolicLink()) {
|
|
965
|
+
fail('conflict', `refusing to remove "${linkPath}": it is a real directory or file, not a link`)
|
|
966
|
+
}
|
|
967
|
+
const actual = await resolveLink(linkPath)
|
|
968
|
+
if (!samePath(actual, link.target)) {
|
|
969
|
+
fail('conflict', `refusing to remove "${linkPath}": it points to ${actual}, not ${link.target}`)
|
|
970
|
+
}
|
|
971
|
+
await unlink(linkPath)
|
|
972
|
+
}
|
|
973
|
+
record.links.splice(index, 1)
|
|
974
|
+
await this.persist()
|
|
975
|
+
return { workspace: await this.view(record), removed: name }
|
|
976
|
+
})
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async register(id) {
|
|
980
|
+
await this.load()
|
|
981
|
+
return this.run(async () => {
|
|
982
|
+
const record = this.mustFind(id)
|
|
983
|
+
if (!(await isDirectory(record.path))) {
|
|
984
|
+
fail('missing', `workspace folder is missing; run repair first: ${record.path}`)
|
|
985
|
+
}
|
|
986
|
+
await this.registerRecord(record)
|
|
987
|
+
return { workspace: await this.view(record), registered: true }
|
|
988
|
+
})
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
async forget(id) {
|
|
992
|
+
await this.load()
|
|
993
|
+
return this.run(async () => {
|
|
994
|
+
const record = this.mustFind(id)
|
|
995
|
+
// An adopted workspace existed before the plugin: stop managing it but
|
|
996
|
+
// leave its sidebar registration alone, because removing it would hide a
|
|
997
|
+
// project the user created outside this plugin.
|
|
998
|
+
const keepRegistration = record.origin === 'adopted'
|
|
999
|
+
let unregistered = false
|
|
1000
|
+
if (!keepRegistration) {
|
|
1001
|
+
const workspaceId = this.registryWorkspaceId(record)
|
|
1002
|
+
if (workspaceId !== null) {
|
|
1003
|
+
await this.registry.delete(workspaceId)
|
|
1004
|
+
unregistered = true
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const index = this.requireManifest().workspaces.findIndex((entry) => entry.id === record.id)
|
|
1008
|
+
if (index >= 0) this.requireManifest().workspaces.splice(index, 1)
|
|
1009
|
+
await this.persist()
|
|
1010
|
+
return { removed: record.name, unregistered, keptRegistration: keepRegistration, path: record.path }
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async repair(id) {
|
|
1015
|
+
await this.load()
|
|
1016
|
+
return this.run(async () => {
|
|
1017
|
+
const record = this.mustFind(id)
|
|
1018
|
+
const repaired = []
|
|
1019
|
+
const conflicts = []
|
|
1020
|
+
const relocated = typeof record.relocatedTo === 'string' ? record.relocatedTo : null
|
|
1021
|
+
|
|
1022
|
+
if (relocated !== null) {
|
|
1023
|
+
if (!(await isDirectory(relocated))) {
|
|
1024
|
+
fail('missing', `the migrated workspace folder is missing and will not be recreated: ${relocated}`)
|
|
1025
|
+
}
|
|
1026
|
+
// The original path is the anchor every recorded session still uses.
|
|
1027
|
+
try {
|
|
1028
|
+
await this.ensureAnchorLink(record, relocated, relocated)
|
|
1029
|
+
if (!(await isDirectory(record.path))) {
|
|
1030
|
+
conflicts.push(`anchor ${record.path} does not resolve to ${relocated}`)
|
|
1031
|
+
} else if (await samePath(await this.realPathOf(record), relocated)) {
|
|
1032
|
+
if (repaired.length === 0) repaired.push('anchor')
|
|
1033
|
+
}
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
conflicts.push(`anchor: ${errorMessage(error)}`)
|
|
1036
|
+
}
|
|
1037
|
+
} else if (!(await isDirectory(record.path))) {
|
|
1038
|
+
if (record.origin === 'adopted') {
|
|
1039
|
+
fail('missing', `the adopted workspace folder is missing and will not be recreated: ${record.path}`)
|
|
1040
|
+
}
|
|
1041
|
+
await mkdir(record.path, { recursive: true })
|
|
1042
|
+
repaired.push('workspace folder')
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const base = relocated ?? record.path
|
|
1046
|
+
const roots = this.linksDirectoryAt(base)
|
|
1047
|
+
// A created workspace always owns its _roots; an adopted one gets it only
|
|
1048
|
+
// when a linked folder actually needs it, so adoption leaves no trace.
|
|
1049
|
+
if (!(await isDirectory(roots)) && (record.links.length > 0 || record.origin !== 'adopted')) {
|
|
1050
|
+
await mkdir(roots, { recursive: true })
|
|
1051
|
+
repaired.push(roots)
|
|
1052
|
+
}
|
|
1053
|
+
for (const link of record.links) {
|
|
1054
|
+
const linkPath = join(roots, link.alias)
|
|
1055
|
+
const node = await lstatOrNull(linkPath)
|
|
1056
|
+
if (node === null) {
|
|
1057
|
+
try {
|
|
1058
|
+
await createDirectoryLink(link.target, linkPath)
|
|
1059
|
+
repaired.push(link.alias)
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
conflicts.push(`${link.alias}: could not create the link (${errorMessage(error)})`)
|
|
1062
|
+
}
|
|
1063
|
+
continue
|
|
1064
|
+
}
|
|
1065
|
+
if (!node.isSymbolicLink()) {
|
|
1066
|
+
conflicts.push(`${link.alias}: path is occupied by a real directory or file`)
|
|
1067
|
+
continue
|
|
1068
|
+
}
|
|
1069
|
+
const actual = await resolveLink(linkPath)
|
|
1070
|
+
if (!samePath(actual, link.target)) {
|
|
1071
|
+
conflicts.push(`${link.alias}: link points elsewhere (${actual})`)
|
|
1072
|
+
continue
|
|
1073
|
+
}
|
|
1074
|
+
if (!(await isDirectory(link.target))) conflicts.push(`${link.alias}: target folder is missing (${link.target})`)
|
|
1075
|
+
}
|
|
1076
|
+
return { repaired, conflicts, workspace: await this.view(record) }
|
|
1077
|
+
})
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** Render an error as one message string. */
|
|
1082
|
+
export function errorMessage(error) {
|
|
1083
|
+
if (error instanceof Error) return error.message
|
|
1084
|
+
return String(error)
|
|
1085
|
+
}
|