dsh-managed-workspaces 0.0.0-stage → 1.0.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/store.js ADDED
@@ -0,0 +1,743 @@
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 { lstat, mkdir, readFile, readlink, realpath, rename, 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
+ function freshManifest() {
165
+ return {
166
+ schemaVersion: MANIFEST_VERSION,
167
+ baseDirectory: defaultBaseDirectory(),
168
+ linksDirectoryName: LINKS_DIRECTORY,
169
+ workspaces: [],
170
+ }
171
+ }
172
+
173
+ function normalizeManifest(raw, warnings) {
174
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
175
+ warnings.push('manifest root was not an object')
176
+ return freshManifest()
177
+ }
178
+ const manifest = freshManifest()
179
+ if (typeof raw.baseDirectory === 'string' && raw.baseDirectory.trim().length > 0) {
180
+ const expanded = expandHomePath(raw.baseDirectory.trim())
181
+ if (isAbsolute(expanded)) manifest.baseDirectory = normalizePath(expanded)
182
+ else warnings.push(`ignored a non-absolute baseDirectory: ${JSON.stringify(raw.baseDirectory)}`)
183
+ }
184
+ if (Array.isArray(raw.workspaces)) {
185
+ for (const entry of raw.workspaces) {
186
+ if (entry === null || typeof entry !== 'object') {
187
+ warnings.push('ignored a malformed workspace record')
188
+ continue
189
+ }
190
+ if (typeof entry.id !== 'string' || typeof entry.name !== 'string' || typeof entry.path !== 'string') {
191
+ warnings.push('ignored a workspace record without id/name/path')
192
+ continue
193
+ }
194
+ if (!isAbsolute(entry.path)) {
195
+ warnings.push(`ignored a workspace with a non-absolute path: ${JSON.stringify(entry.path)}`)
196
+ continue
197
+ }
198
+ const links = []
199
+ if (Array.isArray(entry.links)) {
200
+ for (const link of entry.links) {
201
+ if (link === null || typeof link !== 'object') continue
202
+ if (typeof link.alias !== 'string' || typeof link.target !== 'string') continue
203
+ if (!isAbsolute(link.target)) continue
204
+ links.push({
205
+ alias: link.alias,
206
+ target: normalizePath(link.target),
207
+ addedAt: typeof link.addedAt === 'string' ? link.addedAt : new Date(0).toISOString(),
208
+ })
209
+ }
210
+ }
211
+ manifest.workspaces.push({
212
+ id: entry.id,
213
+ name: entry.name,
214
+ path: normalizePath(entry.path),
215
+ createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date(0).toISOString(),
216
+ origin: entry.origin === 'adopted' ? 'adopted' : 'created',
217
+ links,
218
+ })
219
+ }
220
+ }
221
+ return manifest
222
+ }
223
+
224
+ /**
225
+ * Owns the managed-workspace manifest, the workspace folder layout, and the
226
+ * folder links. All mutations run on one promise chain and persist atomically.
227
+ */
228
+ export class ManagedWorkspaceStore {
229
+ constructor(options = {}) {
230
+ this.home = normalizePath(options.home ?? resolveDshHome())
231
+ this.manifestPath = join(this.home, 'managed-workspaces', 'manifest.json')
232
+ this.now = options.now ?? (() => new Date().toISOString())
233
+ this.logger = options.logger ?? (() => {})
234
+ this.registry = null
235
+ this.manifest = null
236
+ this.warnings = []
237
+ this.queue = Promise.resolve()
238
+ this.loadPromise = null
239
+ }
240
+
241
+ /** Attach the host workspace registry (optional; the filesystem stays usable without it). */
242
+ setRegistry(registry) {
243
+ this.registry = registry ?? null
244
+ }
245
+
246
+ /** Dispose transient state; files and manifest stay on disk. */
247
+ dispose() {
248
+ this.registry = null
249
+ }
250
+
251
+ /** Load the manifest once. Repeated calls share the same promise. */
252
+ load() {
253
+ if (this.loadPromise === null) this.loadPromise = this.loadOnce()
254
+ return this.loadPromise
255
+ }
256
+
257
+ async loadOnce() {
258
+ let raw = null
259
+ try {
260
+ raw = await readFile(this.manifestPath, 'utf8')
261
+ } catch (error) {
262
+ if (error.code !== 'ENOENT') throw error
263
+ }
264
+ if (raw === null) {
265
+ this.manifest = freshManifest()
266
+ return this.manifest
267
+ }
268
+ try {
269
+ const parsed = JSON.parse(raw)
270
+ const warnings = []
271
+ this.manifest = normalizeManifest(parsed, warnings)
272
+ this.warnings.push(...warnings)
273
+ } catch (error) {
274
+ const quarantine = `${this.manifestPath}.corrupt-${Date.now()}`
275
+ let moved = false
276
+ try {
277
+ await rename(this.manifestPath, quarantine)
278
+ moved = true
279
+ } catch (moveError) {
280
+ this.warnings.push(`manifest was unreadable and could not be moved aside: ${errorMessage(moveError)}`)
281
+ }
282
+ this.warnings.push(
283
+ moved
284
+ ? `manifest was unreadable and was moved to ${quarantine}: ${errorMessage(error)}`
285
+ : `manifest was unreadable: ${errorMessage(error)}`,
286
+ )
287
+ this.manifest = freshManifest()
288
+ }
289
+ return this.manifest
290
+ }
291
+
292
+ /** Run one mutation after the previous one settled. */
293
+ run(operation) {
294
+ const result = this.queue.then(() => operation())
295
+ this.queue = result.then(
296
+ () => undefined,
297
+ () => undefined,
298
+ )
299
+ return result
300
+ }
301
+
302
+ async persist() {
303
+ const directory = dirname(this.manifestPath)
304
+ await mkdir(directory, { recursive: true })
305
+ const temporary = `${this.manifestPath}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`
306
+ await writeFile(temporary, `${JSON.stringify(this.manifest, null, 2)}\n`, 'utf8')
307
+ await rename(temporary, this.manifestPath)
308
+ }
309
+
310
+ requireManifest() {
311
+ if (this.manifest === null) {
312
+ fail('not-ready', 'managed workspace state has not been loaded yet')
313
+ }
314
+ return this.manifest
315
+ }
316
+
317
+ mustFind(id) {
318
+ const manifest = this.requireManifest()
319
+ const record = manifest.workspaces.find((entry) => entry.id === id)
320
+ if (record === undefined) fail('not-found', `unknown managed workspace: ${JSON.stringify(id)}`)
321
+ return record
322
+ }
323
+
324
+ linksDirectory(record) {
325
+ return join(record.path, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY)
326
+ }
327
+
328
+ /** The registered workspace id for a record, or null. */
329
+ registryWorkspaceId(record) {
330
+ if (this.registry === null) return null
331
+ const match = this.registry.list().find((workspace) => samePath(workspace.path, record.path))
332
+ return match === undefined ? null : String(match.id)
333
+ }
334
+
335
+ /** All managed workspace records (loaded state only). */
336
+ listSync() {
337
+ return this.manifest === null ? [] : [...this.manifest.workspaces]
338
+ }
339
+
340
+ /** Whether a path is a filesystem root, which cannot become a workspace. */
341
+ isFilesystemRoot(path) {
342
+ const root = parse(normalizePath(path)).root
343
+ return root.length > 0 && samePath(root, path)
344
+ }
345
+
346
+ /** The display title the workspace registry holds for a path, when registered. */
347
+ registryTitle(path) {
348
+ if (this.registry === null) return null
349
+ const match = this.registry.list().find((workspace) => samePath(workspace.path, path))
350
+ if (match === undefined) return null
351
+ const title = typeof match.title === 'string' ? match.title.trim() : ''
352
+ return title.length > 0 ? title : null
353
+ }
354
+
355
+ /** A display name not yet used by another managed workspace. */
356
+ uniqueName(preferred) {
357
+ const taken = new Set(this.listSync().map((record) => record.name.toLowerCase()))
358
+ if (!taken.has(preferred.toLowerCase())) return preferred
359
+ for (let index = 2; index < 1000; index += 1) {
360
+ const candidate = `${preferred} (${index})`
361
+ if (!taken.has(candidate.toLowerCase())) return candidate
362
+ }
363
+ return `${preferred} (${randomUUID().slice(0, 4)})`
364
+ }
365
+
366
+ /** Registry workspaces that are not managed by this plugin yet. */
367
+ candidates() {
368
+ if (this.registry === null || this.manifest === null) return []
369
+ const out = []
370
+ for (const workspace of this.registry.list()) {
371
+ if (typeof workspace.path !== 'string') continue
372
+ if (this.isFilesystemRoot(workspace.path)) continue
373
+ if (this.manifest.workspaces.some((record) => samePath(record.path, workspace.path))) continue
374
+ const title = typeof workspace.title === 'string' && workspace.title.trim().length > 0 ? workspace.title.trim() : basename(workspace.path)
375
+ out.push({ path: normalizePath(workspace.path), title, workspaceId: String(workspace.id) })
376
+ }
377
+ return out
378
+ }
379
+
380
+ /** Resolve the managed workspace that owns an absolute path. */
381
+ findWorkspaceForPath(path) {
382
+ if (this.manifest === null || typeof path !== 'string') return null
383
+ for (const workspace of this.manifest.workspaces) {
384
+ if (isUnder(path, workspace.path)) return { workspace, via: 'home' }
385
+ }
386
+ for (const workspace of this.manifest.workspaces) {
387
+ for (const link of workspace.links) {
388
+ if (isUnder(path, link.target)) return { workspace, via: 'link', alias: link.alias, link }
389
+ }
390
+ }
391
+ return null
392
+ }
393
+
394
+ async linkView(record, link) {
395
+ const linkPath = join(this.linksDirectory(record), link.alias)
396
+ const node = await lstatOrNull(linkPath)
397
+ let health
398
+ if (node === null) {
399
+ health = 'link-missing'
400
+ } else if (!node.isSymbolicLink()) {
401
+ health = 'conflict'
402
+ } else {
403
+ const actual = await resolveLink(linkPath)
404
+ if (!samePath(actual, link.target)) health = 'conflict'
405
+ else health = (await isDirectory(link.target)) ? 'ok' : 'target-missing'
406
+ }
407
+ return { alias: link.alias, target: link.target, linkPath, health }
408
+ }
409
+
410
+ async view(record) {
411
+ const links = []
412
+ for (const link of record.links) links.push(await this.linkView(record, link))
413
+ const workspaceId = this.registryWorkspaceId(record)
414
+ return {
415
+ id: record.id,
416
+ name: record.name,
417
+ path: record.path,
418
+ createdAt: record.createdAt,
419
+ origin: record.origin === 'adopted' ? 'adopted' : 'created',
420
+ directory: (await isDirectory(record.path)) ? 'ok' : 'missing',
421
+ registered: workspaceId !== null,
422
+ workspaceId,
423
+ links,
424
+ }
425
+ }
426
+
427
+ async buildState() {
428
+ const manifest = this.requireManifest()
429
+ const workspaces = []
430
+ for (const record of manifest.workspaces) workspaces.push(await this.view(record))
431
+ return {
432
+ baseDirectory: manifest.baseDirectory,
433
+ linksDirectoryName: manifest.linksDirectoryName ?? LINKS_DIRECTORY,
434
+ manifestPath: this.manifestPath,
435
+ warnings: [...this.warnings],
436
+ workspaces,
437
+ candidates: this.candidates(),
438
+ }
439
+ }
440
+
441
+ async state() {
442
+ await this.load()
443
+ return this.run(() => this.buildState())
444
+ }
445
+
446
+ async ensureBase() {
447
+ const manifest = this.requireManifest()
448
+ const base = manifest.baseDirectory
449
+ await mkdir(base, { recursive: true })
450
+ return base
451
+ }
452
+
453
+ async setBaseDirectory(value) {
454
+ await this.load()
455
+ return this.run(async () => {
456
+ if (typeof value !== 'string' || value.trim().length === 0) {
457
+ fail('invalid-base', 'baseDirectory must be a non-blank absolute path')
458
+ }
459
+ const expanded = expandHomePath(value.trim())
460
+ if (!isAbsolute(expanded)) fail('invalid-base', `baseDirectory must be absolute: ${JSON.stringify(value)}`)
461
+ const normalized = normalizePath(expanded)
462
+ const info = await statOrNull(normalized)
463
+ if (info !== null && !info.isDirectory()) fail('invalid-base', `baseDirectory is a file: ${normalized}`)
464
+ this.requireManifest().baseDirectory = normalized
465
+ await this.persist()
466
+ return { baseDirectory: normalized }
467
+ })
468
+ }
469
+
470
+ async registerRecord(record) {
471
+ if (this.registry === null) fail('registry-unavailable', 'the harness workspace registry is not available')
472
+ const workspace = await this.registry.create(record.path, record.name)
473
+ return String(workspace.id)
474
+ }
475
+
476
+ async create(name) {
477
+ await this.load()
478
+ return this.run(async () => {
479
+ const value = validateSegment(name)
480
+ const base = await this.ensureBase()
481
+ const directory = join(base, value)
482
+ if (await exists(directory)) fail('exists', `workspace folder already exists: ${directory}`)
483
+ await mkdir(join(directory, this.requireManifest().linksDirectoryName ?? LINKS_DIRECTORY), { recursive: true })
484
+ const canonical = normalizePath(await realpath(directory))
485
+ const record = {
486
+ id: `mws-${randomUUID()}`,
487
+ name: value,
488
+ path: canonical,
489
+ createdAt: this.now(),
490
+ origin: 'created',
491
+ links: [],
492
+ }
493
+ this.requireManifest().workspaces.push(record)
494
+ await this.persist()
495
+ let registered = false
496
+ let warning
497
+ try {
498
+ await this.registerRecord(record)
499
+ registered = true
500
+ } catch (error) {
501
+ warning = errorMessage(error)
502
+ this.logger(`workspace "${record.name}" was created but not registered: ${warning}`)
503
+ }
504
+ return { workspace: await this.view(record), registered, ...(warning === undefined ? {} : { warning }) }
505
+ })
506
+ }
507
+
508
+ /**
509
+ * Adopt an existing folder as a managed workspace without creating or moving
510
+ * anything inside it. `_roots` appears only when the first link is attached.
511
+ */
512
+ async adopt(folder) {
513
+ await this.load()
514
+ return this.run(async () => this.adoptOne(folder))
515
+ }
516
+
517
+ /** Adopt every registered workspace that this plugin does not manage yet. */
518
+ async adoptAll() {
519
+ await this.load()
520
+ return this.run(async () => {
521
+ const adopted = []
522
+ const skipped = []
523
+ const failed = []
524
+ for (const candidate of this.candidates()) {
525
+ try {
526
+ const result = await this.adoptOne(candidate.path)
527
+ if (result.adopted) adopted.push(result.workspace.name)
528
+ else skipped.push(result.workspace.name)
529
+ } catch (error) {
530
+ failed.push(`${candidate.path}: ${errorMessage(error)}`)
531
+ }
532
+ }
533
+ return { adopted, skipped, failed, state: await this.buildState() }
534
+ })
535
+ }
536
+
537
+ async adoptOne(folder) {
538
+ if (typeof folder !== 'string' || folder.trim().length === 0) {
539
+ fail('invalid-target', 'folder must be a non-blank absolute path')
540
+ }
541
+ const expanded = expandHomePath(folder.trim())
542
+ if (!isAbsolute(expanded)) fail('invalid-target', `folder must be absolute: ${JSON.stringify(folder)}`)
543
+ const info = await statOrNull(expanded)
544
+ if (info === null) fail('target-missing', `folder does not exist: ${expanded}`)
545
+ if (!info.isDirectory()) fail('invalid-target', `not a directory: ${expanded}`)
546
+ const target = normalizePath(await realpath(expanded))
547
+ if (this.isFilesystemRoot(target)) fail('invalid-target', `a filesystem root cannot become a workspace: ${target}`)
548
+ if (samePath(target, this.requireManifest().baseDirectory)) {
549
+ fail('invalid-target', 'the workspace base directory cannot itself be a workspace')
550
+ }
551
+ const existing = this.requireManifest().workspaces.find((record) => samePath(record.path, target))
552
+ if (existing !== undefined) {
553
+ return { workspace: await this.view(existing), adopted: false }
554
+ }
555
+ const preferred = this.registryTitle(target) ?? basename(target)
556
+ const record = {
557
+ id: `mws-${randomUUID()}`,
558
+ name: this.uniqueName(preferred),
559
+ path: target,
560
+ createdAt: this.now(),
561
+ origin: 'adopted',
562
+ links: [],
563
+ }
564
+ this.requireManifest().workspaces.push(record)
565
+ await this.persist()
566
+ let registered = false
567
+ let warning
568
+ try {
569
+ await this.registerRecord(record)
570
+ registered = true
571
+ } catch (error) {
572
+ warning = errorMessage(error)
573
+ this.logger(`workspace "${record.name}" was adopted but not registered: ${warning}`)
574
+ }
575
+ return {
576
+ workspace: await this.view(record),
577
+ adopted: true,
578
+ registered,
579
+ ...(warning === undefined ? {} : { warning }),
580
+ }
581
+ }
582
+
583
+ async attach(id, folder, alias) {
584
+ await this.load()
585
+ return this.run(async () => {
586
+ const record = this.mustFind(id)
587
+ if (typeof folder !== 'string' || folder.trim().length === 0) fail('invalid-target', 'folder must be a non-blank absolute path')
588
+ const expanded = expandHomePath(folder.trim())
589
+ if (!isAbsolute(expanded)) fail('invalid-target', `folder must be absolute: ${JSON.stringify(folder)}`)
590
+ const info = await statOrNull(expanded)
591
+ if (info === null) fail('target-missing', `folder does not exist: ${expanded}`)
592
+ if (!info.isDirectory()) fail('invalid-target', `not a directory: ${expanded}`)
593
+ const target = normalizePath(await realpath(expanded))
594
+ rejectUnsupportedTarget(target)
595
+ if (isUnder(target, record.path)) fail('invalid-link', 'cannot link a folder that lies inside the workspace')
596
+ if (isUnder(record.path, target)) fail('invalid-link', 'cannot link a folder that contains the workspace')
597
+
598
+ const existing = record.links.find((link) => samePath(link.target, target))
599
+ if (existing !== undefined) {
600
+ const requested = typeof alias === 'string' && alias.trim().length > 0 ? validateSegment(alias, 'alias') : existing.alias
601
+ if (requested !== existing.alias) {
602
+ fail('already-linked', `folder is already linked as "${existing.alias}"`, { alias: existing.alias })
603
+ }
604
+ return { workspace: await this.view(record), link: await this.linkView(record, existing), created: false }
605
+ }
606
+
607
+ const preferred = typeof alias === 'string' && alias.trim().length > 0 ? validateSegment(alias, 'alias') : defaultAlias(target)
608
+ const name = uniqueAlias(record.links.map((link) => link.alias), preferred)
609
+ const roots = this.linksDirectory(record)
610
+ await mkdir(roots, { recursive: true })
611
+ const linkPath = join(roots, name)
612
+ const node = await lstatOrNull(linkPath)
613
+ if (node !== null) {
614
+ if (node.isSymbolicLink() && samePath(await resolveLink(linkPath), target)) {
615
+ const link = { alias: name, target, addedAt: this.now() }
616
+ record.links.push(link)
617
+ await this.persist()
618
+ return { workspace: await this.view(record), link: await this.linkView(record, link), created: false }
619
+ }
620
+ fail('conflict', `path already exists and is not a link to this folder: ${linkPath}`)
621
+ }
622
+ await createDirectoryLink(target, linkPath)
623
+ const link = { alias: name, target, addedAt: this.now() }
624
+ record.links.push(link)
625
+ await this.persist()
626
+ return { workspace: await this.view(record), link: await this.linkView(record, link), created: true }
627
+ })
628
+ }
629
+
630
+ async detach(id, alias) {
631
+ await this.load()
632
+ return this.run(async () => {
633
+ const record = this.mustFind(id)
634
+ const name = validateSegment(alias, 'alias')
635
+ const index = record.links.findIndex((link) => link.alias === name)
636
+ if (index < 0) fail('not-found', `workspace "${record.name}" has no link named "${name}"`)
637
+ const link = record.links[index]
638
+ const linkPath = join(this.linksDirectory(record), name)
639
+ const node = await lstatOrNull(linkPath)
640
+ if (node !== null) {
641
+ if (!node.isSymbolicLink()) {
642
+ fail('conflict', `refusing to remove "${linkPath}": it is a real directory or file, not a link`)
643
+ }
644
+ const actual = await resolveLink(linkPath)
645
+ if (!samePath(actual, link.target)) {
646
+ fail('conflict', `refusing to remove "${linkPath}": it points to ${actual}, not ${link.target}`)
647
+ }
648
+ await unlink(linkPath)
649
+ }
650
+ record.links.splice(index, 1)
651
+ await this.persist()
652
+ return { workspace: await this.view(record), removed: name }
653
+ })
654
+ }
655
+
656
+ async register(id) {
657
+ await this.load()
658
+ return this.run(async () => {
659
+ const record = this.mustFind(id)
660
+ if (!(await isDirectory(record.path))) {
661
+ fail('missing', `workspace folder is missing; run repair first: ${record.path}`)
662
+ }
663
+ await this.registerRecord(record)
664
+ return { workspace: await this.view(record), registered: true }
665
+ })
666
+ }
667
+
668
+ async forget(id) {
669
+ await this.load()
670
+ return this.run(async () => {
671
+ const record = this.mustFind(id)
672
+ // An adopted workspace existed before the plugin: stop managing it but
673
+ // leave its sidebar registration alone, because removing it would hide a
674
+ // project the user created outside this plugin.
675
+ const keepRegistration = record.origin === 'adopted'
676
+ let unregistered = false
677
+ if (!keepRegistration) {
678
+ const workspaceId = this.registryWorkspaceId(record)
679
+ if (workspaceId !== null) {
680
+ await this.registry.delete(workspaceId)
681
+ unregistered = true
682
+ }
683
+ }
684
+ const index = this.requireManifest().workspaces.findIndex((entry) => entry.id === record.id)
685
+ if (index >= 0) this.requireManifest().workspaces.splice(index, 1)
686
+ await this.persist()
687
+ return { removed: record.name, unregistered, keptRegistration: keepRegistration, path: record.path }
688
+ })
689
+ }
690
+
691
+ async repair(id) {
692
+ await this.load()
693
+ return this.run(async () => {
694
+ const record = this.mustFind(id)
695
+ const repaired = []
696
+ const conflicts = []
697
+ if (!(await isDirectory(record.path))) {
698
+ if (record.origin === 'adopted') {
699
+ fail('missing', `the adopted workspace folder is missing and will not be recreated: ${record.path}`)
700
+ }
701
+ await mkdir(record.path, { recursive: true })
702
+ repaired.push('workspace folder')
703
+ }
704
+ const roots = this.linksDirectory(record)
705
+ // A created workspace always owns its _roots; an adopted one gets it only
706
+ // when a linked folder actually needs it, so adoption leaves no trace.
707
+ if (!(await isDirectory(roots)) && (record.links.length > 0 || record.origin !== 'adopted')) {
708
+ await mkdir(roots, { recursive: true })
709
+ repaired.push(roots)
710
+ }
711
+ for (const link of record.links) {
712
+ const linkPath = join(roots, link.alias)
713
+ const node = await lstatOrNull(linkPath)
714
+ if (node === null) {
715
+ try {
716
+ await createDirectoryLink(link.target, linkPath)
717
+ repaired.push(link.alias)
718
+ } catch (error) {
719
+ conflicts.push(`${link.alias}: could not create the link (${errorMessage(error)})`)
720
+ }
721
+ continue
722
+ }
723
+ if (!node.isSymbolicLink()) {
724
+ conflicts.push(`${link.alias}: path is occupied by a real directory or file`)
725
+ continue
726
+ }
727
+ const actual = await resolveLink(linkPath)
728
+ if (!samePath(actual, link.target)) {
729
+ conflicts.push(`${link.alias}: link points elsewhere (${actual})`)
730
+ continue
731
+ }
732
+ if (!(await isDirectory(link.target))) conflicts.push(`${link.alias}: target folder is missing (${link.target})`)
733
+ }
734
+ return { repaired, conflicts, workspace: await this.view(record) }
735
+ })
736
+ }
737
+ }
738
+
739
+ /** Render an error as one message string. */
740
+ export function errorMessage(error) {
741
+ if (error instanceof Error) return error.message
742
+ return String(error)
743
+ }