@vzn/vx 0.0.492 → 0.0.494

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.492",
3
+ "version": "0.0.494",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.492",
25
- "@vzn/vx-linux-arm64": "0.0.492",
26
- "@vzn/vx-darwin-x64": "0.0.492",
27
- "@vzn/vx-darwin-arm64": "0.0.492"
24
+ "@vzn/vx-linux-x64": "0.0.494",
25
+ "@vzn/vx-linux-arm64": "0.0.494",
26
+ "@vzn/vx-darwin-x64": "0.0.494",
27
+ "@vzn/vx-darwin-arm64": "0.0.494"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -120,10 +120,14 @@ export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
120
120
  export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]> {
121
121
  const ws = await workspacePlugins(cwd).catch(() => null)
122
122
  if (ws === null || 'loadError' in ws) return []
123
- // A verb has one owner: the workspace load refuses two plugins declaring it.
123
+ // A verb has one owner, a package: the workspace load refuses two packages
124
+ // declaring it, and of one package's plugins the first declared runs.
124
125
  const lines: string[] = []
126
+ const seen = new Set<string>()
125
127
  for (const plugin of ws.plugins) {
126
128
  for (const [verb, command] of Object.entries(plugin.commands ?? {})) {
129
+ if (seen.has(verb)) continue
130
+ seen.add(verb)
127
131
  lines.push(` vx ${verb.padEnd(17)} ${command.description} (${plugin.name})`)
128
132
  }
129
133
  }
package/src/cli/select.ts CHANGED
@@ -254,6 +254,16 @@ export async function resolveFilters(
254
254
  return { error: err instanceof Error ? err.message : String(err) }
255
255
  }
256
256
  }
257
+ // A tag lives in the config, so a `tag:` selector needs the staged load
258
+ // (a `project` plugin may have given or edited the tags).
259
+ let tags: Map<string, readonly string[]> | undefined
260
+ if (parsed.some((f) => f.tag === true)) {
261
+ try {
262
+ tags = new Map([...(await stagedOnce()).values()].map((p) => [p.name, p.config.tags ?? []]))
263
+ } catch (err) {
264
+ return { error: err instanceof Error ? err.message : String(err) }
265
+ }
266
+ }
257
267
  const graph = buildPackageGraph(projects, edges)
258
268
  // The graph a run reusing this discovery builds is this one when no task
259
269
  // edge went into it.
