@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 +5 -5
- package/src/cli/plugin-commands.ts +5 -1
- package/src/cli/select.ts +27 -2
- package/src/cli/show.ts +6 -3
- package/src/config.ts +5 -0
- package/src/util/verbs.ts +2 -2
- package/src/workspace/config-schema.ts +15 -5
- package/src/workspace/filter.ts +42 -5
- package/src/workspace/migration.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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
|
|
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(', ')}${
|
|
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${
|
|
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
|
-
|
|
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
|
|
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
|
|
33
|
-
'workspace
|
|
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
|
-
|
|
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).
|
|
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:
|
package/src/workspace/filter.ts
CHANGED
|
@@ -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
|
|
208
|
-
function matchSelector(
|
|
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 {'
|
|
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
|