@vzn/vx 0.0.203 → 0.0.205

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/README.md CHANGED
@@ -216,7 +216,7 @@ every commit. The schema may still change; what 1.0 will promise is in
216
216
  | Surface | Maturity |
217
217
  | -------------------------------------------------- | -------------------- |
218
218
  | Core task runner + caching | **production-ready** |
219
- | Plugin pipeline (13 hooks, `commands` included) | **shippable** |
219
+ | Plugin pipeline (14 hooks, `commands` included) | **shippable** |
220
220
  | `vx init` / `@vzn/vx-migrate` (scripts; Turbo, Nx) | **shippable** |
221
221
  | First-party plugins (table above) | **shippable** |
222
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.203",
3
+ "version": "0.0.205",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.203",
21
- "@vzn/vx-linux-arm64": "0.0.203",
22
- "@vzn/vx-darwin-x64": "0.0.203",
23
- "@vzn/vx-darwin-arm64": "0.0.203"
20
+ "@vzn/vx-linux-x64": "0.0.205",
21
+ "@vzn/vx-linux-arm64": "0.0.205",
22
+ "@vzn/vx-darwin-x64": "0.0.205",
23
+ "@vzn/vx-darwin-arm64": "0.0.205"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/select.ts CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  type LoadReads,
16
16
  type FingerprintClaims,
17
17
  FROZEN_WITHOUT_LOCK,
18
- listProjects,
19
18
  loadProjectConfig,
20
19
  loadWorkspace,
21
20
  parseFilter,
@@ -28,7 +27,12 @@ import type { ProjectEntry } from '../workspace/index.js'
28
27
  import { parseDependencySpec } from '../graph/index.js'
29
28
  import { nearest, UserError } from '../util/index.js'
30
29
  import { claimedAffected, fingerprintClaims } from '../orchestrator/index.js'
31
- import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
30
+ import {
31
+ type CliLoadOptions,
32
+ discoverCliProjects,
33
+ loadCliProjects,
34
+ loadCliWorkspace,
35
+ } from './workspace-config.js'
32
36
 
33
37
  /**
34
38
  * The projects whose tasks declare a `cache.inputs.workspaceFiles` glob
@@ -110,7 +114,7 @@ async function workspaceFingerprintClaims(
110
114
  async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
111
115
  const reads: LoadReads = new Map()
112
116
  const root = await findWorkspaceRoot(cwd, reads)
113
- return await listProjects(await loadWorkspace(root, reads))
117
+ return await discoverCliProjects(await loadWorkspace(root, reads))
114
118
  }
115
119
 
116
120
  export async function findCwdProject(cwd: string): Promise<string | null> {
package/src/cli/show.ts CHANGED
@@ -10,11 +10,10 @@ import type { ProjectConfig, TaskConfig } from '../config.js'
10
10
  import { declaredTask } from '../graph/index.js'
11
11
  import { flagHint, seeHelp } from './help.js'
12
12
  import { nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
13
- import { loadCliProjects } from './workspace-config.js'
13
+ import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
14
14
  import {
15
15
  findWorkspaceRoot,
16
16
  type LoadReads,
17
- listProjects,
18
17
  loadWorkspace,
19
18
  type ProjectEntry,
20
19
  type ProjectMeta,
@@ -60,7 +59,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
60
59
  }
61
60
  const reads: LoadReads = new Map()
62
61
  const root = await findWorkspaceRoot(process.cwd(), reads)
63
- const metas = await listProjects(await loadWorkspace(root, reads))
62
+ const metas = await discoverCliProjects(await loadWorkspace(root, reads))
64
63
  const byName = new Map(metas.map((m) => [m.name, m]))
65
64
 
66
65
  const hashAt = parsed.target?.indexOf('#') ?? -1
@@ -5,9 +5,9 @@
5
5
  // name would change meaning the day a plugin declares that verb.
6
6
 
7
7
  import { nxProjectTarget } from '../orchestrator/index.js'
8
- import { findWorkspaceRoot, listProjects, loadWorkspace } from '../workspace/index.js'
8
+ import { findWorkspaceRoot, loadWorkspace } from '../workspace/index.js'
9
9
  import { findCwdProject } from './select.js'
10
- import { loadCliProjects } from './workspace-config.js'
10
+ import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
11
11
 
12
12
  /** The `vx run` a task typed as a verb means, or null when no project declares it. */