@@ -290,6 +300,7 @@ export async function resolveFilters(
290
300
  projects,
291
301
  graph,
292
302
  affectedByFilter,
303
+ ...(tags !== undefined ? { tags } : {}),
293
304
  // A `[<since>]` selector matching nothing is the ordinary "nothing
294
305
  // changed" outcome, reported below; only a name/path pattern that
295
306
  // matched nothing is worth flagging as a probable typo.
@@ -332,14 +343,14 @@ export async function resolveFilters(
332
343
  // the patterns are in the error, and the nearest project name is the
333
344
  // hint a typo needs.
334
345
  return {
335
- error: `no projects matched filter(s): ${raw.join(', ')}${didYouMeanProject(unmatched, projects)}`,
346
+ error: `no projects matched filter(s): ${raw.join(', ')}${didYouMean(unmatched, projects, tags)}`,
336
347
  }
337
348
  }
338
349
  // Something matched, so the run proceeds; a pattern that matched nothing
339
350
  // alongside it is still worth a line — it is probably a typo.
340
351
  for (const f of unmatched)
341
352
  process.stderr.write(
342
- `vx: filter "${f}" matched no projects${didYouMeanProject([f], projects)}\n`,
353
+ `vx: filter "${f}" matched no projects${didYouMean([f], projects, tags)}\n`,
343
354
  )
344
355
  let staged: Map<string, ProjectEntry> | undefined
345
356
  if (stagedPromise !== undefined) {
@@ -447,6 +458,20 @@ export async function pickTask(
447
458
  }
448
459
  }
449
460
 
461
+ /** The hint for the first unmatched pattern: a near tag for a `tag:` one, else a near project name. */
462
+ function didYouMean(
463
+ unmatched: readonly string[],
464
+ projects: Iterable<{ name: string }>,
465
+ tags: ReadonlyMap<string, readonly string[]> | undefined,
466
+ ): string {
467
+ const first = unmatched[0]?.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
468
+ if (first?.startsWith('tag:') !== true) return didYouMeanProject(unmatched, projects)
469
+ const known = [...new Set([...(tags?.values() ?? [])].flat())].sort()
470
+ if (known.length === 0) return '. No project declares tags'
471
+ const best = nearest(first.slice(4), known)
472
+ return best !== undefined ? `. Did you mean tag:${best}?` : `. Tags: ${listed(known)}`
473
+ }
474
+
450
475
  /** `. Did you mean @acme/app?` for the first unmatched pattern within two edits of a project name. */
451
476
  function didYouMeanProject(
452
477
  unmatched: readonly string[],
package/src/cli/show.ts CHANGED
@@ -224,12 +224,13 @@ function renderList(
224
224
  const rows = metas.map((meta) => ({
225
225
  name: meta.name,
226
226
  dir: projectDir(root, meta),
227
+ tags: projects.get(meta.name)?.config.tags ?? [],
227
228
  tasks: Object.keys(projects.get(meta.name)?.config.tasks ?? {}),
228
229
  configured: meta.configPath !== null,
229
230
  }))
230
231
  if (format === 'json') {
231
232
  return `${JSON.stringify(
232
- rows.map(({ name, dir, tasks }) => ({ name, dir, tasks })),
233
+ rows.map(({ name, dir, tags, tasks }) => ({ name, dir, tags, tasks })),
233
234
  null,
234
235
  2,
235
236
  )}\n`
@@ -245,7 +246,8 @@ function renderList(
245
246
  : n > 0
246
247
  ? `${count} (no vx config; from plugins)`
247
248
  : '(no vx config)'
248
- return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}`
249
+ const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : ''
250
+ return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}${tags}`
249
251
  })
250
252
  return `${lines.join('\n')}\n`
251
253
  }
@@ -263,7 +265,8 @@ function renderProject(
263
265
  const shown = shownConfig(config)
264
266
  return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config: shown })), null, 2)}\n`
265
267
  }
266
- const head = `${name} — ${dir}`
268
+ const tags = config?.tags ?? []
269
+ const head = `${name} — ${dir}${tags.length > 0 ? `\n tags: ${tags.join(', ')}` : ''}`
267
270
  const tasks = Object.entries(config?.tasks ?? {})
268
271
  if (tasks.length === 0)
269
272
  return `${head}\n ${hasConfigFile ? '(no tasks declared)' : '(no vx config)'}\n`
package/src/config.ts CHANGED
@@ -130,6 +130,11 @@ const _pluginHooksMatch: [HooksNotListed, HooksNotOnPlugin] extends [never, neve
130
130
  void _pluginHooksMatch
131
131
 
132
132
  export interface ProjectConfig {
133
+ /**
134
+ * Labels for selection: `--filter tag:<name>` picks the projects that
135
+ * carry one. Not in any cache key: a tag changes no task's behaviour.
136
+ */
137
+ tags?: readonly string[]
133
138
  /** Tasks declared by this project, keyed by task name. */
134
139
  tasks?: Record<string, TaskConfig>
135
140
  }
package/src/util/verbs.ts CHANGED
@@ -29,7 +29,7 @@ export const MOVED_VERBS: Readonly<Record<string, string>> = {
29
29
  'vx migrate moved to @vzn/vx-migrate: run `bunx @vzn/vx-migrate` (turbo.json or an Nx ' +
30
30
  'project graph → vx.config.ts); `vx init` reads package.json scripts',
31
31
  prune:
32
- 'vx prune was removed (2026-09-11): the Docker-subset verb is not shipped; copy the ' +
33
- 'workspace and `--filter` the build instead',
32
+ 'vx prune comes from @vzn/vx-lockfile: declare pnpm(), bun(), npm() or yarn() in ' +
33
+ 'vx.workspace.ts',
34
34
  stats: 'vx stats was removed (H-19): run `vx info`, which prints the same report',
35
35
  }
@@ -204,14 +204,16 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
204
204
  // dead: core verbs are matched first, and two plugins on one verb
205
205
  // would run the first declared and hide the second. Here, in the
206
206
  // schema, so every loader of the file — a run, a reading verb, the
207
- // plugin-verb lookup — refuses it the same way.
207
+ // plugin-verb lookup — refuses it the same way. The owner is the
208
+ // package: two plugins one package exports (`bun()` and `pnpm()`
209
+ // both carry `prune`) are one owner, and the first declared runs.
208
210
  if ((CORE_VERBS as readonly string[]).includes(verb)) {
209
211
  throw new UserError(
210
212
  `${configPath}: plugin '${plug.name}' declares command '${verb}', a core verb — core verbs cannot be shadowed`,
211
213
  )
212
214
  }
213
215
  const owner = verbOwners.get(verb)
214
- if (owner !== undefined) {
216
+ if (owner !== undefined && owner !== plug.name) {
215
217
  throw new UserError(
216
218
  `${configPath}: plugins '${owner}' and '${plug.name}' both declare command '${verb}' — a verb has one owner`,
217
219
  )
@@ -287,6 +289,13 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
287
289
  // The top level too: `task:` (singular) loaded as a project with no
288
290
  // tasks and every request against it said "no projects declare".
289
291
  assertKnownFields(config, PROJECT_FIELDS, configPath)
292
+ const tags: unknown = config.tags
293
+ if (
294
+ tags !== undefined &&
295
+ (!Array.isArray(tags) || tags.some((t) => typeof t !== 'string' || t.trim() === ''))
296
+ ) {
297
+ throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
298
+ }
290
299
  const tasks = config.tasks
291
300
  if (tasks === undefined) return
292
301
  if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
@@ -728,7 +737,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
728
737
  // discarded, so the task hashes as if the field were never written and vx
729
738
  // serves a stale artifact — the same reasoning `sandbox` already
730
739
  // encodes. A new field must be added here deliberately.
731
- const PROJECT_FIELDS = new Set(['tasks'])
740
+ // `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
741
+ // from both.
742
+ const PROJECT_FIELDS = new Set(['tasks', 'tags'])
732
743
  const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'cache'])
