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/LICENSE +21 -0
- package/README.md +91 -2
- package/client.js +740 -0
- package/context.js +78 -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 +118 -0
- package/store.js +743 -0
- package/tool.js +246 -0
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
|
+
}
|