@vibe-forge/workspace-assets 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ /* eslint-disable max-lines -- dependency normalization and graph expansion share the same local helpers */
2
+ import { readFile } from 'node:fs/promises'
3
+
4
+ import fm from 'front-matter'
5
+
6
+ import { parseScopedReference, resolveSkillIdentifier } from '@vibe-forge/definition-core'
7
+ import type { Config, Definition, Skill, WorkspaceAsset } from '@vibe-forge/types'
8
+ import { resolveRelativePath } from '@vibe-forge/utils'
9
+
10
+ import { HOME_BRIDGE_RESOLVED_BY } from './home-bridge'
11
+ import { installSkillsCliDependency } from './skills-cli-dependency'
12
+
13
+ type SkillAsset = Extract<WorkspaceAsset, { kind: 'skill' }>
14
+
15
+ export interface NormalizedSkillDependency {
16
+ ref: string
17
+ name: string
18
+ source?: string
19
+ }
20
+
21
+ interface DependencyExpansionParams {
22
+ allAssets: WorkspaceAsset[]
23
+ configs: [Config?, Config?]
24
+ cwd: string
25
+ excludedIds?: Set<string>
26
+ selectedAssets: SkillAsset[]
27
+ skillAssets: SkillAsset[]
28
+ }
29
+
30
+ const asNonEmptyString = (value: unknown) => (
31
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
32
+ )
33
+
34
+ const toSkillSlug = (value: string) => (
35
+ value
36
+ .trim()
37
+ .toLowerCase()
38
+ .replace(/[\s_]+/g, '-')
39
+ .replace(/[^a-z0-9-]/g, '')
40
+ .replace(/-+/g, '-')
41
+ .replace(/^-|-$/g, '')
42
+ )
43
+
44
+ const resolveUniqueSkillByName = (assets: SkillAsset[], name: string) => {
45
+ const nameSlug = toSkillSlug(name)
46
+ const matches = assets.filter(asset => asset.name === name || toSkillSlug(asset.name) === nameSlug)
47
+ if (matches.length === 0) return undefined
48
+
49
+ const unscopedMatches = matches.filter(asset => asset.scope == null)
50
+ if (unscopedMatches.length === 1) return unscopedMatches[0]
51
+
52
+ if (matches.length > 1) {
53
+ throw new Error(
54
+ `Ambiguous skill dependency ${name}. Candidates: ${matches.map(match => match.displayName).join(', ')}`
55
+ )
56
+ }
57
+
58
+ return matches[0]
59
+ }
60
+
61
+ const filterSearchableSkillAssets = (
62
+ assets: SkillAsset[],
63
+ options?: {
64
+ includeHomeBridge?: boolean
65
+ }
66
+ ) => (
67
+ options?.includeHomeBridge === false
68
+ ? assets.filter(asset => asset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY)
69
+ : assets
70
+ )
71
+
72
+ const removeHomeBridgeSkillDuplicates = (
73
+ assets: WorkspaceAsset[],
74
+ displayName: string
75
+ ) => {
76
+ for (let index = assets.length - 1; index >= 0; index--) {
77
+ const asset = assets[index]
78
+ if (asset.kind !== 'skill') continue
79
+ if (asset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY) continue
80
+ if (asset.displayName !== displayName) continue
81
+ assets.splice(index, 1)
82
+ }
83
+ }
84
+
85
+ const findSkillAssetByRef = (
86
+ assets: SkillAsset[],
87
+ ref: string,
88
+ currentInstancePath?: string,
89
+ options?: {
90
+ includeHomeBridge?: boolean
91
+ }
92
+ ) => {
93
+ const searchableAssets = filterSearchableSkillAssets(assets, options)
94
+ const scoped = parseScopedReference(ref, { pathSuffixes: ['.md', '.json', '.yaml', '.yml'] })
95
+ if (scoped != null) {
96
+ return searchableAssets.find(asset => asset.scope === scoped.scope && asset.name === scoped.name)
97
+ }
98
+
99
+ if (currentInstancePath != null) {
100
+ const local = searchableAssets.find(asset => asset.instancePath === currentInstancePath && asset.name === ref)
101
+ if (local != null) return local
102
+ }
103
+
104
+ return resolveUniqueSkillByName(searchableAssets, ref)
105
+ }
106
+
107
+ const resolveDisplayName = (name: string, scope?: string) => (
108
+ scope != null && scope.trim() !== '' ? `${scope}/${name}` : name
109
+ )
110
+
111
+ const parseFrontmatterSkill = async (path: string): Promise<Definition<Skill>> => {
112
+ const content = await readFile(path, 'utf-8')
113
+ const { body, attributes } = fm<Skill>(content)
114
+ return {
115
+ path,
116
+ body,
117
+ attributes
118
+ }
119
+ }
120
+
121
+ const createResolvedSkillAsset = (params: {
122
+ cwd: string
123
+ definition: Definition<Skill>
124
+ }) => {
125
+ const name = resolveSkillIdentifier(params.definition.path, params.definition.attributes.name)
126
+ const displayName = resolveDisplayName(name)
127
+ return {
128
+ id: `skill:workspace:workspace:${displayName}:${resolveRelativePath(params.cwd, params.definition.path)}`,
129
+ kind: 'skill',
130
+ name,
131
+ displayName,
132
+ origin: 'workspace',
133
+ sourcePath: params.definition.path,
134
+ payload: {
135
+ definition: params.definition
136
+ }
137
+ } satisfies SkillAsset
138
+ }
139
+
140
+ const parseStringDependency = (value: string): NormalizedSkillDependency => {
141
+ const ref = value.trim()
142
+ const atIndex = ref.lastIndexOf('@')
143
+ if (atIndex > 0 && atIndex < ref.length - 1) {
144
+ return {
145
+ ref,
146
+ source: ref.slice(0, atIndex),
147
+ name: ref.slice(atIndex + 1)
148
+ }
149
+ }
150
+
151
+ const sourcePathSegments = ref.split('/').filter(segment => segment.trim() !== '')
152
+ if (
153
+ sourcePathSegments.length >= 3 &&
154
+ sourcePathSegments.every(segment => !segment.includes(' '))
155
+ ) {
156
+ return {
157
+ ref,
158
+ source: sourcePathSegments.slice(0, -1).join('/'),
159
+ name: sourcePathSegments[sourcePathSegments.length - 1]
160
+ }
161
+ }
162
+
163
+ return {
164
+ ref,
165
+ name: ref
166
+ }
167
+ }
168
+
169
+ export const normalizeSkillDependency = (value: unknown): NormalizedSkillDependency | undefined => {
170
+ const stringValue = asNonEmptyString(value)
171
+ if (stringValue != null) return parseStringDependency(stringValue)
172
+
173
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) return undefined
174
+ const record = value as Record<string, unknown>
175
+ const name = asNonEmptyString(record.name)
176
+ if (name == null) return undefined
177
+
178
+ const source = asNonEmptyString(record.source)
179
+ return {
180
+ ref: source == null ? name : `${source}@${name}`,
181
+ name,
182
+ ...(source == null ? {} : { source })
183
+ }
184
+ }
185
+
186
+ export const normalizeSkillDependencies = (value: Skill['dependencies'] | undefined) => (
187
+ Array.isArray(value)
188
+ ? value
189
+ .map(normalizeSkillDependency)
190
+ .filter((dependency): dependency is NormalizedSkillDependency => dependency != null)
191
+ : []
192
+ )
193
+
194
+ export const findSkillDependencyAsset = (
195
+ assets: SkillAsset[],
196
+ dependency: NormalizedSkillDependency,
197
+ currentInstancePath?: string,
198
+ options?: {
199
+ includeHomeBridge?: boolean
200
+ }
201
+ ) => {
202
+ const candidateRefs = Array.from(
203
+ new Set([
204
+ dependency.ref,
205
+ dependency.name
206
+ ])
207
+ )
208
+
209
+ for (const ref of candidateRefs) {
210
+ const asset = findSkillAssetByRef(assets, ref, currentInstancePath, options)
211
+ if (asset != null) return asset
212
+ }
213
+
214
+ return undefined
215
+ }
216
+
217
+ export const expandSkillAssetDependencies = (
218
+ assets: SkillAsset[],
219
+ selectedAssets: SkillAsset[],
220
+ options: {
221
+ excludedIds?: Set<string>
222
+ } = {}
223
+ ) => {
224
+ const selected: SkillAsset[] = []
225
+ const seen = new Set<string>()
226
+
227
+ const addAsset = (asset: SkillAsset) => {
228
+ if (options.excludedIds?.has(asset.id)) return
229
+ if (seen.has(asset.id)) return
230
+ seen.add(asset.id)
231
+ selected.push(asset)
232
+
233
+ for (const dependency of normalizeSkillDependencies(asset.payload.definition.attributes.dependencies)) {
234
+ const dependencyAsset = findSkillDependencyAsset(assets, dependency, asset.instancePath)
235
+ if (dependencyAsset == null) {
236
+ throw new Error(`Failed to resolve skill dependency ${dependency.ref} declared by ${asset.displayName}`)
237
+ }
238
+ addAsset(dependencyAsset)
239
+ }
240
+ }
241
+
242
+ selectedAssets.forEach(addAsset)
243
+ return selected
244
+ }
245
+
246
+ export const expandSkillAssetDependenciesWithRemoteResolution = async (
247
+ params: DependencyExpansionParams
248
+ ) => {
249
+ const selected: SkillAsset[] = []
250
+ const seen = new Set<string>()
251
+ const fetchedDependencyRefs = new Set<string>()
252
+
253
+ const removeSupersededHomeBridgeSkill = (displayName: string) => {
254
+ removeHomeBridgeSkillDuplicates(params.allAssets, displayName)
255
+ removeHomeBridgeSkillDuplicates(params.skillAssets, displayName)
256
+ removeHomeBridgeSkillDuplicates(params.selectedAssets, displayName)
257
+ removeHomeBridgeSkillDuplicates(selected, displayName)
258
+ }
259
+
260
+ const installDependencyAsset = async (
261
+ dependency: NormalizedSkillDependency,
262
+ currentInstancePath?: string
263
+ ) => {
264
+ const fetchKey = dependency.ref
265
+ if (!fetchedDependencyRefs.has(fetchKey)) {
266
+ fetchedDependencyRefs.add(fetchKey)
267
+ const installed = await installSkillsCliDependency({
268
+ cwd: params.cwd,
269
+ configs: params.configs,
270
+ dependency
271
+ })
272
+ const definition = await parseFrontmatterSkill(installed.skillPath)
273
+ const dependencyAsset = createResolvedSkillAsset({
274
+ cwd: params.cwd,
275
+ definition
276
+ })
277
+ const existingAsset = findSkillDependencyAsset(
278
+ params.skillAssets,
279
+ dependency,
280
+ currentInstancePath,
281
+ { includeHomeBridge: false }
282
+ ) ??
283
+ params.skillAssets.find(existing => (
284
+ existing.resolvedBy !== HOME_BRIDGE_RESOLVED_BY &&
285
+ existing.displayName === dependencyAsset.displayName
286
+ ))
287
+ if (existingAsset != null) {
288
+ removeSupersededHomeBridgeSkill(existingAsset.displayName)
289
+ return existingAsset
290
+ }
291
+
292
+ removeSupersededHomeBridgeSkill(dependencyAsset.displayName)
293
+ params.allAssets.push(dependencyAsset)
294
+ params.skillAssets.push(dependencyAsset)
295
+ return dependencyAsset
296
+ }
297
+
298
+ // After the first fetch attempt, reuse whichever asset is now visible in the
299
+ // skill set: a newly installed registry skill, or a home-bridge fallback
300
+ // that was accepted by an earlier plain-name dependency resolution.
301
+ const resolvedAsset = findSkillDependencyAsset(params.skillAssets, dependency, currentInstancePath)
302
+ if (resolvedAsset != null && resolvedAsset.resolvedBy !== HOME_BRIDGE_RESOLVED_BY) {
303
+ removeSupersededHomeBridgeSkill(resolvedAsset.displayName)
304
+ }
305
+ return resolvedAsset
306
+ }
307
+
308
+ const addAsset = async (asset: SkillAsset): Promise<void> => {
309
+ if (params.excludedIds?.has(asset.id)) return
310
+ if (seen.has(asset.id)) return
311
+ seen.add(asset.id)
312
+ selected.push(asset)
313
+
314
+ for (const dependency of normalizeSkillDependencies(asset.payload.definition.attributes.dependencies)) {
315
+ const localOrBridgedDependency = findSkillDependencyAsset(
316
+ params.skillAssets,
317
+ dependency,
318
+ asset.instancePath
319
+ )
320
+ const directDependency = findSkillDependencyAsset(
321
+ params.skillAssets,
322
+ dependency,
323
+ asset.instancePath,
324
+ { includeHomeBridge: false }
325
+ )
326
+
327
+ if (directDependency != null) {
328
+ await addAsset(directDependency)
329
+ continue
330
+ }
331
+
332
+ // Keep registry-backed dependencies ahead of home-bridge skills. We only
333
+ // fall back to a bridged home skill for plain-name dependencies when the
334
+ // registry path is unavailable.
335
+ const dependencyAsset = await installDependencyAsset(dependency, asset.instancePath).catch((error: unknown) => {
336
+ if (
337
+ localOrBridgedDependency != null &&
338
+ dependency.source == null
339
+ ) {
340
+ return localOrBridgedDependency
341
+ }
342
+ throw error
343
+ }) ?? (
344
+ dependency.source == null
345
+ ? localOrBridgedDependency
346
+ : undefined
347
+ )
348
+
349
+ if (dependencyAsset == null) {
350
+ throw new Error(`Failed to resolve skill dependency ${dependency.ref} declared by ${asset.displayName}`)
351
+ }
352
+ await addAsset(dependencyAsset)
353
+ }
354
+ }
355
+
356
+ for (const asset of params.selectedAssets) {
357
+ await addAsset(asset)
358
+ }
359
+
360
+ return selected
361
+ }
@@ -0,0 +1,208 @@
1
+ import { access, copyFile, lstat, mkdir, readdir, rename, rm } from 'node:fs/promises'
2
+ import { dirname, resolve } from 'node:path'
3
+ import process from 'node:process'
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+
6
+ import type { Config, SkillsCliConfig } from '@vibe-forge/types'
7
+ import { resolveSkillsCliRuntimeConfig } from '@vibe-forge/utils'
8
+ import { resolveProjectSharedCachePath } from '@vibe-forge/utils/project-cache-path'
9
+ import {
10
+ findSkillsCli,
11
+ installSkillsCliRefToTemp,
12
+ installSkillsCliSkillToTemp,
13
+ resolveSkillsCliRegistry,
14
+ toSkillSlug
15
+ } from '@vibe-forge/utils/skills-cli'
16
+
17
+ import type { NormalizedSkillDependency } from './skill-dependencies'
18
+
19
+ const INSTALL_LOCK_TIMEOUT_MS = 30_000
20
+ const INSTALL_LOCK_RETRY_MS = 100
21
+
22
+ const toCacheSegment = (value: string) => (
23
+ value
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9._-]+/g, '-')
27
+ .replace(/^-+|-+$/g, '') || 'default'
28
+ )
29
+
30
+ const pathExists = async (targetPath: string) => {
31
+ try {
32
+ await access(targetPath)
33
+ return true
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ const withInstallLock = async <T>(lockDir: string, callback: () => Promise<T>) => {
40
+ const start = Date.now()
41
+ await mkdir(dirname(lockDir), { recursive: true })
42
+
43
+ while (true) {
44
+ try {
45
+ await mkdir(lockDir)
46
+ break
47
+ } catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
49
+ if (Date.now() - start > INSTALL_LOCK_TIMEOUT_MS) {
50
+ throw new Error(`Timed out waiting for skill dependency install lock ${lockDir}`)
51
+ }
52
+ await delay(INSTALL_LOCK_RETRY_MS)
53
+ }
54
+ }
55
+
56
+ try {
57
+ return await callback()
58
+ } finally {
59
+ await rm(lockDir, { recursive: true, force: true })
60
+ }
61
+ }
62
+
63
+ const copyRegularFiles = async (sourceDir: string, targetDir: string) => {
64
+ let fileCount = 0
65
+ const entries = await readdir(sourceDir, { withFileTypes: true })
66
+
67
+ await mkdir(targetDir, { recursive: true })
68
+
69
+ for (const entry of entries) {
70
+ const sourcePath = resolve(sourceDir, entry.name)
71
+ const targetPath = resolve(targetDir, entry.name)
72
+ const stat = await lstat(sourcePath)
73
+
74
+ if (stat.isDirectory()) {
75
+ fileCount += await copyRegularFiles(sourcePath, targetPath)
76
+ continue
77
+ }
78
+
79
+ if (!stat.isFile()) continue
80
+
81
+ await mkdir(dirname(targetPath), { recursive: true })
82
+ await copyFile(sourcePath, targetPath)
83
+ fileCount += 1
84
+ }
85
+
86
+ return fileCount
87
+ }
88
+
89
+ const pickSearchResult = (results: Awaited<ReturnType<typeof findSkillsCli>>, name: string) => {
90
+ const slug = toSkillSlug(name)
91
+ return results.find(result => (
92
+ result.skill === name ||
93
+ toSkillSlug(result.skill) === slug
94
+ )) ?? results[0]
95
+ }
96
+
97
+ const resolveConfiguredSkillsCliConfig = (configs: [Config?, Config?]) => {
98
+ const [projectConfig, userConfig] = configs
99
+ const merged = {
100
+ ...(resolveSkillsCliRuntimeConfig(projectConfig) ?? {}),
101
+ ...(resolveSkillsCliRuntimeConfig(userConfig) ?? {})
102
+ } satisfies SkillsCliConfig
103
+
104
+ return Object.keys(merged).length === 0 ? undefined : merged
105
+ }
106
+
107
+ const buildInstallDir = (params: {
108
+ config?: SkillsCliConfig
109
+ cwd: string
110
+ skill: string
111
+ source: string
112
+ }) => {
113
+ const registry = resolveSkillsCliRegistry({
114
+ config: params.config
115
+ }) ?? 'default'
116
+ return resolveProjectSharedCachePath(
117
+ params.cwd,
118
+ process.env,
119
+ 'skill-dependencies',
120
+ 'skills-cli',
121
+ toCacheSegment(params.config?.package ?? 'skills'),
122
+ toCacheSegment(params.config?.version ?? 'latest'),
123
+ toCacheSegment(registry),
124
+ ...params.source.split('/').map(toCacheSegment),
125
+ toCacheSegment(params.skill)
126
+ )
127
+ }
128
+
129
+ export const installSkillsCliDependency = async (params: {
130
+ cwd: string
131
+ configs: [Config?, Config?]
132
+ dependency: NormalizedSkillDependency
133
+ }) => {
134
+ const config = resolveConfiguredSkillsCliConfig(params.configs)
135
+ const resolvedTarget = params.dependency.source != null
136
+ ? {
137
+ skill: params.dependency.name,
138
+ source: params.dependency.source
139
+ }
140
+ : await (async () => {
141
+ const searchResults = await findSkillsCli({
142
+ config,
143
+ query: params.dependency.name
144
+ })
145
+ const selected = pickSearchResult(searchResults, params.dependency.name)
146
+ if (selected == null) {
147
+ throw new Error(`Skill ${params.dependency.name} was not found by the skills CLI search.`)
148
+ }
149
+
150
+ return {
151
+ installRef: selected.installRef,
152
+ skill: selected.skill,
153
+ source: selected.source
154
+ }
155
+ })()
156
+
157
+ const installDir = buildInstallDir({
158
+ config,
159
+ cwd: params.cwd,
160
+ skill: resolvedTarget.skill,
161
+ source: resolvedTarget.source
162
+ })
163
+ const skillPath = resolve(installDir, 'SKILL.md')
164
+
165
+ return await withInstallLock(`${installDir}.lock`, async () => {
166
+ if (await pathExists(skillPath)) {
167
+ return {
168
+ installDir,
169
+ skillPath
170
+ }
171
+ }
172
+
173
+ const tempInstallDir = `${installDir}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
174
+ await rm(tempInstallDir, { recursive: true, force: true })
175
+ await mkdir(tempInstallDir, { recursive: true })
176
+
177
+ const installResult = 'installRef' in resolvedTarget
178
+ ? await installSkillsCliRefToTemp({
179
+ config,
180
+ installRef: resolvedTarget.installRef
181
+ })
182
+ : await installSkillsCliSkillToTemp({
183
+ config,
184
+ skill: resolvedTarget.skill,
185
+ source: resolvedTarget.source
186
+ })
187
+
188
+ try {
189
+ await copyRegularFiles(installResult.installedSkill.sourcePath, tempInstallDir)
190
+ if (!await pathExists(resolve(tempInstallDir, 'SKILL.md'))) {
191
+ throw new Error(`Skill dependency ${params.dependency.ref} did not include SKILL.md`)
192
+ }
193
+
194
+ await rm(installDir, { recursive: true, force: true })
195
+ await rename(tempInstallDir, installDir)
196
+ } catch (error) {
197
+ await rm(tempInstallDir, { recursive: true, force: true })
198
+ throw error
199
+ } finally {
200
+ await rm(installResult.tempDir, { recursive: true, force: true })
201
+ }
202
+
203
+ return {
204
+ installDir,
205
+ skillPath
206
+ }
207
+ })
208
+ }
@@ -0,0 +1,132 @@
1
+ import { stat } from 'node:fs/promises'
2
+
3
+ import type { Config, WorkspaceConfigEntry } from '@vibe-forge/types'
4
+ import { glob } from 'fast-glob'
5
+
6
+ const DEFAULT_WORKSPACE_IGNORES = [
7
+ '**/.git/**',
8
+ '**/.ai/**',
9
+ '**/node_modules/**'
10
+ ]
11
+
12
+ export interface NormalizedWorkspaceEntry {
13
+ enabled?: boolean
14
+ name?: string
15
+ description?: string
16
+ path?: string
17
+ include?: string[]
18
+ exclude?: string[]
19
+ }
20
+
21
+ const isRecord = (value: unknown): value is Record<string, unknown> => (
22
+ value != null && typeof value === 'object' && !Array.isArray(value)
23
+ )
24
+
25
+ const toStringList = (value: unknown): string[] => {
26
+ if (typeof value === 'string' && value.trim() !== '') {
27
+ return [value.trim()]
28
+ }
29
+ if (!Array.isArray(value)) return []
30
+
31
+ return value
32
+ .filter((item): item is string => typeof item === 'string' && item.trim() !== '')
33
+ .map(item => item.trim())
34
+ }
35
+
36
+ const normalizeWorkspaceEntry = (
37
+ id: string,
38
+ value: string | WorkspaceConfigEntry
39
+ ): NormalizedWorkspaceEntry | undefined => {
40
+ if (typeof value === 'string') {
41
+ return { path: value }
42
+ }
43
+
44
+ if (!isRecord(value)) return undefined
45
+ const enabled = typeof value.enabled === 'boolean' ? value.enabled : undefined
46
+ if (enabled === false) return { enabled: false }
47
+
48
+ return {
49
+ enabled,
50
+ name: typeof value.name === 'string' && value.name.trim() !== '' ? value.name.trim() : id,
51
+ description: typeof value.description === 'string' && value.description.trim() !== ''
52
+ ? value.description.trim()
53
+ : undefined,
54
+ path: typeof value.path === 'string' && value.path.trim() !== '' ? value.path.trim() : undefined,
55
+ include: [
56
+ ...toStringList(value.include),
57
+ ...toStringList(value.glob),
58
+ ...toStringList(value.globs)
59
+ ],
60
+ exclude: toStringList(value.exclude)
61
+ }
62
+ }
63
+
64
+ export const normalizeWorkspaceConfig = (config: Config['workspaces'] | undefined) => {
65
+ if (config == null) {
66
+ return {
67
+ include: [] as string[],
68
+ exclude: [] as string[],
69
+ entries: {} as Record<string, NormalizedWorkspaceEntry>
70
+ }
71
+ }
72
+
73
+ if (typeof config === 'string' || Array.isArray(config)) {
74
+ return {
75
+ include: toStringList(config),
76
+ exclude: [] as string[],
77
+ entries: {} as Record<string, NormalizedWorkspaceEntry>
78
+ }
79
+ }
80
+
81
+ if (!isRecord(config)) {
82
+ return {
83
+ include: [] as string[],
84
+ exclude: [] as string[],
85
+ entries: {} as Record<string, NormalizedWorkspaceEntry>
86
+ }
87
+ }
88
+
89
+ const entries = Object.fromEntries(
90
+ Object.entries(config.entries ?? {})
91
+ .map(([id, value]) => [id, normalizeWorkspaceEntry(id, value)])
92
+ .filter((entry): entry is [string, NormalizedWorkspaceEntry] => entry[1] != null)
93
+ )
94
+
95
+ return {
96
+ include: [
97
+ ...toStringList(config.include),
98
+ ...toStringList(config.glob),
99
+ ...toStringList(config.globs)
100
+ ],
101
+ exclude: toStringList(config.exclude),
102
+ entries
103
+ }
104
+ }
105
+
106
+ export const isDirectory = async (path: string) => {
107
+ try {
108
+ return (await stat(path)).isDirectory()
109
+ } catch {
110
+ return false
111
+ }
112
+ }
113
+
114
+ export const scanWorkspacePatterns = async (
115
+ cwd: string,
116
+ patterns: string[],
117
+ exclude: string[]
118
+ ) => {
119
+ if (patterns.length === 0) return []
120
+
121
+ return await glob(patterns, {
122
+ cwd,
123
+ absolute: true,
124
+ onlyDirectories: true,
125
+ unique: true,
126
+ followSymbolicLinks: true,
127
+ ignore: [
128
+ ...DEFAULT_WORKSPACE_IGNORES,
129
+ ...exclude
130
+ ]
131
+ })
132
+ }