733
744
  const EXEC_FIELDS = new Set([
734
745
  'command',
@@ -867,7 +878,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
867
878
  },
868
879
  ],
869
880
  // A package's turbo.json and Nx's project.json keys, written into a
870
- // project's vx.config (D-56). `tags` had read as a typo of `tasks`.
881
+ // project's vx.config (D-56).
871
882
  [
872
883
  PROJECT_FIELDS,
873
884
  {
@@ -875,7 +886,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
875
886
  extends: 'an imported module spread into `tasks` (a vx.config is code; nothing is inherited)',
876
887
  implicitDependencies: 'a `dependsOn` entry `pkg#task`, or a package.json dependency',
877
888
  name: 'the package.json `name` (a project is named by its package)',
878
- tags: '`--filter` (a name glob or a directory) to select projects',
879
889
  // A package.json's, turbo.json's and project.json's keys, and a
880
890
  // task's fields one level too high (D-99).
881
891
  scripts:
@@ -4,6 +4,7 @@
4
4
  // ./<dir> the package at <dir>, else the packages under it (relative to workspace root)
5
5
  // {<dir>} same as ./<dir>
6
6
  // // the workspace-root project only (Turbo's name for the root)
7
+ // tag:<pattern> the projects whose config `tags` hold a match (Nx's `tag:`)
7
8
  // <pattern>... pattern + its transitive workspace dependencies
8
9
  // ...<pattern> pattern + its transitive workspace dependents
9
10
  // <pattern>^... only the transitive deps of pattern (excluding the matched package)
@@ -55,6 +56,8 @@ export interface ParsedFilter {
55
56
  pathRoot?: string
56
57
  /** `//`: the project at `matcher` itself, never the ones under it. */
57
58
  exactDir?: true
59
+ /** `tag:<pattern>`: `matcher` is a glob over the projects' tags, not their names. */
60
+ tag?: true
58
61
  }
59
62
 
60
63
  export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
@@ -141,6 +144,24 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
141
144
  }
142
145
  }
143
146
 
147
+ if (s.startsWith('tag:')) {
148
+ const tag = s.slice(4)
149
+ if (tag === '') throw new UserError(`filter "${raw}" names no tag`)
150
+ return {
151
+ raw,
152
+ negate,
153
+ withDeps,
154
+ withDependents,
155
+ onlyDeps,
156
+ onlyDependents,
157
+ isPath: false,
158
+ matcher: tag,
159
+ tag: true,
160
+ ...(gitSince !== undefined ? { gitSince } : {}),
161
+ ...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
162
+ }
163
+ }
164
+
144
165
  let isPath = false