13
13
  export async function taskVerbHint(
@@ -64,9 +64,14 @@ async function workspaceProjects(
64
64
  ): Promise<Awaited<ReturnType<typeof loadCliProjects>> | null> {
65
65
  try {
66
66
  const root = await findWorkspaceRoot(cwd)
67
- return await loadCliProjects(root, await listProjects(await loadWorkspace(root)), 'all', {
68
- noCreate: true,
69
- })
67
+ return await loadCliProjects(
68
+ root,
69
+ await discoverCliProjects(await loadWorkspace(root)),
70
+ 'all',
71
+ {
72
+ noCreate: true,
73
+ },
74
+ )
70
75
  } catch {
71
76
  // No workspace, or a config that does not load: the caller's plain line stands.
72
77
  return null
package/src/cli/watch.ts CHANGED
@@ -26,13 +26,12 @@ import {
26
26
  import {
27
27
  findWorkspaceRoot,
28
28
  type LoadReads,
29
- listProjects,
30
29
  loadWorkspace,
31
30
  LOCKFILE_NAME,
32
31
  memberBaseDirs,
33
32
  type ProjectMeta,
34
33
  } from '../workspace/index.js'
35
- import { type CliLoadOptions, loadCliWorkspace } from './workspace-config.js'
34
+ import { type CliLoadOptions, discoverCliProjects, loadCliWorkspace } from './workspace-config.js'
36
35
  import {
37
36
  isIgnoredWatchPath,
38
37
  isWorkspaceConfigFile,
@@ -148,7 +147,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
148
147
  const reads: LoadReads = new Map()
149
148
  const workspaceRoot = await findWorkspaceRoot(cwd, reads)
150
149
  const workspace = await loadWorkspace(workspaceRoot, reads)
151
- const allProjects = await listProjects(workspace)
150
+ const allProjects = await discoverCliProjects(workspace)
152
151
  const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
153
152
  opts.projects === undefined ? [...all] : all.filter((p) => opts.projects!.includes(p.name))
154
153
  const scope = inScope(allProjects)
@@ -226,7 +225,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
226
225
  // dependency of it.
227
226
  rediscover: async () => {
228
227
  const workspace = await loadWorkspace(workspaceRoot)
229
- const all = await listProjects(workspace)
228
+ const all = await discoverCliProjects(workspace)
230
229
  const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
231
230
  const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
232
231
  return {
@@ -9,12 +9,13 @@ import path from 'node:path'
9
9
  import type { WorkspaceConfig } from '../config.js'
10
10
  import { UserError } from '../util/index.js'
11
11
  import { Cache, noteSchemaReset } from '../cache/index.js'
12
- import { loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
12
+ import { discoverProjects, loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
13
13
  import type { VxPlugin } from '../orchestrator/index.js'
14
14
  import {
15
15
  buildPackageGraph,
16
16
  computeWorkspaceFingerprint,
17
17
  FROZEN_WITHOUT_LOCK,
18
+ type Workspace,
18
19
  type ProjectEntry,
19
20
  type ProjectMeta,
20
21
  readLockfile,
@@ -36,6 +37,15 @@ export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorksp
36
37
  return { workspaceConfig, plugins, cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig) }
37
38
  }
38
39
 
40
+ /**
41
+ * The workspace's projects as a run discovers them: core's, then the
42
+ * plugin `discover` stage, with the cache dir the run uses.
43
+ */
44
+ export async function discoverCliProjects(workspace: Workspace): Promise<ProjectMeta[]> {
45
+ const { plugins, cacheDir } = await loadCliWorkspace(workspace.root)
46
+ return await discoverProjects(workspace, plugins, cacheDir, warnToStderr)
47
+ }
48
+
39
49
  /**
40
50
  * `--cache-dir <path>` / `--cache-dir=<path>` for a verb that reads the
41
51
  * cache a run wrote — `why`, `last`, `info`, `cache prune`. Same rules as
package/src/config.ts CHANGED
@@ -56,6 +56,7 @@ export interface Plugin {
56
56
  /** The package name — set by `definePlugin`, never by the plugin. */
57
57
  readonly name: string
58
58
  config?(workspace: unknown, ctx: unknown): unknown
59
+ discover?(ctx: unknown): unknown
59
60
  project?(config: unknown, ctx: unknown): unknown
60
61
  graph?(nodes: unknown, ctx: unknown): unknown
61
62
  key?(task: unknown, ctx: unknown): unknown
@@ -80,6 +81,7 @@ export interface Plugin {
80
81
  */
81
82
  export const PLUGIN_HOOKS = [
82
83
  'config',
84
+ 'discover',
83
85
  'project',
84
86
  'graph',
85
87
  'key',
package/src/index.ts CHANGED
@@ -161,6 +161,8 @@ export type {
161
161
  ScheduleHookContext,
162
162
  AdmitContext,
163
163
  WorkspaceHookContext,
164
+ DiscoverContext,
165
+ NamedProject,
164
166
  PluginSetupContext,
165
167
  } from './orchestrator/index.js'
166
168
  // The per-task execution contract a plugin's `executor` capability returns.
@@ -24,7 +24,6 @@ import {
24
24
  computeWorkspaceFingerprint,
25
25
  findWorkspaceRoot,
26
26
  type LoadReads,
27
- listProjects,
28
27
  loadProjectConfig,
29
28
  loadWorkspace,
30
29
  lockfilePath,
@@ -33,7 +32,7 @@ import {
33
32
  } from '../workspace/index.js'
34
33
  import { flakyTasks, type FlakyTask } from './failure-mode.js'
35
34
  import type { VxPlugin } from './plugin.js'
36
- import { loadProjects, loadWorkspacePlugins } from './projects.js'
35
+ import { discoverProjects, loadProjects, loadWorkspacePlugins } from './projects.js'
37
36
 
38
37
  const warnToStderr = (message: string): void => {
39
38
  process.stderr.write(`${message}\n`)
@@ -114,12 +113,12 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
114
113
  const warn = opts.warn ?? warnToStderr
115
114
  const reads: LoadReads = new Map()
116
115
  const root = await findWorkspaceRoot(cwd, reads)
117
- const metas = await listProjects(await loadWorkspace(root, reads))
118
116
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(root, warn)
119
117
  const cacheDir =
120
118
  opts.cacheDir === undefined
121
119
  ? resolveCacheDir(root, workspaceConfig)
122
120
  : path.resolve(cwd, opts.cacheDir)
121
+ const metas = await discoverProjects(await loadWorkspace(root, reads), plugins, cacheDir, warn)
123
122
  const cache = Cache.inspect(cacheDir)
124
123
  noteSchemaReset(cache, warn)
125
124
  let stats
@@ -4,6 +4,7 @@
4
4
  export { run, planRun, nxProjectTarget } from './run.js'
5
5
  export { prepareRun, type PreparedRun } from './prepare.js'
6
6
  export {
7
+ discoverProjects,
7
8
  loadProjects,
8
9
  loadResolvedProjects,
9
10
  loadWorkspacePlugins,
@@ -70,6 +71,8 @@ export {
70
71
  type ScheduleHookContext,
71
72
  type AdmitContext,
72
73
  type WorkspaceHookContext,
74
+ type DiscoverContext,
75
+ type NamedProject,
73
76
  type InstallPluginsArgs,
74
77
  type Plugin,
75
78
  type PluginContext,
@@ -14,16 +14,19 @@ import type { ProjectConfig, WorkspaceConfig } from '../config.js'
14
14
  import { checkGraph, type TaskNode } from '../graph/index.js'
15
15
  import type {
16
16
  CacheContext,
17
+ DiscoverContext,
17
18
  ExecutorContext,
18
19
  FingerprintChange,
19
20
  FingerprintContext,
20
21
  GraphHookContext,
21
22
  KeyHookContext,
23
+ NamedProject,
22
24
  ProjectHookContext,
23
25
  ScheduleHookContext,
24
26
  VxPlugin,
25
27
  WorkspaceHookContext,
26
28
  } from './plugin.js'
29
+ import type { ProjectMeta } from '../workspace/index.js'
27
30
 
28
31
  /** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
29
32
  function describeValue(v: unknown): string {
@@ -97,7 +100,7 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
97
100
 
98
101
  export function hasHook(
99
102
  plugins: readonly VxPlugin[],
100
- hook: 'config' | 'project' | 'graph' | 'key' | 'schedule' | 'admit',
103
+ hook: 'config' | 'discover' | 'project' | 'graph' | 'key' | 'schedule' | 'admit',
101
104
  ): boolean {
102
105
  for (const p of plugins) if (p[hook] !== undefined) return true
103
106
  return false
@@ -118,6 +121,32 @@ export async function applyConfigHooks(
118
121
  }
119
122
  }
120
123
 
124
+ /**
125
+ * `discover` stage: each plugin names directories to add to `projects`,
126
+ * seeing the ones earlier plugins added. `add` turns one name into a meta
127
+ * (null: already there) or refuses it.
128
+ */
129
+ export async function applyDiscoverHooks(
130
+ plugins: readonly VxPlugin[],
131
+ projects: ProjectMeta[],
132
+ ctx: Omit<DiscoverContext, 'projects'>,
133
+ add: (named: NamedProject, plugin: VxPlugin) => Promise<ProjectMeta | null>,
134
+ ): Promise<void> {
135
+ for (const plugin of plugins) {
136
+ if (plugin.discover === undefined) continue
137
+ const named = await safe(plugin, 'discover', () => plugin.discover!({ ...ctx, projects }))
138
+ if (!Array.isArray(named)) {
139
+ throw new UserError(
140
+ `plugin '${plugin.name}' failed in discover: expected an array, got ${describeValue(named)}`,
141
+ )
142
+ }
143
+ for (const n of named as readonly NamedProject[]) {
144
+ const meta = await add(n, plugin)
145
+ if (meta !== null) projects.push(meta)
146
+ }
147
+ }
148
+ }
149
+
121
150
  /** `project` stage: every plugin edits one project's config in place. */
122
151
  export async function applyProjectHooks(
123
152
  plugins: readonly VxPlugin[],
@@ -60,6 +60,17 @@ export interface VxPlugin {
60
60
  */
61
61
  config?(workspace: WorkspaceConfig, ctx: WorkspaceHookContext): void | Promise<void>
62
62
 
63
+ /**
64
+ * Directories to make projects beyond the package manager's members, as
65
+ * `{ dir, name }` (`dir` absolute or relative to the workspace root): a
66
+ * runner's root tasks, a project no member glob lists. Runs once per
67
+ * discovery, before any project config loads; each plugin sees core's
68
+ * projects and the ones earlier plugins named. A named directory keeps
69
+ * a `package.json` of the same name, if it has one; a name or directory
70
+ * already taken otherwise, or one outside the root, is refused.
71
+ */
72
+ discover?(ctx: DiscoverContext): readonly NamedProject[] | Promise<readonly NamedProject[]>
73
+
63
74
  /**
64
75
  * One project's validated config, right after it loaded and before the
65
76
  * graph is built. Add, remove or edit tasks. Runs for every loaded project
@@ -243,6 +254,18 @@ export interface WorkspaceHookContext {
243
254
  warn(message: string): void
244
255
  }
245
256
 
257
+ export interface DiscoverContext extends WorkspaceHookContext {
258
+ /** Where vx's cache lives: a plugin may keep what it read to answer here. */
259
+ readonly cacheDir: string
260
+ /** The projects found so far: core's, then earlier plugins'. */
261
+ readonly projects: readonly ProjectMeta[]
262
+ }
263
+
264
+ export interface NamedProject {
265
+ readonly dir: string
266
+ readonly name: string
267
+ }
268
+
246
269
  export interface ProjectHookContext extends BaseContext {
247
270
  /** The package name (`package.json#name`). */
248
271
  readonly name: string
@@ -29,7 +29,6 @@ import {
29
29
  computeWorkspaceFingerprints,
30
30
  findWorkspaceRoot,
31
31
  type LoadReads,
32
- listProjects,
33
32
  loadWorkspace,
34
33
  FROZEN_WITHOUT_LOCK,
35
34
  readLockfile,
@@ -53,7 +52,12 @@ import {
53
52
  resolveCache,
54
53
  teardownPlugins,
55
54
  } from './plugin-host.js'
56
- import { loadProjects, loadWorkspacePlugins, type LoadedProjects } from './projects.js'
55
+ import {
56
+ discoverProjects,
57
+ loadProjects,
58
+ loadWorkspacePlugins,
59
+ type LoadedProjects,
60
+ } from './projects.js'
57
61
  import { keyExcludedDependencies } from './excluded-keys.js'
58
62
  import { FingerprintWatch } from './fingerprint-watch.js'
59
63
  import type { VxPlugin } from './plugin.js'
@@ -184,10 +188,15 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
184
188
  log.status(m),
185
189
  )
186
190
  mark('workspace config')
191
+ // `--cache-dir <path>` (RunOptions.cacheDir) overrides the workspace
192
+ // `cacheDir` field + the `.vx/cache` default; resolved relative to cwd.
193
+ const cacheDir = options.cacheDir
194
+ ? path.resolve(options.cwd, options.cacheDir)
195
+ : resolveCacheDir(workspaceRoot, workspaceConfig)
187
196
  const projectMetas =
188
197
  options.discovered?.root === workspaceRoot
189
198
  ? options.discovered.projects
190
- : await listProjects(workspace)
199
+ : await discoverProjects(workspace, plugins, cacheDir, (m) => log.status(m))
191
200
  mark('discover projects')
192
201
 
193
202
  // SCOPED config loading: configs are programs, and evaluating 1090
@@ -220,13 +229,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
220
229
  mark('package graph')
221
230
 
222
231
  // The local cache opens BEFORE the configs load: it is also where their
223
- // cached evaluations live. `--cache-dir <path>` (RunOptions.cacheDir)
224
- // overrides the workspace `cacheDir` field + the `.vx/cache` default;
225
- // resolved relative to cwd.
232
+ // cached evaluations live.
226
233
  const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
227
- const cacheDir = options.cacheDir
228
- ? path.resolve(options.cwd, options.cacheDir)
229
- : resolveCacheDir(workspaceRoot, workspaceConfig)
230
234
  const localCache = new Cache(
231
235
  cacheDir,
232
236
  { read: policy.localRead, write: policy.localWrite },
@@ -15,6 +15,7 @@ import {
15
15
  type PackageGraph,
16
16
  type ProjectEntry,
17
17
  type ProjectMeta,
18
+ type Workspace,
18
19
  } from '../workspace/index.js'
19
20
  import type { WorkspaceConfig } from '../config.js'
20
21
  import { Cache } from '../cache/index.js'
@@ -25,11 +26,12 @@ import {
25
26
  listProjects,
26
27
  loadWorkspace,
27
28
  loadWorkspaceConfig,
29
+ namedProject,
28
30
  resolveCacheDir,
29
31
  validateWorkspace,
30
32
  } from '../workspace/index.js'
31
33
  import { parseDependencySpec } from '../graph/index.js'
32
- import { applyConfigHooks, applyProjectHooks, hasHook } from './plugin-host.js'
34
+ import { applyConfigHooks, applyDiscoverHooks, applyProjectHooks, hasHook } from './plugin-host.js'
33
35
  import type { VxPlugin } from './plugin.js'
34
36
 
35
37
  /**
@@ -54,6 +56,27 @@ export async function loadWorkspacePlugins(
54
56
  return { workspaceConfig, plugins }
55
57
  }
56
58
 
59
+ /**
60
+ * The workspace's projects: core's discovery, then the plugin `discover`
61
+ * stage. With no plugin declaring it, `listProjects` alone.
62
+ */
63
+ export async function discoverProjects(
64
+ workspace: Workspace,
65
+ plugins: readonly VxPlugin[],
66
+ cacheDir: string,
67
+ warn: (message: string) => void,
68
+ ): Promise<ProjectMeta[]> {
69
+ const projects = await listProjects(workspace)
70
+ if (!hasHook(plugins, 'discover')) return projects
71
+ await applyDiscoverHooks(
72
+ plugins,
73
+ projects,
74
+ { workspaceRoot: workspace.root, cacheDir, warn },
75
+ (named, plugin) => namedProject(workspace, projects, named, plugin.name),
76
+ )
77
+ return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
78
+ }
79
+
57
80
  export interface LoadProjectsArgs {
58
81
  workspaceRoot: string
59
82
  cacheDir: string
@@ -249,9 +272,14 @@ export async function loadResolvedProjects(
249
272
  ): Promise<Map<string, ProjectEntry>> {
250
273
  const warn = opts.warn ?? ((): void => {})
251
274
  const reads: LoadReads = new Map()
252
- const metas = await listProjects(await loadWorkspace(workspaceRoot, reads))
253
275
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warn)
254
276
  const cacheDir = resolveCacheDir(workspaceRoot, workspaceConfig)
277
+ const metas = await discoverProjects(
278
+ await loadWorkspace(workspaceRoot, reads),
279
+ plugins,
280
+ cacheDir,
281
+ warn,
282
+ )
255
283
  // Opened as `vx last` opens it: a reader makes no index where there is
256
284
  // none and never resets an earlier schema's (C-5). One it cannot read
257
285
  // serves nothing; the configs evaluate live.
@@ -46,6 +46,7 @@ export {
46
46
  export {
47
47
  findWorkspaceRoot,
48
48
  listProjects,
49
+ namedProject,
49
50
  loadWorkspace,
50
51
  unreachedHint,
51
52
  unreachedPackages,
@@ -54,6 +55,7 @@ export {
54
55
  resolveCacheDir,
55
56
  type ProjectEntry,
56
57
  type ProjectMeta,
58
+ type Workspace,
57
59
  } from './workspace.js'
58
60
  export type { LoadReads } from './load-reads.js'
59
61
  // The migration seam (see migration.ts) and core's own mapper, the scripts one.
@@ -596,3 +596,63 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
596
596
  // all any consumer needs.
597
597
  return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
598
598
  }
599
+
600
+ /**
601
+ * A directory a plugin's `discover` hook names, as a project meta; null
602
+ * when `known` already holds it under that name. `by` names the plugin in
603
+ * a refusal. A directory without a `package.json` is a project by the
604
+ * plugin's name alone.
605
+ */
606
+ export async function namedProject(
607
+ workspace: Workspace,
608
+ known: readonly ProjectMeta[],
609
+ named: { readonly dir: string; readonly name: string },
610
+ by: string,
611
+ ): Promise<ProjectMeta | null> {
612
+ const where = `plugin '${by}' named project`
613
+ if (typeof named?.name !== 'string' || named.name === '' || typeof named.dir !== 'string') {
614
+ throw new UserError(`${where} ${JSON.stringify(named)}: expected { dir: string, name: string }`)
615
+ }
616
+ const dir = path.resolve(workspace.root, named.dir)
617
+ const rel = relPosix(workspace.root, dir)
618
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
619
+ throw new UserError(`${where} "${named.name}" at ${dir}: outside the workspace root`)
620
+ }
621
+ const shown = rel === '' ? '.' : rel
622
+ for (const p of known) {
623
+ if (p.dir === dir && p.name === named.name) return null
624
+ if (p.dir === dir) {
625
+ throw new UserError(`${where} "${named.name}" at ${shown}: already the project "${p.name}"`)
626
+ }
627
+ if (p.name === named.name) {
628
+ throw new UserError(
629
+ `${where} "${named.name}" at ${shown}: the name is taken by ${relPosix(workspace.root, p.dir) || '.'}`,
630
+ )
631
+ }
632
+ }
633
+ const pkgJsonPath = dir + path.sep + 'package.json'
634
+ const [configPath, text] = await Promise.all([
635
+ findConfigFile(dir),
636
+ Bun.file(pkgJsonPath)
637
+ .text()
638
+ .catch((err: unknown) => absent(err, null)),
639
+ ])
640
+ if (
641
+ text === null &&
642
+ !(await stat(dir).then(
643
+ (st) => st.isDirectory(),
644
+ () => false,
645
+ ))
646
+ ) {
647
+ throw new UserError(`${where} "${named.name}" at ${shown}: no such directory`)
648
+ }
649
+ const parsed: Partial<PackageJson> = text === null ? {} : parsePackageJson(text, pkgJsonPath)
650
+ // A nameless manifest names nothing to disagree with (Nx's own projects).
651
+ const pkg: PackageJson = { ...parsed, name: parsed.name ?? named.name }
652
+ if (pkg.name !== named.name) {
653
+ throw new UserError(
654
+ `${where} "${named.name}" at ${shown}: its package.json names it "${pkg.name ?? ''}"`,
655
+ )
656
+ }
657
+ return { name: named.name, dir, packageJson: pkg, configPath }
658
+ }