@vzn/vx 0.0.646 → 0.0.648

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.
Files changed (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -7,10 +7,12 @@ export {
7
7
  affectedProjects,
8
8
  defaultAffectedBase,
9
9
  type FingerprintClaims,
10
- refIsHead,
10
+ headBase,
11
+ mergeBaseWith,
11
12
  } from './affected.js'
12
13
  export { applyFilters, parseFilter } from './filter.js'
13
14
  export { readClaimedFiles } from './fingerprint.js'
15
+ export { checkBracketLiterals } from './bracket-literal.js'
14
16
  export { checkDependencyRules } from './dependency-rules.js'
15
17
  export { computeNestedProjectDirs } from './nested-dirs.js'
16
18
  export { buildPackageGraph, type PackageGraph } from './package-graph.js'
@@ -19,6 +21,8 @@ export {
19
21
  LOCKFILE_NAME,
20
22
  LOCKFILE_VERSION,
21
23
  LOCK_INVALID,
24
+ EMPTY_LOCK,
25
+ lockDigest,
22
26
  lockEntryBroken,
23
27
  lockfilePath,
24
28
  lockKey,
@@ -29,6 +33,7 @@ export {
29
33
  type LockedProject,
30
34
  } from './lockfile.js'
31
35
  export {
36
+ ConfigsRefused,
32
37
  loadProjectConfig,
33
38
  loadProjectConfigs,
34
39
  loadWorkspaceConfig,
@@ -50,6 +55,7 @@ export {
50
55
  checkProjectNames,
51
56
  findWorkspaceRoot,
52
57
  findProjectConfigs,
58
+ REVIEW_FILE,
53
59
  discoveredProject,
54
60
  linkDiscoveredEdges,
55
61
  lockedProjects,
@@ -59,6 +65,7 @@ export {
59
65
  resolveStoreRoot,
60
66
  ROOT_NAME,
61
67
  type ConfigDir,
68
+ type ProjectConfigs,
62
69
  type ProjectEntry,
63
70
  type ProjectMeta,
64
71
  } from './workspace.js'
@@ -67,9 +74,12 @@ export type { LoadReads } from './load-reads.js'
67
74
  export {
68
75
  applyMigration,
69
76
  foldScriptHooks,
77
+ isStarterConfig,
70
78
  PERSISTENT_TODO,
71
79
  pruneOrphanPersistentNotes,
72
80
  quoteTsLiteral,
81
+ renderWorkspace,
82
+ starterProject,
73
83
  type ApplyMigrationArgs,
74
84
  type MigrationFormat,
75
85
  type MigrationMember,
@@ -9,8 +9,7 @@
9
9
  // the value, it was silently outside the key on both. So every path
10
10
  // refuses it, before schema validation, by this one rule: the first load
11
11
  // on the live object (`validateProjectConfig`), the worker before its
12
- // `JSON.stringify` (config-eval.ts), and the site's playground in its own
13
- // Worker. The two workers embed `nonJsonPaths` by its source text, so
12
+ // `JSON.stringify` (config-eval.ts). The worker embeds `nonJsonPaths` by its source text, so
14
13
  // the function must stay self-contained: no import, no module-scope name.
15
14
  //
16
15
  // An `undefined` PROPERTY is allowed: JSON drops it, the validator reads
@@ -91,7 +90,7 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
91
90
  return out
92
91
  }
93
92
 
94
- /** The refusal every path gives, the CLI's and the playground's. */
93
+ /** The refusal every path gives. */
95
94
  export function nonJsonMessage(configPath: string, found: NonJsonValue): string {
96
- return `${configPath}: ${found.path} is ${found.is} — a config must be JSON data, because the cache key folds its JSON`
95
+ return `${configPath}: ${found.path} is ${found.is} — a config must be JSON data, because the cache key is built from its JSON`
97
96
  }
@@ -3,7 +3,7 @@
3
3
  // `vx lock` finds every project's vx.config.*, evaluates each in the
4
4
  // CURRENT environment and freezes the resolved objects, by directory, into
5
5
  // `.vx/projects.lock.json` at the workspace root. Every run loads configs from
6
- // it and never evaluates one (owner, 2026-10-14); only `vx lock` writes
6
+ // it and never evaluates one (2026-10-14); only `vx lock` writes
7
7
  // it. See docs/design/config-lock-2026-06.md.
8
8
  //
9
9
  // Verification asymmetry (deliberate):
@@ -15,7 +15,7 @@ import { readFileSync } from 'node:fs'
15
15
  import { rename, rm } from 'node:fs/promises'
16
16
  import path from 'node:path'
17
17
  import type { ProjectConfig } from '../config.js'
18
- import { relPosix, UserError, xxh3 } from '../util/index.js'
18
+ import { keysSorted, relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
19
19
  import { VERSION } from '../version.js'
20
20
  import { validateProjectConfig } from './config-schema.js'
21
21
  import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
@@ -46,7 +46,7 @@ export function lockfilePath(root: string): string {
46
46
  return path.join(root, LOCKFILE_NAME)
47
47
  }
48
48
 
49
- /** What a run says when a crash came from a hand-broken lock entry (M, 2026-10-14). */
49
+ /** What a run says when a crash came from a hand-broken lock entry (2026-10-14). */
50
50
  export const LOCK_INVALID = `${LOCKFILE_NAME} is invalid, run \`vx lock\``
51
51
 
52
52
  /**
@@ -73,9 +73,21 @@ export function lockEntryBroken(root: string): boolean {
73
73
  return false
74
74
  }
75
75
 
76
+ /** What a run or `vx show` says when the lock holds no project. */
77
+ export const EMPTY_LOCK =
78
+ 'The lock holds no project — write a vx.config (`vx init`), then run `vx lock`'
79
+
76
80
  /** What a run says when there is no lock to read. */
77
81
  export const NO_LOCK = 'no lock: run `vx lock`'
78
82
 
83
+ /** Each read lock's bytes as a digest: every config a run loads from it is a function of them. */
84
+ const digests = new WeakMap<Lockfile, string>()
85
+
86
+ /** The digest of the bytes `lock` was read from; undefined for a lock no read made. */
87
+ export function lockDigest(lock: Lockfile): string | undefined {
88
+ return digests.get(lock)
89
+ }
90
+
79
91
  /**
80
92
  * Read and shape-validate `.vx/projects.lock.json`. Returns `null` when no lock
81
93
  * exists (the common case — the feature is opt-in). The lock is a
@@ -91,7 +103,10 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
91
103
  text = await file.text()
92
104
  parsed = JSON.parse(text)
93
105
  } catch {
94
- throw new UserError(`${LOCKFILE_NAME} is not valid JSON — re-run \`vx lock\` or delete it`)
106
+ throw new UserError(
107
+ `${LOCKFILE_NAME} is not valid JSON — re-run \`vx lock\` or delete it`,
108
+ 'VX_E_LOCK',
109
+ )
95
110
  }
96
111
  // `Array.isArray` is not redundant with the `typeof` check: `typeof [] ===
97
112
  // 'object'` and `[] !== null`, so an array sails through and the NEXT check
@@ -101,22 +116,31 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
101
116
  // already guards this explicitly, which is what makes the omission here an
102
117
  // inconsistency rather than an oversight.
103
118
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
104
- throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``)
119
+ throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``, 'VX_E_LOCK')
105
120
  }
106
121
  const lock = parsed as { version?: unknown; projects?: unknown }
107
122
  if (lock.version !== LOCKFILE_VERSION) {
108
123
  throw new UserError(
109
124
  `${LOCKFILE_NAME} has unsupported version ${String(lock.version)} ` +
110
125
  `(this vx expects ${LOCKFILE_VERSION}) — re-run \`vx lock\``,
126
+ 'VX_E_LOCK',
111
127
  )
112
128
  }
113
129
  // Same trap, and this one is worse than a bad message: an ARRAY of entries is
114
130
  // ACCEPTED, because `Object.entries` happily walks it — yielding a project
115
131
  // at directory "0".
116
132
  if (typeof lock.projects !== 'object' || lock.projects === null || Array.isArray(lock.projects)) {
117
- throw new UserError(`${LOCKFILE_NAME}: \`projects\` must be an object — re-run \`vx lock\``)
133
+ throw new UserError(
134
+ `${LOCKFILE_NAME}: \`projects\` must be an object — re-run \`vx lock\``,
135
+ 'VX_E_LOCK',
136
+ )
118
137
  }
119
138
  for (const [dir, entry] of Object.entries(lock.projects)) {
139
+ // A run joins the key onto the root: `../x` ran a project outside it.
140
+ if (!isLockKey(dir))
141
+ throw new UserError(
142
+ `${LOCKFILE_NAME}: "${dir}" is not a directory under the workspace root — re-run \`vx lock\``,
143
+ )
120
144
  const { file, config } = (entry ?? {}) as { file?: unknown; config?: unknown }
121
145
  if (
122
146
  typeof entry !== 'object' ||
@@ -128,9 +152,13 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
128
152
  config === null ||
129
153
  Array.isArray(config)
130
154
  ) {
131
- throw new UserError(`${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``)
155
+ throw new UserError(
156
+ `${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
157
+ 'VX_E_LOCK',
158
+ )
132
159
  }
133
160
  }
161
+ digests.set(parsed as Lockfile, xxh3hex(text))
134
162
  return parsed as Lockfile
135
163
  }
136
164
 
@@ -143,6 +171,18 @@ function validatedDigest(body: string): string {
143
171
  return xxh3(`vx-lock-valid-v3\0${VERSION}\0${body}`).toString(16)
144
172
  }
145
173
 
174
+ /**
175
+ * Each task's config with its keys sorted (`keysSorted`), so a run's key
176
+ * check finds it sorted and copies nothing. The tasks keep their order,
177
+ * which `vx show` lists them in.
178
+ */
179
+ function taskKeysSorted(key: string, value: unknown): unknown {
180
+ if (key !== 'tasks' || value === null || typeof value !== 'object' || Array.isArray(value))
181
+ return value
182
+ // Entries, not assignment: a task named `__proto__` set the prototype.
183
+ return Object.fromEntries(Object.entries(value).map(([name, task]) => [name, keysSorted(task)]))
184
+ }
185
+
146
186
  export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
147
187
  // Written beside its name and renamed over it: `Bun.write` truncates in
148
188
  // place, so a run, or `vx watch` woken by the write
@@ -154,7 +194,7 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
154
194
  // Minified: the file only vx reads, and it loads faster than pretty
155
195
  // JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
156
196
  // beside each config is the copy for people.
157
- const body = JSON.stringify({ version: lock.version, projects: lock.projects })
197
+ const body = JSON.stringify({ version: lock.version, projects: lock.projects }, taskKeysSorted)
158
198
  await Bun.write(tmp, `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`)
159
199
  await rename(tmp, file)
160
200
  } catch (err) {
@@ -191,12 +231,21 @@ export function frozenProjectConfigs(
191
231
  throw new UserError(
192
232
  `${LOCKFILE_NAME} has no entry for "${meta.name}" (${relPosix(root, meta.configPath)}) — ` +
193
233
  'run `vx lock`',
234
+ 'VX_E_LOCK',
194
235
  )
195
236
  }
196
237
  return entry.config
197
238
  })
198
239
  }
199
240
 
241
+ /** An empty, `.` or `..` segment. A regex: a split per key was 1.2 ms of a 10k-project read, this 0.4 ms. */
242
+ const BAD_SEGMENT = /(?:^|\/)\.{0,2}(?:\/|$)/
243
+
244
+ /** Whether `dir` is a key `lockKey` could give: `.`, or a relative POSIX path with no empty, `.` or `..` segment. */
245
+ function isLockKey(dir: string): boolean {
246
+ return dir === '.' || (!dir.startsWith('/') && !BAD_SEGMENT.test(dir))
247
+ }
248
+
200
249
  /** A project directory as the lock keys it: root-relative POSIX, `.` for the root. */
201
250
  export function lockKey(root: string, dir: string): string {
202
251
  return relPosix(root, dir) || '.'
@@ -9,10 +9,39 @@ import { existsSync, readFileSync } from 'node:fs'
9
9
  import { unlink } from 'node:fs/promises'
10
10
  import path from 'node:path'
11
11
  import { workspaceFileWritten } from '../handoff.js'
12
- import { DOT_VX_GITIGNORE, executablePath, gitEnv, relPosix, UserError, ui } from '../util/index.js'
12
+ import {
13
+ DOT_VX_GITIGNORE,
14
+ executablePath,
15
+ gitEnv,
16
+ refuseSymlink,
17
+ relPosix,
18
+ UserError,
19
+ ui,
20
+ writeNoFollow,
21
+ } from '../util/index.js'
13
22
  import { VERSION } from '../version.js'
14
23
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
15
24
 
25
+ /** The root config `vx init` writes outside a Turbo or Nx repo: no task yet. */
26
+ export function starterProject(format: MigrationFormat): string {
27
+ return format === 'ts'
28
+ ? "import type { ProjectConfig } from '@vzn/vx/config'\n\nexport default { tasks: {} } satisfies ProjectConfig\n"
29
+ : 'export default { tasks: {} }\n'
30
+ }
31
+
32
+ /**
33
+ * Whether `file` is `vx init`'s starter, untouched: a migration maps over it
34
+ * as over no config, so the scripts `vx init` pointed at become tasks.
35
+ */
36
+ export function isStarterConfig(file: string): boolean {
37
+ try {
38
+ // By extension: the mjs starter's text in a .ts file is a user's config.
39
+ return readFileSync(file, 'utf8') === starterProject(file.endsWith('.ts') ? 'ts' : 'mjs')
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
16
45
  /**
17
46
  * Escape an arbitrary string into a single-quoted TS literal. Escapes
18
47
  * backslash + quote AND raw newlines/CR — a value with an embedded newline
@@ -208,7 +237,7 @@ export interface ApplyMigrationArgs {
208
237
  /**
209
238
  * Run after the files are written (not under `dry`): `vx lock`'s step, so
210
239
  * the migrated workspace has its .vx/projects.lock.json and its graph rules checked
211
- * (owner, 2026-10-14). On success `said` is its line, shown as the
240
+ * (2026-10-14). On success `said` is its line, shown as the
212
241
  * report's `lock` row; a refusal (a graph rule, a plugin not installed
213
242
  * yet) prints above the report and the migration still succeeds: its
214
243
  * files are written, and `vx lock` is the step to re-run.
@@ -243,7 +272,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
243
272
  // the whole workspace, and `--force` would have replaced the hand-written
244
273
  // file; `--force` still replaces, as asked.
245
274
  const kept: string[] = []
246
- // Every project gets a config, a task-less one too (owner, 2026-10-14:
275
+ // Every project gets a config, a task-less one too (2026-10-14:
247
276
  // "no skips. every project gets a config"): solidjs/solid's solid-ssr,
248
277
  // with no script Turbo runs, was left with none and read as a project
249
278
  // whose tasks are not locked.
@@ -263,7 +292,13 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
263
292
  // A plan never maps a workspace root that has a config (D-45), even
264
293
  // under `--force`; its file stays, so it is named with the rest (X-27).
265
294
  const rootConfig = metas.find((m) => m.dir === root)?.configPath
266
- if (init && rootConfig && !projects.some((p) => p.dir === root && p.tasks.length > 0)) {
295
+ // A one-package repo's root is a mapped member too: the filter kept it already.
296
+ if (
297
+ init &&
298
+ rootConfig &&
299
+ !kept.includes(relPosix(root, rootConfig)) &&
300
+ !projects.some((p) => p.dir === root && p.tasks.length > 0)
301
+ ) {
267
302
  kept.push(relPosix(root, rootConfig))
268
303
  }
269
304
  if (empty && !init) {
@@ -298,7 +333,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
298
333
  files.push({
299
334
  relPath: relPosix(root, abs),
300
335
  abs,
301
- contents: workspaceFile(format, args.plugins ?? []),
336
+ contents: renderWorkspace(format, args.plugins ?? []),
302
337
  })
303
338
  }
304
339
  // `.vx/.gitignore` keeps the machine-local `.vx/cache` out of git and the
@@ -308,6 +343,15 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
308
343
  files.push({ relPath: '.vx/.gitignore', abs: dotVxIgnore, contents: DOT_VX_GITIGNORE })
309
344
  }
310
345
 
346
+ // A plan is a mapper's reading of the repo's own files (an Nx node's
347
+ // `root`, a workspace glob): one that names `../x` wrote outside it.
348
+ for (const f of files) {
349
+ const rel = relPosix(root, f.abs)
350
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
351
+ throw new UserError(`${f.relPath}: outside the workspace root`)
352
+ }
353
+ }
354
+
311
355
  if (!dry && !force) {
312
356
  const conflicts = new Set<string>()
313
357
  // A discovered project with ANY existing vx config (.ts/.mjs/.js) — refuse
@@ -321,7 +365,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
321
365
  // scan alone would miss an existing vx.config.ts at that path and clobber
322
366
  // it. This also covers the extraFiles (vx-preset.ts).
323
367
  for (const f of files) {
324
- if (await Bun.file(f.abs).exists()) conflicts.add(f.relPath)
368
+ if ((await Bun.file(f.abs).exists()) && !isStarterConfig(f.abs)) conflicts.add(f.relPath)
325
369
  }
326
370
  if (conflicts.size > 0) {
327
371
  throw new UserError(
@@ -342,10 +386,11 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
342
386
  }
343
387
  if (dry && args.json !== true) {
344
388
  for (const f of files) {
345
- process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
389
+ ui.print('out', ui.excerpt(f.relPath, f.contents))
346
390
  }
347
391
  } else if (!dry) {
348
- for (const f of files) await Bun.write(f.abs, f.contents)
392
+ for (const f of files) refuseSymlink(f.abs)
393
+ for (const f of files) await writeNoFollow(f.abs, f.contents)
349
394
  for (const f of replaced) await unlink(f)
350
395
  // The lock that follows loads its plugins from the file just written.
351
396
  workspaceFileWritten()
@@ -387,7 +432,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
387
432
  // refuse without: a git work tree (vx keys inputs on git's view) and,
388
433
  // with no scripts mapped, a task to run.
389
434
  // `vx init` installs vx globally first: the line names vx alone, never
390
- // a package manager's runner (owner, 2026-10-22: vx owns the command).
435
+ // a package manager's runner (2026-10-22: vx owns the command).
391
436
  const run = `vx run ${firstTask} --all`
392
437
  // A run reads configs only from the lock; with no lock step here
393
438
  // (`--no-install`), that run stopped with "no lock: run `vx lock`".
@@ -625,12 +670,15 @@ function exampleConfig(format: MigrationFormat): string {
625
670
 
626
671
  // ─── TS emission ──────────────────────────────────────────────────────
627
672
 
628
- // Type-only import + `satisfies`, like the project configs: a runtime
629
- // `import { defineWorkspace } from '@vzn/vx'` loads a SECOND copy of core
630
- // into every run — measured 2026-09-09 at ~17 ms on a two-package
631
- // workspace, a fifth of the whole run — for an identity function.
632
- // No explanatory comment: the file is the user's (owner, 2026-10-06).
633
- function workspaceFile(format: MigrationFormat, plugins: readonly WorkspacePlugin[]): string {
673
+ // Type-only import + `satisfies`, like the project configs: Bun erases it,
674
+ // so the file loads with nothing installed, and a user's tsc checks it
675
+ // against config.ts alone, where `@vzn/vx` walks core's Bun-only sources
676
+ // (527 errors, J-94). `vx init` writes the same file.
677
+ // No explanatory comment: the file is the user's (2026-10-06).
678
+ export function renderWorkspace(
679
+ format: MigrationFormat,
680
+ plugins: readonly WorkspacePlugin[],
681
+ ): string {
634
682
  const imports = plugins.map((p) => `import { ${p.factory} } from '${p.pkg}'\n`).join('')
635
683
  const body = `export default { plugins: [${plugins.map((p) => `${p.factory}()`).join(', ')}] }`
636
684
  return format === 'ts'
@@ -667,8 +715,8 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
667
715
  // package, and Bun erases it, so the config loads in a workspace that
668
716
  // runs the vx binary without the package installed. `mjs` is the same
669
717
  // object with nothing to erase.
670
- // No "generated by" header: the file is the user's from here (owner,
671
- // 2026-10-06), and each TODO(vx-migrate) sits on its own task.
718
+ // No "generated by" header: the file is the user's from here
719
+ // (2026-10-06), and each TODO(vx-migrate) sits on its own task.
672
720
  const lines: string[] = []
673
721
  if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx/config'")
674
722
  if (p.importLines.length > 0) lines.push(...p.importLines)
@@ -752,7 +800,10 @@ function ownConfigNamed(
752
800
 
753
801
  function readManifest(dir: string): { type?: unknown } | undefined {
754
802
  try {
755
- return JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')) as { type?: unknown }
803
+ // A byte-order mark is stripped, as npm strips it.
804
+ return JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8').replace(/^/, '')) as {
805
+ type?: unknown
806
+ }
756
807
  } catch {
757
808
  return undefined
758
809
  }
@@ -20,8 +20,9 @@ export interface PackageGraph {
20
20
  * no plugin edge names but the task graph does; without it
21
21
  * `--filter '...app'` never selected `e2e`, and a CI that runs "what
22
22
  * changed and everything depending on it" silently left it out
23
- * (2026-09-10). The `^task` walk still reads `directDeps`, which carries
24
- * both — a task edge IS a dependency.
23
+ * (2026-09-10). Selection, `vx watch` and the dependency rules pass them;
24
+ * the graph a run or `vx lock` resolves `^task` against does not (it is
25
+ * built before configs load), so a task edge never widens a `^task` walk.
25
26
  */
26
27
  export function buildPackageGraph(
27
28
  projects: ProjectMeta[],
@@ -30,8 +31,8 @@ export function buildPackageGraph(
30
31
  const byName = new Map<string, ProjectMeta>()
31
32
  for (const p of projects) byName.set(p.name, p)
32
33
 
33
- // Two adjacencies. ORDER (`directDeps`, the `^task` walk) is the task
34
- // edges and a `discover` plugin's `deps`. REACH (the transitive closures
34
+ // Two adjacencies. ORDER (`directDeps`, the `^task` walk) is a
35
+ // `discover` plugin's `deps`, plus the task edges when passed. REACH (the transitive closures
35
36
  // `--filter pkg...` and `--affected` read) is ORDER plus a plugin's
36
37
  // `reach`, which never orders a build.
37
38
  const directDeps = new Map<string, string[]>()