@vzn/vx 0.0.632 → 0.0.634
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 +14 -10
- package/docs/caching.md +2250 -0
- package/docs/cli.md +3283 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +174 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2151 -0
- package/docs/security.md +209 -0
- package/package.json +12 -5
- package/schemas/docs.json +28 -0
- package/schemas/error.json +5 -1
- package/schemas/init.json +57 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +4 -0
- package/skills/vx/SKILL.md +13 -3
- package/src/cache/cache.ts +556 -122
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +71 -30
- package/src/cache/layer.ts +8 -4
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +42 -16
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +173 -0
- package/src/cli/help.ts +14 -3
- package/src/cli/index.ts +2 -0
- package/src/cli/init.ts +44 -8
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +3 -1
- package/src/cli/run.ts +8 -2
- package/src/cli/upgrade.ts +18 -6
- package/src/config.ts +9 -0
- package/src/graph/priorities.ts +72 -6
- package/src/graph/scheduler.ts +17 -4
- package/src/graph/task-graph.ts +56 -18
- package/src/orchestrator/metrics.ts +11 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/remote-prefetch.ts +8 -2
- package/src/orchestrator/run.ts +13 -1
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/util/errors.ts +9 -2
- package/src/util/paths.ts +32 -0
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +26 -8
- package/src/workspace/lockfile.ts +29 -2
- package/src/workspace/migration.ts +30 -3
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Paid only when a telemetry sink exists, and only on a miss: one scan of
|
|
6
6
|
// `entries` for the newest entry per task, the first time a miss asks, then
|
|
7
|
-
// one primary-key read of that entry's `entry_inputs` per miss.
|
|
7
|
+
// one primary-key read of that entry's `entry_inputs` row per miss.
|
|
8
8
|
|
|
9
9
|
import type { Database } from 'bun:sqlite'
|
|
10
10
|
import type { InputChanges } from '../graph/index.js'
|
|
11
|
-
import { diffKeyComponents } from './metrics.js'
|
|
11
|
+
import { diffKeyComponents, loadEntryInputs } from './metrics.js'
|
|
12
12
|
import type { TaskInputComponent } from './task-hash.js'
|
|
13
13
|
|
|
14
14
|
/** How many changed components a miss names; `count` says how many there were. */
|
|
@@ -37,9 +37,7 @@ export function createMissExplainer(db: Database): MissExplainer {
|
|
|
37
37
|
)
|
|
38
38
|
const prev = newest.get(taskId)
|
|
39
39
|
if (prev === undefined) return undefined
|
|
40
|
-
const before = db
|
|
41
|
-
.query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
|
|
42
|
-
.all(prev) as TaskInputComponent[]
|
|
40
|
+
const before = loadEntryInputs(db, prev)
|
|
43
41
|
// Pruned, or saved before fingerprints were kept: nothing to compare.
|
|
44
42
|
if (before.length === 0) return undefined
|
|
45
43
|
const { entries } = diffKeyComponents(before, components)
|
|
@@ -191,11 +191,12 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
191
191
|
endSave()
|
|
192
192
|
if (a.measure === true) {
|
|
193
193
|
facts.saveMs = Math.round(performance.now() - saveStart)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
// No local artifact (local writes off): no size to say.
|
|
195
|
+
const size =
|
|
196
|
+
cache.artifactSize !== undefined
|
|
197
|
+
? cache.artifactSize(a.hash)
|
|
198
|
+
: statSync(cache.outputsPath(a.hash), { throwIfNoEntry: false })?.size
|
|
199
|
+
if (size !== undefined) facts.artifactBytes = size
|
|
199
200
|
}
|
|
200
201
|
// The files the task just wrote are the entry's bytes: stamp them,
|
|
201
202
|
// so the next hit can tell them from another entry's with the same
|
|
@@ -328,8 +328,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
328
328
|
* one (owner, 2026-10-04): a group behind `^build`, keyed on every file of
|
|
329
329
|
* the project. A package consumed as source then carries its files to every
|
|
330
330
|
* dependent behind `^build`, in the key and in `--affected`, and the chain
|
|
331
|
-
* through it to deeper builds holds.
|
|
332
|
-
* declare
|
|
331
|
+
* through it to deeper builds holds. A keyed group, as a config may
|
|
332
|
+
* declare one (config-schema.ts).
|
|
333
333
|
*/
|
|
334
334
|
const DEFAULT_BUILD: TaskConfig = Object.freeze({
|
|
335
335
|
dependsOn: Object.freeze(['^build']),
|
|
@@ -113,11 +113,17 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
|
|
|
113
113
|
*/
|
|
114
114
|
export function getContext(node: TaskNode, command: string) {
|
|
115
115
|
const outputs = node.config.cache?.outputs
|
|
116
|
+
let masked: string | undefined
|
|
116
117
|
return {
|
|
117
118
|
taskId: node.id,
|
|
118
119
|
// Stored in the entry row a remote hit inserts: masked as a save's is,
|
|
119
|
-
// or the token a config inlined sat in cache.db (L-27).
|
|
120
|
-
|
|
120
|
+
// or the token a config inlined sat in cache.db (L-27). Masked on the
|
|
121
|
+
// first read: only an adopt or a remote hit reads it, and masking every
|
|
122
|
+
// lookup's command cost a cold 1,090-package run ~20 ms before its
|
|
123
|
+
// first task.
|
|
124
|
+
get command() {
|
|
125
|
+
return (masked ??= maskedCommand(command, node.config.exec?.env))
|
|
126
|
+
},
|
|
121
127
|
outputs: { files: outputs?.files ?? [], workspaceFiles: outputs?.workspaceFiles ?? [] },
|
|
122
128
|
}
|
|
123
129
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -945,7 +945,15 @@ async function runOnBus(
|
|
|
945
945
|
// shouldShortCircuit (local reads on, no remote layer); when off, both
|
|
946
946
|
// maps are empty and the run is byte-identical.
|
|
947
947
|
let shortCircuit: ShortCircuit = emptyShortCircuit()
|
|
948
|
-
|
|
948
|
+
// An empty local cache answers every probe with a miss: each task
|
|
949
|
+
// derives its key as it starts instead (~100 ms before a cold 1,090-
|
|
950
|
+
// package run's first spawn), and every sandboxed task runs.
|
|
951
|
+
const coldLocal =
|
|
952
|
+
cache === prepared.localCache &&
|
|
953
|
+
shouldShortCircuit(nodes, policy, cache) &&
|
|
954
|
+
prepared.localCache.holdsNothing()
|
|
955
|
+
if (coldLocal) prewarm()
|
|
956
|
+
else if (shouldShortCircuit(nodes, policy, cache)) {
|
|
949
957
|
shortCircuit = await startLocalShortCircuit({
|
|
950
958
|
nodes,
|
|
951
959
|
cache,
|
|
@@ -1165,6 +1173,10 @@ async function runOnBus(
|
|
|
1165
1173
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1166
1174
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1167
1175
|
...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
|
|
1176
|
+
// The baseline under them; `most-work` when the workspace names none.
|
|
1177
|
+
...(prepared.workspaceConfig?.schedule !== undefined
|
|
1178
|
+
? { strategy: prepared.workspaceConfig.schedule }
|
|
1179
|
+
: {}),
|
|
1168
1180
|
// Local short-circuit: confirmed stable local hits the scheduler
|
|
1169
1181
|
// runs ahead of their deps as low-priority worker-slot backfill.
|
|
1170
1182
|
// Empty when the short-circuit didn't fire → byte-identical.
|
|
@@ -411,8 +411,8 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
411
411
|
* in place. Its one behavioural case is a config with NO `exec`, where
|
|
412
412
|
* the spread would add `exec: {}` — and nothing reaches here that way,
|
|
413
413
|
* proven by throwing on a no-exec config and running the whole suite:
|
|
414
|
-
* a group takes `computeGroupKey`, and
|
|
415
|
-
* `
|
|
414
|
+
* a group takes `computeGroupKey`, and a keyed group has no `exec`, so no
|
|
415
|
+
* `remote`. `timeout`/`retries` stay folded — their keys are
|
|
416
416
|
* distinct by design (see the decision log); stripping them retroactively
|
|
417
417
|
* would bump CACHE_VERSION.
|
|
418
418
|
*/
|
|
@@ -423,8 +423,8 @@ function hashableConfig(cfg: TaskConfig): unknown {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
/**
|
|
426
|
-
* A group's key: its upstream, and for a keyed group —
|
|
427
|
-
* `build` (projects.ts)
|
|
426
|
+
* A group's key: its upstream, and for a keyed group — one with `cache`,
|
|
427
|
+
* the default `build` (projects.ts) or a config's — its own inputs too,
|
|
428
428
|
* the way a task's key folds them.
|
|
429
429
|
*/
|
|
430
430
|
export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
|
package/src/util/errors.ts
CHANGED
|
@@ -62,9 +62,16 @@ export function wantsJson(argv: readonly string[]): boolean {
|
|
|
62
62
|
return false
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* The JSON a verb that answers JSON prints for a refusal: one line. A
|
|
67
|
+
* core code (`VX_E_…`) links its section of cli.md, which says the fix;
|
|
68
|
+
* `vx docs <code>` prints the same section offline. A plugin's code has none.
|
|
69
|
+
*/
|
|
66
70
|
export function errorDocument(code: string, message: string): string {
|
|
67
|
-
|
|
71
|
+
const docs = code.startsWith('VX_E_')
|
|
72
|
+
? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
|
|
73
|
+
: {}
|
|
74
|
+
return `${JSON.stringify({ ok: false, error: { code, message, ...docs } })}\n`
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
/**
|
package/src/util/paths.ts
CHANGED
|
@@ -195,6 +195,30 @@ export const EXTGLOB = /[!@+*?]\(/
|
|
|
195
195
|
* else: one site that forgot is a route directory that keys nothing.
|
|
196
196
|
*/
|
|
197
197
|
export function taskGlob(pattern: string): Bun.Glob {
|
|
198
|
+
const memo = memoFor(taskGlobs)
|
|
199
|
+
let glob = memo.get(pattern)
|
|
200
|
+
if (glob === undefined) memo.set(pattern, (glob = compileTaskGlob(pattern)))
|
|
201
|
+
return glob
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Compiled once per pattern: a Turbo-mapped config repeats one 400-entry
|
|
206
|
+
* list across two thousand tasks, and the graph's overlap checks compiled
|
|
207
|
+
* each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
|
|
208
|
+
* comes from a config, so the set is bounded by what the configs say. Kept
|
|
209
|
+
* per `Bun.Glob` in force: the playground and a test swap it, and a glob
|
|
210
|
+
* built by one is not the other's.
|
|
211
|
+
*/
|
|
212
|
+
const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
|
|
213
|
+
const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
|
|
214
|
+
|
|
215
|
+
function memoFor<T>(memos: WeakMap<object, Map<string, T>>): Map<string, T> {
|
|
216
|
+
let memo = memos.get(Bun.Glob)
|
|
217
|
+
if (memo === undefined) memos.set(Bun.Glob, (memo = new Map()))
|
|
218
|
+
return memo
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function compileTaskGlob(pattern: string): Bun.Glob {
|
|
198
222
|
// A `]` with no `[` before it is already literal to `Bun.Glob`.
|
|
199
223
|
const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
|
|
200
224
|
const glob = new Bun.Glob(source)
|
|
@@ -228,6 +252,14 @@ export function taskGlob(pattern: string): Bun.Glob {
|
|
|
228
252
|
* `src/a.ts` and never `src/deep/a.ts`, and an edit there was a stale hit.
|
|
229
253
|
*/
|
|
230
254
|
export function anyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
|
|
255
|
+
const memo = memoFor(anyTaskGlobs)
|
|
256
|
+
const key = rawPatterns.join('\0')
|
|
257
|
+
let any = memo.get(key)
|
|
258
|
+
if (any === undefined) memo.set(key, (any = compileAnyTaskGlob(rawPatterns)))
|
|
259
|
+
return any
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function compileAnyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
|
|
231
263
|
const patterns = rawPatterns.flatMap(braceAlternatives)
|
|
232
264
|
if (patterns.length === 0) return () => false
|
|
233
265
|
const sources = patterns.map(regExpSource)
|
package/src/util/verbs.ts
CHANGED
|
@@ -56,9 +56,18 @@ const WORKSPACE_FIELDS = new Set([
|
|
|
56
56
|
'affectedBase',
|
|
57
57
|
'cacheScope',
|
|
58
58
|
'rules',
|
|
59
|
+
'schedule',
|
|
59
60
|
'plugins',
|
|
60
61
|
])
|
|
61
62
|
|
|
63
|
+
type Schedule = NonNullable<WorkspaceConfig['schedule']>
|
|
64
|
+
const SCHEDULES = {
|
|
65
|
+
'most-work': true,
|
|
66
|
+
'critical-path': true,
|
|
67
|
+
'direct-dependents': true,
|
|
68
|
+
'ready-order': true,
|
|
69
|
+
} satisfies Record<Schedule, true>
|
|
70
|
+
|
|
62
71
|
const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
|
|
63
72
|
|
|
64
73
|
function validateRules(rules: unknown, configPath: string): void {
|
|
@@ -184,6 +193,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
184
193
|
throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
|
|
185
194
|
}
|
|
186
195
|
if (config.rules !== undefined) validateRules(config.rules, configPath)
|
|
196
|
+
if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
|
|
197
|
+
throw new UserError(
|
|
198
|
+
`${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
|
|
199
|
+
.map((k) => `'${k}'`)
|
|
200
|
+
.join(', ')}`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
187
203
|
if (config.plugins !== undefined) {
|
|
188
204
|
if (!Array.isArray(config.plugins)) {
|
|
189
205
|
throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
|
|
@@ -616,15 +632,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
616
632
|
// so a dependant's `^build` finds it and waits on nothing
|
|
617
633
|
// (the quickstart). The comment here used to call that case
|
|
618
634
|
// "almost certainly a config mistake" while the docs taught it.
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
`(group tasks exist to chain dependencies)`,
|
|
623
|
-
)
|
|
624
|
-
}
|
|
625
|
-
if (cache !== undefined) {
|
|
635
|
+
// A keyed group (`cache` with no `exec`) is a declaration on its own:
|
|
636
|
+
// its inputs are a key that dependants fold, with no process.
|
|
637
|
+
if (dependsOn === undefined && cache === undefined) {
|
|
626
638
|
throw new UserError(
|
|
627
|
-
`${where}:
|
|
639
|
+
`${where}: a task with no \`exec\` must declare \`dependsOn\` or \`cache.inputs\` ` +
|
|
640
|
+
`(group tasks exist to chain dependencies or carry a key)`,
|
|
628
641
|
)
|
|
629
642
|
}
|
|
630
643
|
}
|
|
@@ -852,6 +865,11 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
852
865
|
if (wsOutputs !== undefined) {
|
|
853
866
|
validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
|
|
854
867
|
}
|
|
868
|
+
if (exec === undefined && (outFiles.length > 0 || wsOutputs !== undefined)) {
|
|
869
|
+
throw new UserError(
|
|
870
|
+
`${where}.cache.outputs: a task with no \`exec\` writes nothing — \`outputs: { files: [] }\``,
|
|
871
|
+
)
|
|
872
|
+
}
|
|
855
873
|
}
|
|
856
874
|
}
|
|
857
875
|
}
|
|
@@ -36,6 +36,8 @@ export interface LockfileEntry {
|
|
|
36
36
|
export interface Lockfile {
|
|
37
37
|
version: number
|
|
38
38
|
projects: Record<string, LockfileEntry>
|
|
39
|
+
/** Written by `writeLockfile` from the bytes; never read off the object. */
|
|
40
|
+
validated?: string
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function lockfilePath(root: string): string {
|
|
@@ -100,12 +102,33 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
lockDigests.set(parsed, xxh3(text).toString(16))
|
|
105
|
+
const stamped = VALIDATED_LINE.exec(text)
|
|
106
|
+
if (stamped !== null && stamped[1] === validatedDigest(text.slice(0, stamped.index) + '\n}')) {
|
|
107
|
+
selfValidated.add(parsed)
|
|
108
|
+
}
|
|
103
109
|
return parsed as Lockfile
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
/** The digest of the bytes each lock `readLockfile` returned was parsed from. */
|
|
107
113
|
const lockDigests = new WeakMap<object, string>()
|
|
108
114
|
|
|
115
|
+
/**
|
|
116
|
+
* The lock's own record that this vx validated its entries: `vx lock`
|
|
117
|
+
* evaluates and validates every config, then writes a digest of the bytes
|
|
118
|
+
* it wrote, under vx's version (validation's rules are vx's alone, so Bun's
|
|
119
|
+
* is left out and does not churn a committed lock), as the last field. A lock
|
|
120
|
+
* whose digest matches is validated already, and a cold `--frozen` run
|
|
121
|
+
* skips the ~30 ms of validating 1,090 entries before its first task. An
|
|
122
|
+
* edit, a merge, or another vx version leaves it unmatched, and the
|
|
123
|
+
* entries are validated as before.
|
|
124
|
+
*/
|
|
125
|
+
const VALIDATED_LINE = /,\n {2}"validated": "([0-9a-f]+)"\n\}\n?$/
|
|
126
|
+
const selfValidated = new WeakSet<object>()
|
|
127
|
+
|
|
128
|
+
function validatedDigest(body: string): string {
|
|
129
|
+
return xxh3(`vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${body}`).toString(16)
|
|
130
|
+
}
|
|
131
|
+
|
|
109
132
|
export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
|
|
110
133
|
// Written beside its name and renamed over it: `Bun.write` truncates in
|
|
111
134
|
// place, so a `--frozen` run, or `vx watch --frozen` woken by the write
|
|
@@ -114,7 +137,8 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
|
|
|
114
137
|
const file = lockfilePath(root)
|
|
115
138
|
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
116
139
|
try {
|
|
117
|
-
|
|
140
|
+
const body = JSON.stringify({ version: lock.version, projects: lock.projects }, null, 2)
|
|
141
|
+
await Bun.write(tmp, `${body.slice(0, -2)},\n "validated": "${validatedDigest(body)}"\n}\n`)
|
|
118
142
|
await rename(tmp, file)
|
|
119
143
|
} catch (err) {
|
|
120
144
|
// Best effort: where the write was refused the temp cannot be named
|
|
@@ -187,7 +211,10 @@ export async function frozenProjectConfigs(
|
|
|
187
211
|
// asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
|
|
188
212
|
// config load, where the eval-cache path it replaces is cheaper (X-181).
|
|
189
213
|
// A scoped run still validates, and remembers, only what it loads.
|
|
190
|
-
if (
|
|
214
|
+
if (
|
|
215
|
+
selfValidated.has(lock) ||
|
|
216
|
+
(prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null)
|
|
217
|
+
) {
|
|
191
218
|
return entries.map((e) => e.config)
|
|
192
219
|
}
|
|
193
220
|
const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)
|
|
@@ -261,6 +261,11 @@ export interface ApplyMigrationArgs {
|
|
|
261
261
|
* and compiled the config into its dist (TanStack/query, 2026-09-11).
|
|
262
262
|
*/
|
|
263
263
|
format?: MigrationFormat
|
|
264
|
+
/**
|
|
265
|
+
* With `dry`: one JSON document on stdout (`schemas/init.json`) in place
|
|
266
|
+
* of the files and the report, so an agent reads the plan as data.
|
|
267
|
+
*/
|
|
268
|
+
json?: boolean
|
|
264
269
|
}
|
|
265
270
|
|
|
266
271
|
export type MigrationFormat = 'ts' | 'mjs'
|
|
@@ -360,11 +365,11 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
360
365
|
const existing = metas.find((m) => m.dir === p.dir)?.configPath
|
|
361
366
|
if (existing && existing !== path.join(p.dir, configName)) replaced.push(existing)
|
|
362
367
|
}
|
|
363
|
-
if (dry) {
|
|
368
|
+
if (dry && args.json !== true) {
|
|
364
369
|
for (const f of files) {
|
|
365
370
|
process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
|
|
366
371
|
}
|
|
367
|
-
} else {
|
|
372
|
+
} else if (!dry) {
|
|
368
373
|
for (const f of files) await Bun.write(f.abs, f.contents)
|
|
369
374
|
for (const f of replaced) await unlink(f)
|
|
370
375
|
}
|
|
@@ -476,7 +481,29 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
476
481
|
...(inGitWorkTree(root) ? [] : ['git init']),
|
|
477
482
|
...(empty ? [`declare a task as the example shows`] : []),
|
|
478
483
|
]
|
|
479
|
-
|
|
484
|
+
const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
|
|
485
|
+
if (args.json === true) {
|
|
486
|
+
process.stdout.write(
|
|
487
|
+
`${JSON.stringify({
|
|
488
|
+
dry,
|
|
489
|
+
source,
|
|
490
|
+
files: files.map((f) => ({ path: f.relPath, contents: f.contents })),
|
|
491
|
+
kept,
|
|
492
|
+
replaced: replaced.map((f) => relPosix(root, f)),
|
|
493
|
+
todos: [...todos].map(([reason, tasks]) => ({ reason, tasks })),
|
|
494
|
+
notes: [
|
|
495
|
+
...(empty && unreached.length > 0 ? [unreachedHint(unreached, root)] : []),
|
|
496
|
+
...(empty ? noMembers : []),
|
|
497
|
+
...(args.notes ?? []),
|
|
498
|
+
...plan.headerNotes,
|
|
499
|
+
...plan.notes,
|
|
500
|
+
],
|
|
501
|
+
next,
|
|
502
|
+
})}\n`,
|
|
503
|
+
)
|
|
504
|
+
return 0
|
|
505
|
+
}
|
|
506
|
+
report.push('', `next: ${next}`)
|
|
480
507
|
process.stdout.write(`${report.join('\n')}\n`)
|
|
481
508
|
return 0
|
|
482
509
|
}
|