145
166
  let matcher = s
146
167
  let pathGlob: Bun.Glob | undefined
@@ -187,6 +208,7 @@ function matchProjects(
187
208
  projects: ProjectMeta[],
188
209
  affectedByFilter: Map<ParsedFilter, Set<string>> | undefined,
189
210
  graph: PackageGraph,
211
+ tags: ProjectTags | undefined,
190
212
  ): string[] {
191
213
  // `[<since>]` selectors are pre-resolved by the caller (the parser
192
214
  // is pure; git access happens upstream). Use the provided set as
@@ -199,14 +221,24 @@ function matchProjects(
199
221
  for (const d of graph.transitiveDependents(name)) changed.add(d)
200
222
  }
201
223
  if (filter.matcher === '') return [...changed]
202
- return matchSelector(filter, projects).filter((name) => changed.has(name))
224
+ return matchSelector(filter, projects, tags).filter((name) => changed.has(name))
203
225
  }
204
- return matchSelector(filter, projects)
226
+ return matchSelector(filter, projects, tags)
205
227
  }
206
228
 
207
- /** The projects a name or path selector names, git ranges aside. */
208
- function matchSelector(filter: ParsedFilter, projects: ProjectMeta[]): string[] {
229
+ /** The projects a name, path or tag selector names, git ranges aside. */
230
+ function matchSelector(
231
+ filter: ParsedFilter,
232
+ projects: ProjectMeta[],
233
+ tags: ProjectTags | undefined,
234
+ ): string[] {
209
235
  const out: string[] = []
236
+ if (filter.tag === true) {
237
+ const re = compileNameGlob(filter.matcher)
238
+ for (const p of projects)
239
+ if (tags?.get(p.name)?.some((t) => re.test(t)) === true) out.push(p.name)
240
+ return out
241
+ }
210
242
  if (filter.isPath) {
211
243
  // A path naming a project's own directory is that project, as Turbo and
212
244
  // pnpm read it: `.` is the root project, never every project under the
@@ -268,10 +300,15 @@ function compileNameGlob(pattern: string): RegExp {
268
300
  return new RegExp(`^${escaped}$`)
269
301
  }
270
302
 
303
+ /** Each project's config `tags`, by project name. */
304
+ type ProjectTags = ReadonlyMap<string, readonly string[]>
305
+
271
306
  export interface ApplyFiltersOptions {
272
307
  filters: ParsedFilter[]
273
308
  projects: ProjectMeta[]
274
309
  graph: PackageGraph
310
+ /** Read by `tag:` selectors; a project absent here carries no tag. */
311
+ tags?: ProjectTags
275
312
  /**
276
313
  * Pre-resolved affected-project sets for each `[<since>]` filter.
277
314
  * The caller (CLI / programmatic embedder) runs the git work and
@@ -304,7 +341,7 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
304
341
  // taken in argv order, so `onNoMatch` names the filters as typed.
305
342
  const excludes: Set<string>[] = []
306
343
  for (const f of opts.filters) {
307
- const matched = matchProjects(f, opts.projects, opts.affectedByFilter, opts.graph)
344
+ const matched = matchProjects(f, opts.projects, opts.affectedByFilter, opts.graph, opts.tags)
308
345
  if (matched.length === 0) opts.onNoMatch?.(f)
309
346
  const expanded = new Set<string>()
310
347
  for (const name of matched) {
@@ -203,6 +203,8 @@ export interface GeneratedProject {
203
203
  /** absolute project dir */
204
204
  dir: string
205
205
  importLines: string[]
206
+ /** The project's `tags` (an Nx project's); none written when empty. */
207
+ tags?: readonly string[]
206
208
  tasks: GeneratedTask[]
207
209
  }
208
210
 
@@ -599,7 +601,9 @@ function renderConfigFile(
599
601
  ]
600
602
  if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx/config'")
601
603
  if (p.importLines.length > 0) lines.push(...p.importLines)
602
- lines.push('', 'export default {', ' tasks: {')
604
+ lines.push('', 'export default {')
605
+ if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
606
+ lines.push(' tasks: {')
603
607
  for (const t of p.tasks) {
604
608
  // A reason can quote a manifest's own text (an Nx `env` key), and a
605
609
  // line break there ended the comment: the rest was code in the file