@vzn/vx 0.0.632 → 0.0.633

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.
@@ -13,7 +13,7 @@
13
13
  // deno upgrade — "update" is what package managers do to indexes.
14
14
 
15
15
  import { constants } from 'node:fs'
16
- import { access, chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
16
+ import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
17
17
  import path from 'node:path'
18
18
  import { flagHint, seeHelp } from './help.js'
19
19
  import { UserError } from '../util/index.js'
@@ -247,8 +247,20 @@ export async function replaceBinary(
247
247
  `vx upgrade: the download did not match the release's SHA-256 (expected ${sha256}, got ${got}) — nothing replaced; try again, and if it repeats the asset is not the one the release published`,
248
248
  )
249
249
  }
250
- const tmp = `${dest}.upgrade-${process.pid}`
251
- const old = `${dest}.previous-${process.pid}`
250
+ // A fresh directory beside the binary, not a name built from the pid:
251
+ // anyone who can write the install directory could plant a link at a
252
+ // predictable name, and a `sudo vx upgrade` wrote through it as root.
253
+ let stage: string
254
+ try {
255
+ stage = await mkdtemp(path.join(dir, '.vx-upgrade-'))
256
+ } catch (err) {
257
+ const msg = err instanceof Error ? err.message : String(err)
258
+ throw new UserError(
259
+ `vx upgrade: could not stage the new binary in ${dir} (${msg}) — nothing replaced; check the permissions of ${dir}`,
260
+ )
261
+ }
262
+ const tmp = path.join(stage, 'new')
263
+ const old = path.join(stage, 'previous')
252
264
  let kept = false
253
265
  try {
254
266
  const was = await stat(dest).catch(() => null)
@@ -265,18 +277,18 @@ export async function replaceBinary(
265
277
  }
266
278
  await rename(tmp, dest)
267
279
  } catch (err) {
268
- await rm(tmp, { force: true })
269
- if (kept) await rm(old, { force: true })
280
+ await rm(stage, { recursive: true, force: true })
270
281
  const msg = err instanceof Error ? err.message : String(err)
271
282
  throw new UserError(
272
283
  `vx upgrade: could not replace ${dest} (${msg}) — nothing replaced; check the permissions of ${dest} and its directory`,
273
284
  )
274
285
  }
275
286
  if (starts === undefined || starts(dest)) {
276
- if (kept) await rm(old, { force: true })
287
+ await rm(stage, { recursive: true, force: true })
277
288
  return
278
289
  }
279
290
  if (kept) await rename(old, dest)
291
+ await rm(stage, { recursive: true, force: true })
280
292
  throw new UserError(
281
293
  `vx upgrade: the new binary did not start on this machine (\`${dest} --version\` failed)${kept ? ' — the previous vx is back in place' : ''}. Nothing else changed; report it with this os/arch`,
282
294
  )
@@ -109,7 +109,7 @@ export function isGroupTask(node: TaskNode): boolean {
109
109
  return node.config.exec === undefined
110
110
  }
111
111
 
112
- /** The default `build` (orchestrator/projects.ts): a config cannot key a group, so it is the one keyed group. */
112
+ /** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `cache`. */
113
113
  function isKeyedGroup(task: TaskConfig): boolean {
114
114
  return task.exec === undefined && task.cache !== undefined
115
115
  }
@@ -1505,8 +1505,8 @@ interface Side {
1505
1505
  * `inputs.files` rebased to the root against another task's
1506
1506
  * `outputs.workspaceFiles`, which may land in any project (X-135). A task's own outputs are
1507
1507
  * already subtracted from its inputs, and an output the reader's `!` entries
1508
- * take back whole is no overlap. A keyed group is exempt: the default
1509
- * `build` reads `**` of a config-less dependency and runs nothing, and its
1508
+ * take back whole is no overlap. A keyed group is exempt: it runs nothing
1509
+ * (the default `build` reads `**` of a config-less dependency), and its
1510
1510
  * key waits as before.
1511
1511
  */
1512
1512
  function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
@@ -610,10 +610,17 @@ export interface CacheKeyDiff {
610
610
  note: string
611
611
  }
612
612
 
613
- function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
614
- return db
615
- .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
616
- .all(entryHash) as TaskInputComponent[]
613
+ /** An entry's key components, from its one `entry_inputs` row (v34); none when it has none. */
614
+ export function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
615
+ const row = db
616
+ .query('SELECT components FROM entry_inputs WHERE entry_hash = ?')
617
+ .get(entryHash) as { components: string } | null
618
+ if (row === null) return []
619
+ return (JSON.parse(row.components) as [string, string, string][]).map(([kind, name, hash]) => ({
620
+ kind,
621
+ name,
622
+ hash,
623
+ }))
617
624
  }
618
625
 
619
626
  /**
@@ -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
- try {
195
- facts.artifactBytes = statSync(cache.outputsPath(a.hash)).size
196
- } catch {
197
- // No local artifact (local writes off): no size to say.
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. The one keyed group: a config cannot
332
- * declare `cache` on a group (config-schema.ts), so nothing else is.
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']),
@@ -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 the one keyed group (the default
415
- * `build`) declares no `remote`. `timeout`/`retries` stay folded — their keys are
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 — the default
427
- * `build` (projects.ts), the one group with `cache` — its own inputs too,
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/verbs.ts CHANGED
@@ -14,6 +14,7 @@ export const CORE_VERBS = [
14
14
  'info',
15
15
  'why',
16
16
  'last',
17
+ 'docs',
17
18
  'completions',
18
19
  'help',
19
20
  'version',
@@ -616,15 +616,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
616
616
  // so a dependant's `^build` finds it and waits on nothing
617
617
  // (the quickstart). The comment here used to call that case
618
618
  // "almost certainly a config mistake" while the docs taught it.
619
- if (dependsOn === undefined) {
619
+ // A keyed group (`cache` with no `exec`) is a declaration on its own:
620
+ // its inputs are a key that dependants fold, with no process.
621
+ if (dependsOn === undefined && cache === undefined) {
620
622
  throw new UserError(
621
- `${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
622
- `(group tasks exist to chain dependencies)`,
623
- )
624
- }
625
- if (cache !== undefined) {
626
- throw new UserError(
627
- `${where}: \`cache\` requires \`exec\` — a group task has nothing to cache`,
623
+ `${where}: a task with no \`exec\` must declare \`dependsOn\` or \`cache.inputs\` ` +
624
+ `(group tasks exist to chain dependencies or carry a key)`,
628
625
  )
629
626
  }
630
627
  }
@@ -852,6 +849,11 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
852
849
  if (wsOutputs !== undefined) {
853
850
  validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
854
851
  }
852
+ if (exec === undefined && (outFiles.length > 0 || wsOutputs !== undefined)) {
853
+ throw new UserError(
854
+ `${where}.cache.outputs: a task with no \`exec\` writes nothing — \`outputs: { files: [] }\``,
855
+ )
856
+ }
855
857
  }
856
858
  }
857
859
  }
@@ -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
- report.push('', `next: ${steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`}`)
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
  }