@vzn/vx 0.0.649 → 0.0.651
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 +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +225 -51
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/fold-memo.ts +8 -1
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +12 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +120 -0
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-hits.ts +9 -1
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/run-notes.ts +123 -0
- package/src/cache/schema.ts +54 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +224 -223
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +10 -3
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +126 -92
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +12 -2
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +39 -2
- package/src/history/index.ts +3 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +12 -3
- package/src/orchestrator/doctor.ts +3 -3
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +3 -0
- package/src/orchestrator/index.ts +3 -1
- package/src/orchestrator/live-usage.ts +98 -0
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +176 -102
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +49 -4
- package/src/orchestrator/prepare.ts +18 -2
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +35 -114
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +89 -26
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/forecast.ts +12 -33
- package/src/report/framed-output.ts +388 -161
- package/src/report/index.ts +8 -2
- package/src/report/logger.ts +100 -36
- package/src/report/status-line.ts +81 -365
- package/src/report/summary.ts +104 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/hidden-prompt.ts +8 -2
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/secrets.ts +3 -0
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/orchestrator/run-steps.ts +0 -93
- package/src/util/colors.ts +0 -130
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cli/select.ts
CHANGED
|
@@ -37,12 +37,13 @@ import {
|
|
|
37
37
|
nearest,
|
|
38
38
|
relPosix,
|
|
39
39
|
UserError,
|
|
40
|
-
sayNotice,
|
|
41
|
-
ui,
|
|
42
40
|
} from '../util/index.js'
|
|
41
|
+
import * as ui from '../ui/index.js'
|
|
42
|
+
import { sayNotice } from '../ui/index.js'
|
|
43
43
|
import {
|
|
44
44
|
claimedAffected,
|
|
45
45
|
claimedExtraFiles,
|
|
46
|
+
claimedDigests,
|
|
46
47
|
fingerprintClaims,
|
|
47
48
|
gitOfDiscovery,
|
|
48
49
|
hasHook,
|
|
@@ -132,6 +133,7 @@ async function workspaceFingerprintClaims(
|
|
|
132
133
|
files: new Set(claims.keys()),
|
|
133
134
|
affected: (change) => claimedAffected(claims.get(change.file)!, change, ctx),
|
|
134
135
|
extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes, ctx),
|
|
136
|
+
digests: (file) => claimedDigests(claims.get(file)!, file, ctx),
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
@@ -705,6 +707,31 @@ function didYouMean(
|
|
|
705
707
|
return best !== undefined ? `. Did you mean tag:${best}?` : `. Tags: ${listed(known)}`
|
|
706
708
|
}
|
|
707
709
|
|
|
710
|
+
/**
|
|
711
|
+
* The patterns a comma list names, or one when it is none. A comma inside
|
|
712
|
+
* a glob's braces is no list (`./packages/{a,b}`), and a braced directory
|
|
713
|
+
* list (Turbo's `{cli,docs}`) is one braced directory each: split at every
|
|
714
|
+
* comma, it hinted `--filter {cli --filter docs}`.
|
|
715
|
+
*/
|
|
716
|
+
function commaList(pattern: string): string[] {
|
|
717
|
+
const braced = pattern.length > 2 && pattern.startsWith('{') && pattern.endsWith('}')
|
|
718
|
+
const body = braced ? pattern.slice(1, -1) : pattern
|
|
719
|
+
const parts: string[] = []
|
|
720
|
+
let depth = 0
|
|
721
|
+
let start = 0
|
|
722
|
+
for (let i = 0; i <= body.length; i++) {
|
|
723
|
+
const c = body[i]
|
|
724
|
+
if (c === '{') depth++
|
|
725
|
+
else if (c === '}') depth--
|
|
726
|
+
else if (i === body.length || (c === ',' && depth === 0)) {
|
|
727
|
+
const part = body.slice(start, i)
|
|
728
|
+
if (part !== '') parts.push(braced ? `{${part}}` : part)
|
|
729
|
+
start = i + 1
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return parts
|
|
733
|
+
}
|
|
734
|
+
|
|
708
735
|
/** `. Did you mean @acme/app?` for the first unmatched pattern within two edits of a project name. */
|
|
709
736
|
function didYouMeanProject(
|
|
710
737
|
unmatched: readonly string[],
|
|
@@ -725,15 +752,26 @@ function didYouMeanProject(
|
|
|
725
752
|
return '. `//` is the workspace root, which is no project here: add a vx.config there and run `vx lock`'
|
|
726
753
|
}
|
|
727
754
|
// Nx's `--projects=a,b`: the edit distance hinted the first name alone.
|
|
728
|
-
const parts = pattern
|
|
755
|
+
const parts = commaList(pattern)
|
|
729
756
|
if (parts.length > 1) {
|
|
730
757
|
return `. A filter is one pattern: ${parts.map((p) => `--filter ${p}`).join(' ')}`
|
|
731
758
|
}
|
|
759
|
+
// The pattern as typed with the name put right: `apq...` hinted `app`,
|
|
760
|
+
// and the run it was copied into dropped the dependencies.
|
|
761
|
+
const meant = (name: string): string => `. Did you mean ${pattern.replace(typed, name)}?`
|
|
762
|
+
// A task id where a project goes (`--filter app#build`): the filter
|
|
763
|
+
// takes the project, and the projects listed beside it hid why.
|
|
764
|
+
const hash = typed.indexOf('#')
|
|
765
|
+
const project = typed.slice(0, Math.max(hash, 0))
|
|
766
|
+
const owner =
|
|
767
|
+
hash <= 0 ? undefined : names.includes(project) ? project : nearest(project, names)
|
|
768
|
+
if (owner !== undefined)
|
|
769
|
+
return `. A filter names projects; did you mean ${pattern.replace(typed, owner)}?`
|
|
732
770
|
const best = nearest(typed, names)
|
|
733
|
-
if (best !== undefined) return
|
|
771
|
+
if (best !== undefined) return meant(best)
|
|
734
772
|
const bare = byBare.has(typed) ? typed : nearest(typed, byBare.keys())
|
|
735
773
|
const scoped = bare === undefined ? undefined : byBare.get(bare)
|
|
736
|
-
if (scoped?.length === 1) return
|
|
774
|
+
if (scoped?.length === 1) return meant(scoped[0]!)
|
|
737
775
|
}
|
|
738
776
|
return names.length === 0 ? '' : `. Projects: ${listed(names)}`
|
|
739
777
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -13,17 +13,15 @@ import { isDefaultBuild } from '../graph/index.js'
|
|
|
13
13
|
import { affectedTaskProjects } from '../orchestrator/index.js'
|
|
14
14
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
15
15
|
import {
|
|
16
|
-
type ColorSupport,
|
|
17
|
-
detectColors,
|
|
18
16
|
listed,
|
|
19
17
|
nearMatches,
|
|
20
18
|
relPosix,
|
|
21
19
|
secretMask,
|
|
22
20
|
type SecretMask,
|
|
23
21
|
UserError,
|
|
24
|
-
sayNotice,
|
|
25
|
-
ui,
|
|
26
22
|
} from '../util/index.js'
|
|
23
|
+
import * as ui from '../ui/index.js'
|
|
24
|
+
import { sayNotice } from '../ui/index.js'
|
|
27
25
|
import { affectedFilterFor, resolveFilters } from './select.js'
|
|
28
26
|
import {
|
|
29
27
|
discoverCliProjects,
|
|
@@ -48,6 +46,8 @@ interface ShowArgs {
|
|
|
48
46
|
filters: string[]
|
|
49
47
|
/** `--affected[=<base>]`: `''` is the default base. */
|
|
50
48
|
affected?: string
|
|
49
|
+
/** The word after a bare `--affected`, read as the target: the base meant, when nothing is named so. */
|
|
50
|
+
spacedBase?: string
|
|
51
51
|
error?: string
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -67,8 +67,11 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
67
67
|
error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
|
|
68
68
|
}
|
|
69
69
|
out.filters.push(v)
|
|
70
|
-
} else if (a === '--affected')
|
|
71
|
-
|
|
70
|
+
} else if (a === '--affected') {
|
|
71
|
+
out.affected = ''
|
|
72
|
+
const next = args[i + 1]
|
|
73
|
+
if (next !== undefined && !next.startsWith('-')) out.spacedBase ??= next
|
|
74
|
+
} else if (a.startsWith('--affected=')) out.affected = a.slice('--affected='.length)
|
|
72
75
|
else if (a.startsWith('-'))
|
|
73
76
|
return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
|
|
74
77
|
else if (out.target === 'project')
|
|
@@ -188,7 +191,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
188
191
|
`no projects: ${EMPTY_LOCK.charAt(0).toLowerCase()}${EMPTY_LOCK.slice(1)}`,
|
|
189
192
|
)
|
|
190
193
|
const shown = metas.filter((m) => inSelection(m.name))
|
|
191
|
-
|
|
194
|
+
emit(renderList(root, shown, projects, parsed.format))
|
|
192
195
|
return 0
|
|
193
196
|
}
|
|
194
197
|
|
|
@@ -206,10 +209,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
206
209
|
if (declares(task)) names.add(t)
|
|
207
210
|
// `nx show projects` lists them; here a bare `vx show` does.
|
|
208
211
|
const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
const unknown = `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`
|
|
213
|
+
const base =
|
|
214
|
+
parsed.spacedBase === projectName
|
|
215
|
+
? `${unknown.endsWith('.') ? '' : '.'} \`--affected\` takes its base with '=': --affected=${projectName}`
|
|
216
|
+
: ''
|
|
217
|
+
throw new UserError(`${unknown}${base}`, 'VX_E_UNKNOWN_TASK')
|
|
213
218
|
}
|
|
214
219
|
let shown = declaring.filter((p) => inSelection(p.name))
|
|
215
220
|
if (byDiff !== undefined) {
|
|
@@ -243,7 +248,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
243
248
|
: `no selected project declares ${projectName} (${n} outside the selection ${n === 1 ? 'does' : 'do'})`,
|
|
244
249
|
)
|
|
245
250
|
}
|
|
246
|
-
|
|
251
|
+
emit(renderTaskAcross(root, shown, projectName!, parsed.format))
|
|
247
252
|
return 0
|
|
248
253
|
}
|
|
249
254
|
|
|
@@ -253,9 +258,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
253
258
|
const dir = projectDir(root, meta)
|
|
254
259
|
|
|
255
260
|
if (taskName === undefined) {
|
|
256
|
-
|
|
257
|
-
renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format, colorsOut()),
|
|
258
|
-
)
|
|
261
|
+
emit(renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format))
|
|
259
262
|
return 0
|
|
260
263
|
}
|
|
261
264
|
|
|
@@ -266,7 +269,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
266
269
|
'VX_E_UNKNOWN_TASK',
|
|
267
270
|
)
|
|
268
271
|
}
|
|
269
|
-
|
|
272
|
+
emit(renderTask(meta.name, dir, taskName, task, parsed.format))
|
|
270
273
|
return 0
|
|
271
274
|
}
|
|
272
275
|
|
|
@@ -339,8 +342,13 @@ function shownConfig(config: ProjectConfig | null): ProjectConfig | null {
|
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
344
|
|
|
342
|
-
|
|
343
|
-
|
|
345
|
+
/** What `vx show` prints: JSON a program reads, or blocks a person does. */
|
|
346
|
+
type Shown = string | readonly ui.Block[]
|
|
347
|
+
|
|
348
|
+
function emit(shown: Shown): void {
|
|
349
|
+
if (typeof shown === 'string') ui.data('out', shown)
|
|
350
|
+
else if (shown.length > 0) ui.print('out', ...shown)
|
|
351
|
+
}
|
|
344
352
|
|
|
345
353
|
/** One block of several, with no blank line between them. */
|
|
346
354
|
const joined = (blocks: readonly ui.Block[]): ui.Block => ({
|
|
@@ -352,8 +360,7 @@ function renderList(
|
|
|
352
360
|
metas: readonly ProjectMeta[],
|
|
353
361
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
354
362
|
format: 'pretty' | 'json',
|
|
355
|
-
|
|
356
|
-
): string {
|
|
363
|
+
): Shown {
|
|
357
364
|
const rows = metas.map((meta) => {
|
|
358
365
|
const tasks = projects.get(meta.name)?.config.tasks ?? {}
|
|
359
366
|
return {
|
|
@@ -368,7 +375,7 @@ function renderList(
|
|
|
368
375
|
if (format === 'json') {
|
|
369
376
|
return `${JSON.stringify(rows.map(({ name, dir, tags, tasks }) => ({ name, dir, tags, tasks })))}\n`
|
|
370
377
|
}
|
|
371
|
-
if (rows.length === 0) return
|
|
378
|
+
if (rows.length === 0) return []
|
|
372
379
|
const anyTags = rows.some((r) => r.tags.length > 0)
|
|
373
380
|
const cells = rows.map((r) => {
|
|
374
381
|
const n = r.tasks.length
|
|
@@ -385,7 +392,7 @@ function renderList(
|
|
|
385
392
|
return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
|
|
386
393
|
})
|
|
387
394
|
const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
|
|
388
|
-
return ui.
|
|
395
|
+
return [ui.section('Projects', String(rows.length), ui.table(columns, cells))]
|
|
389
396
|
}
|
|
390
397
|
|
|
391
398
|
function renderProject(
|
|
@@ -394,8 +401,7 @@ function renderProject(
|
|
|
394
401
|
config: ProjectConfig | null,
|
|
395
402
|
hasConfigFile: boolean,
|
|
396
403
|
format: 'pretty' | 'json',
|
|
397
|
-
|
|
398
|
-
): string {
|
|
404
|
+
): Shown {
|
|
399
405
|
if (format === 'json') {
|
|
400
406
|
// Round-trip so the printed object is exactly the JSON form of the
|
|
401
407
|
// resolved config (drops `undefined` fields).
|
|
@@ -414,7 +420,7 @@ function renderProject(
|
|
|
414
420
|
const blocks = tasks.map(([taskName, task]) =>
|
|
415
421
|
joined([ui.text(ui.id(`${name}#${taskName}`)), ui.fields(taskRows(task))]),
|
|
416
422
|
)
|
|
417
|
-
return
|
|
423
|
+
return [ui.section(name, ui.fields(head), ...blocks)]
|
|
418
424
|
}
|
|
419
425
|
|
|
420
426
|
function renderTask(
|
|
@@ -423,13 +429,12 @@ function renderTask(
|
|
|
423
429
|
taskName: string,
|
|
424
430
|
task: TaskConfig,
|
|
425
431
|
format: 'pretty' | 'json',
|
|
426
|
-
|
|
427
|
-
): string {
|
|
432
|
+
): Shown {
|
|
428
433
|
if (format === 'json') {
|
|
429
434
|
const obj = { name, dir, task: taskName, config: shownTask(task) }
|
|
430
435
|
return `${JSON.stringify(obj)}\n`
|
|
431
436
|
}
|
|
432
|
-
return
|
|
437
|
+
return [taskReport(name, dir, taskName, task)]
|
|
433
438
|
}
|
|
434
439
|
|
|
435
440
|
function renderTaskAcross(
|
|
@@ -437,8 +442,7 @@ function renderTaskAcross(
|
|
|
437
442
|
declaring: readonly ProjectEntry[],
|
|
438
443
|
taskName: string,
|
|
439
444
|
format: 'pretty' | 'json',
|
|
440
|
-
|
|
441
|
-
): string {
|
|
445
|
+
): Shown {
|
|
442
446
|
if (format === 'json') {
|
|
443
447
|
const list = declaring.map((p) => ({
|
|
444
448
|
name: p.name,
|
|
@@ -448,22 +452,14 @@ function renderTaskAcross(
|
|
|
448
452
|
}))
|
|
449
453
|
return `${JSON.stringify(list)}\n`
|
|
450
454
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
[
|
|
454
|
-
joined(
|
|
455
|
-
declaring.map((p) =>
|
|
456
|
-
taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
|
|
457
|
-
),
|
|
458
|
-
),
|
|
459
|
-
],
|
|
460
|
-
colors,
|
|
455
|
+
return declaring.map((p) =>
|
|
456
|
+
taskReport(p.name, relPosix(root, p.dir) || '.', taskName, p.config.tasks![taskName]!),
|
|
461
457
|
)
|
|
462
458
|
}
|
|
463
459
|
|
|
464
|
-
/**
|
|
460
|
+
/** The `app#build` panel: the project's dir, then every field the run reads. */
|
|
465
461
|
function taskReport(name: string, dir: string, taskName: string, task: TaskConfig): ui.Block {
|
|
466
|
-
return ui.
|
|
462
|
+
return ui.section(`${name}#${taskName}`, ui.fields([['dir', dir], ...taskRows(task)]))
|
|
467
463
|
}
|
|
468
464
|
|
|
469
465
|
/** Every field the run reads, in the order the schema declares them. */
|
package/src/cli/task-verb.ts
CHANGED
|
@@ -84,6 +84,28 @@ export async function taskNamesHere(cwd: string): Promise<string[] | null> {
|
|
|
84
84
|
return [...names].sort()
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* "no projects declare task(s): …" for the bare names no project in the
|
|
89
|
+
* workspace declares, or undefined. A diff that matched nothing judged no
|
|
90
|
+
* name, so `vx run tset --affected` exited 0 as "nothing affected"
|
|
91
|
+
* (vercel/turborepo#12268). A workspace that does not load judges none.
|
|
92
|
+
*/
|
|
93
|
+
export async function undeclaredTasks(
|
|
94
|
+
tasks: readonly string[],
|
|
95
|
+
cwd: string,
|
|
96
|
+
): Promise<string | undefined> {
|
|
97
|
+
const projects = await workspaceProjects(cwd)
|
|
98
|
+
if (projects === null) return undefined
|
|
99
|
+
const declared = new Set<string>()
|
|
100
|
+
for (const p of projects.values())
|
|
101
|
+
for (const [t, task] of Object.entries(p.config.tasks ?? {}))
|
|
102
|
+
if (!isDefaultBuild(task)) declared.add(t)
|
|
103
|
+
const missing = tasks.filter((t) => !t.includes('#') && !declared.has(t))
|
|
104
|
+
if (missing.length === 0) return undefined
|
|
105
|
+
const near = [...new Set(missing.map((t) => nearest(t, declared)).filter((t) => t !== undefined))]
|
|
106
|
+
return `no projects declare task(s): ${missing.join(', ')}.${near.length > 0 ? ` Did you mean ${near.join(', ')}?` : ''}`
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
async function workspaceProjects(
|
|
88
110
|
cwd: string,
|
|
89
111
|
): Promise<Awaited<ReturnType<typeof loadCliProjects>> | null> {
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -22,7 +22,9 @@ import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/p
|
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import { flagHint, refuse, seeHelp } from './help.js'
|
|
24
24
|
import { resolveRegistry, type Registry } from './registry.js'
|
|
25
|
-
import { UserError
|
|
25
|
+
import { UserError } from '../util/index.js'
|
|
26
|
+
import * as ui from '../ui/index.js'
|
|
27
|
+
import { sayNotice } from '../ui/index.js'
|
|
26
28
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
27
29
|
import { VERSION } from '../version.js'
|
|
28
30
|
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import
|
|
7
|
+
import * as ui from '../ui/index.js'
|
|
8
8
|
|
|
9
9
|
/** Directories whose changes never trigger a re-run, wherever they appear. */
|
|
10
10
|
export const IGNORED_SEGMENTS = ['node_modules', '.git']
|
|
@@ -493,6 +493,11 @@ export class WatcherPool {
|
|
|
493
493
|
return handle
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
+
/** The watchers open now, each an OS watch or the poller in its slot. */
|
|
497
|
+
get open(): number {
|
|
498
|
+
return this.watchers.filter((w) => w !== CLOSED).length
|
|
499
|
+
}
|
|
500
|
+
|
|
496
501
|
/** Settles once every arm so far has proved delivery or fallen back. */
|
|
497
502
|
async proved(): Promise<void> {
|
|
498
503
|
await Promise.all(this.proofs)
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { filesBehind } from '../cache/index.js'
|
|
8
|
-
import { printable,
|
|
8
|
+
import { printable, xxh3 } from '../util/index.js'
|
|
9
|
+
import * as ui from '../ui/index.js'
|
|
9
10
|
import { gitIgnored, gitSpeller } from './watch-filter.js'
|
|
10
11
|
import { modifiedBefore } from './watch-fs.js'
|
|
11
12
|
|
package/src/cli/watch.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from 'node:fs'
|
|
16
16
|
import path from 'node:path'
|
|
17
|
-
import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
|
|
17
|
+
import { parseRunArgs, resolveRunOptions, type RunArgs, spacedHint } from './run.js'
|
|
18
18
|
import { refuse, seeHelp } from './help.js'
|
|
19
19
|
import {
|
|
20
20
|
fingerprintClaims,
|
|
@@ -45,14 +45,9 @@ import {
|
|
|
45
45
|
import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
|
|
46
46
|
import { ChangeJudge } from './watch-judge.js'
|
|
47
47
|
import { type WatchCycle, watchCycle } from './watch-cycle.js'
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
printable,
|
|
52
|
-
restartTimings,
|
|
53
|
-
sayNotice,
|
|
54
|
-
ui,
|
|
55
|
-
} from '../util/index.js'
|
|
48
|
+
import { hangupIgnored, maskedLine, printable, restartTimings } from '../util/index.js'
|
|
49
|
+
import * as ui from '../ui/index.js'
|
|
50
|
+
import { sayNotice } from '../ui/index.js'
|
|
56
51
|
import { sweepConfigs, watchedProjects } from './watch-set.js'
|
|
57
52
|
import { isCacheHit } from '../graph/index.js'
|
|
58
53
|
import { resolveOutputView } from '../report/index.js'
|
|
@@ -72,7 +67,7 @@ function sayAllCached(opts: RunOptions, outcomes: readonly { status: string }[])
|
|
|
72
67
|
const mode = resolveOutputView(opts).mode
|
|
73
68
|
if (mode !== 'broad' && mode !== 'errors-only' && mode !== 'none') return
|
|
74
69
|
const n = outcomes.length
|
|
75
|
-
|
|
70
|
+
ui.print('out', ui.line(' ', n === 1 ? '1 task' : `${n} tasks`, ' cached, nothing ran'))
|
|
76
71
|
}
|
|
77
72
|
|
|
78
73
|
/** Wait this long after the last filesystem event before re-running. */
|
|
@@ -129,7 +124,8 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
129
124
|
const cwd = process.cwd()
|
|
130
125
|
const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
|
|
131
126
|
if ('error' in resolved) {
|
|
132
|
-
|
|
127
|
+
const hint = resolved.code === 'VX_E_UNKNOWN_TASK' ? await spacedHint(parsed, cwd) : ''
|
|
128
|
+
sayNotice('error', 'vx watch', `${resolved.error}${hint}`)
|
|
133
129
|
return 1
|
|
134
130
|
}
|
|
135
131
|
if ('nothingSelected' in resolved) {
|
|
@@ -166,7 +162,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
166
162
|
// landed, the aborted cycle's summary printed below it.
|
|
167
163
|
const stopped = (): number => {
|
|
168
164
|
if (stop.signal.reason === 'SIGINT') {
|
|
169
|
-
|
|
165
|
+
ui.print('out', ui.gap())
|
|
170
166
|
ui.say('info', 'vx watch: stopped', 'out')
|
|
171
167
|
}
|
|
172
168
|
return 0
|
|
@@ -249,8 +245,13 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
249
245
|
// not (item 1017).
|
|
250
246
|
let refusedToStart = false
|
|
251
247
|
initial.held = runOrchestrator(initial.cycle.opts).then(
|
|
252
|
-
(run) => {
|
|
253
|
-
if (run.refused !== undefined)
|
|
248
|
+
async (run) => {
|
|
249
|
+
if (run.refused !== undefined) {
|
|
250
|
+
const hint = run.refused.startsWith('no projects declare')
|
|
251
|
+
? await spacedHint(parsed, cwd)
|
|
252
|
+
: ''
|
|
253
|
+
sayNotice('error', 'vx watch', `${run.refused}${hint}`)
|
|
254
|
+
}
|
|
254
255
|
sayAllCached(opts, run.outcomes)
|
|
255
256
|
// A run that failed having run nothing refused to start: a requested
|
|
256
257
|
// name no project declares (run() says which, with a "did you mean").
|
|
@@ -558,13 +559,14 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
558
559
|
// cycle ran on with no workspace config, its plugins gone and
|
|
559
560
|
// its tasks named by path.
|
|
560
561
|
if (!hasWorkspaceFile()) {
|
|
561
|
-
|
|
562
|
+
ui.print('out', ui.gap())
|
|
563
|
+
ui.say('info', `changed ${label}`, 'out')
|
|
562
564
|
throw new Error(
|
|
563
565
|
`no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
|
|
564
566
|
)
|
|
565
567
|
}
|
|
566
568
|
if (reread) await rearm(false)
|
|
567
|
-
|
|
569
|
+
ui.print('out', ui.gap())
|
|
568
570
|
ui.say('info', `changed ${label}`, 'out')
|
|
569
571
|
if (!relocked) sayUnlocked(changes.changed)
|
|
570
572
|
if (stop.aborted) break
|
|
@@ -738,6 +740,33 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
738
740
|
}
|
|
739
741
|
}
|
|
740
742
|
const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
|
|
743
|
+
// Bun keeps ONE FSEvents stream for every `fs.watch` of a process and
|
|
744
|
+
// rebuilds it, from "now", on each watch opened or closed: what lands
|
|
745
|
+
// while it is down is lost to every watch, the proved root stream
|
|
746
|
+
// included, and a close proves nothing after it. A re-arm that swapped
|
|
747
|
+
// `apps/`'s arms lost `apps/` made again, and the edit after it, for good
|
|
748
|
+
// (macOS CI, 2026-11-04). So inside the root the one stream serves the
|
|
749
|
+
// ancestor and import arms too, and a re-arm there opens or closes none.
|
|
750
|
+
/** Whether the one stream hears `dir`'s children: under the root by its real path. */
|
|
751
|
+
const inStream = (dir: string): boolean => {
|
|
752
|
+
if (!oneStream) return false
|
|
753
|
+
const rel = path.relative(workspaceRoot, dir)
|
|
754
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
755
|
+
try {
|
|
756
|
+
return fs.realpathSync(dir) === path.join(realRoot, rel)
|
|
757
|
+
} catch {
|
|
758
|
+
return false
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/** The ancestor and import directories the one stream serves (`inStream`). */
|
|
762
|
+
let streamedAncestors = new Set<string>()
|
|
763
|
+
let streamedImports = new Set<string>()
|
|
764
|
+
const heardInStream = (filename: string): void => {
|
|
765
|
+
const abs = path.join(workspaceRoot, filename)
|
|
766
|
+
const dir = path.dirname(abs)
|
|
767
|
+
if (streamedAncestors.has(dir)) ancestorEvent(dir, path.basename(abs))
|
|
768
|
+
if (streamedImports.has(dir)) importEvent(dir, path.basename(abs))
|
|
769
|
+
}
|
|
741
770
|
/** The projects the workspace-wide arm names an event by. */
|
|
742
771
|
let wide: readonly ProjectMeta[] = []
|
|
743
772
|
// The deepest, as a nested project's file is its own.
|
|
@@ -800,6 +829,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
800
829
|
* it from the outer), so each listener decides exactly as its own arm did.
|
|
801
830
|
*/
|
|
802
831
|
const streamEvent = (filename: string): void => {
|
|
832
|
+
heardInStream(filename)
|
|
803
833
|
const rel = filename.split(path.sep).join('/')
|
|
804
834
|
if (!rel.includes('/')) rootFileEvent(rel)
|
|
805
835
|
else if (rel.startsWith('.vx/') && rel.indexOf('/', 4) < 0) dotVxEvent(rel.slice(4))
|
|
@@ -819,6 +849,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
819
849
|
// the rest of the tree; the ignore filter then keeps node_modules
|
|
820
850
|
// / .git / .vx and declared outputs out of what remains.
|
|
821
851
|
arm(workspaceRoot, true, (filename) => {
|
|
852
|
+
if (oneStream) heardInStream(filename)
|
|
822
853
|
if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
|
|
823
854
|
const abs = path.join(workspaceRoot, filename)
|
|
824
855
|
if (
|
|
@@ -900,6 +931,17 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
900
931
|
'out',
|
|
901
932
|
)
|
|
902
933
|
}
|
|
934
|
+
const importEvent = (dir: string, filename: string): void => {
|
|
935
|
+
if (!(importNames.get(dir)?.has(filename) ?? false)) return
|
|
936
|
+
const abs = path.join(dir, filename)
|
|
937
|
+
const rel = path.relative(workspaceRoot, abs)
|
|
938
|
+
if (configImportFiles.includes(abs)) {
|
|
939
|
+
reread = true
|
|
940
|
+
trigger(`config import ${rel}`, abs)
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
sayStaleImport(abs)
|
|
944
|
+
}
|
|
903
945
|
const armImports = (): void => {
|
|
904
946
|
const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
|
|
905
947
|
const want = new Map<string, Set<string>>()
|
|
@@ -910,29 +952,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
910
952
|
want.set(path.dirname(f), names)
|
|
911
953
|
}
|
|
912
954
|
importNames = want
|
|
955
|
+
streamedImports = new Set([...want.keys()].filter(inStream))
|
|
913
956
|
for (const [dir, handle] of importArms) {
|
|
914
|
-
if (want.has(dir) && !stale(dir)) continue
|
|
957
|
+
if (want.has(dir) && !streamedImports.has(dir) && !stale(dir)) continue
|
|
915
958
|
handle.close()
|
|
916
959
|
importArms.delete(dir)
|
|
917
960
|
}
|
|
918
961
|
for (const dir of want.keys()) {
|
|
919
|
-
if (importArms.has(dir)) continue
|
|
962
|
+
if (importArms.has(dir) || streamedImports.has(dir)) continue
|
|
920
963
|
// Gone for now: its ancestor's arm (`armAncestors`) hears it return.
|
|
921
964
|
if (inodeOf(dir) === undefined) continue
|
|
922
965
|
try {
|
|
923
966
|
importArms.set(
|
|
924
967
|
dir,
|
|
925
|
-
arm(dir, false, (filename) =>
|
|
926
|
-
if (!(importNames.get(dir)?.has(filename) ?? false)) return
|
|
927
|
-
const abs = path.join(dir, filename)
|
|
928
|
-
const rel = path.relative(workspaceRoot, abs)
|
|
929
|
-
if (configImportFiles.includes(abs)) {
|
|
930
|
-
reread = true
|
|
931
|
-
trigger(`config import ${rel}`, abs)
|
|
932
|
-
return
|
|
933
|
-
}
|
|
934
|
-
sayStaleImport(abs)
|
|
935
|
-
}),
|
|
968
|
+
arm(dir, false, (filename) => importEvent(dir, filename)),
|
|
936
969
|
)
|
|
937
970
|
armedAs.set(dir, inodeOf(dir) ?? '')
|
|
938
971
|
} catch (err) {
|
|
@@ -1012,7 +1045,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1012
1045
|
}
|
|
1013
1046
|
const debugArms = resolveSetting('debug.watch', undefined).value === true
|
|
1014
1047
|
const sayArmed = (count: number): void => {
|
|
1015
|
-
if (debugArms)
|
|
1048
|
+
if (debugArms)
|
|
1049
|
+
ui.say(
|
|
1050
|
+
'info',
|
|
1051
|
+
`vx watch: armed ${plural(count, 'project')} (${pool.open} watch${pool.open === 1 ? '' : 'es'})`,
|
|
1052
|
+
)
|
|
1016
1053
|
}
|
|
1017
1054
|
|
|
1018
1055
|
const rearm = async (say = true): Promise<void> => {
|
|
@@ -1089,6 +1126,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1089
1126
|
// never heard `packages/` removed under `packages/a` (inotify did).
|
|
1090
1127
|
const ancestorArms = new Map<string, WatchHandle>()
|
|
1091
1128
|
let ancestorNames = new Map<string, Set<string>>()
|
|
1129
|
+
const ancestorEvent = (dir: string, filename: string): void => {
|
|
1130
|
+
// A project's parent's `.gitignore` shapes what its keys read, as the root's does.
|
|
1131
|
+
if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
|
|
1132
|
+
const abs = path.join(dir, filename)
|
|
1133
|
+
// A project directory still the one its arm holds is no change:
|
|
1134
|
+
// its arm judges what is in it. macOS reports the directory's
|
|
1135
|
+
// mtime here on every write at its top level, and a cycle's own
|
|
1136
|
+
// write started the next (solid's `types`, 2026-10-15).
|
|
1137
|
+
if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
|
|
1138
|
+
reread = true
|
|
1139
|
+
trigger(path.relative(workspaceRoot, abs), abs)
|
|
1140
|
+
}
|
|
1092
1141
|
/** Each project directory below the root as these arms first saw it (`inodeOf`). */
|
|
1093
1142
|
let heldDirs = new Map<string, string>()
|
|
1094
1143
|
const armAncestors = (): void => {
|
|
@@ -1119,28 +1168,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
1119
1168
|
dir = path.dirname(dir)
|
|
1120
1169
|
}
|
|
1121
1170
|
}
|
|
1171
|
+
streamedAncestors = new Set([...ancestorNames.keys()].filter(inStream))
|
|
1122
1172
|
for (const [dir, handle] of ancestorArms) {
|
|
1123
|
-
if (ancestorNames.has(dir) && !stale(dir)) continue
|
|
1173
|
+
if (ancestorNames.has(dir) && !streamedAncestors.has(dir) && !stale(dir)) continue
|
|
1124
1174
|
handle.close()
|
|
1125
1175
|
ancestorArms.delete(dir)
|
|
1126
1176
|
}
|
|
1127
1177
|
for (const dir of ancestorNames.keys()) {
|
|
1128
|
-
if (ancestorArms.has(dir)) continue
|
|
1178
|
+
if (ancestorArms.has(dir) || streamedAncestors.has(dir)) continue
|
|
1129
1179
|
try {
|
|
1130
1180
|
ancestorArms.set(
|
|
1131
1181
|
dir,
|
|
1132
|
-
arm(dir, false, (filename) =>
|
|
1133
|
-
// A project's parent's `.gitignore` shapes what its keys read, as the root's does.
|
|
1134
|
-
if (!ancestorNames.get(dir)?.has(filename) && filename !== '.gitignore') return
|
|
1135
|
-
const abs = path.join(dir, filename)
|
|
1136
|
-
// A project directory still the one its arm holds is no change:
|
|
1137
|
-
// its arm judges what is in it. macOS reports the directory's
|
|
1138
|
-
// mtime here on every write at its top level, and a cycle's own
|
|
1139
|
-
// write started the next (solid's `types`, 2026-10-15).
|
|
1140
|
-
if (heldDirs.get(abs) === (inodeOf(abs) ?? '')) return
|
|
1141
|
-
reread = true
|
|
1142
|
-
trigger(path.relative(workspaceRoot, abs), abs)
|
|
1143
|
-
}),
|
|
1182
|
+
arm(dir, false, (filename) => ancestorEvent(dir, filename)),
|
|
1144
1183
|
)
|
|
1145
1184
|
armedAs.set(dir, inodeOf(dir) ?? '')
|
|
1146
1185
|
} catch (err) {
|