@vzn/vx 0.0.141 → 0.0.143

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 CHANGED
@@ -32,7 +32,8 @@ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.
32
32
 
33
33
  ## Try it on your repo
34
34
 
35
- In a Turborepo or Nx workspace, add one file:
35
+ In a Turborepo or Nx workspace, add one file (`npx @vzn/vx init` writes
36
+ it and prints the command to run next):
36
37
 
37
38
  ```ts
38
39
  // vx.workspace.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.141",
3
+ "version": "0.0.143",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.141",
21
- "@vzn/vx-linux-arm64": "0.0.141",
22
- "@vzn/vx-darwin-x64": "0.0.141",
23
- "@vzn/vx-darwin-arm64": "0.0.141"
20
+ "@vzn/vx-linux-x64": "0.0.143",
21
+ "@vzn/vx-linux-arm64": "0.0.143",
22
+ "@vzn/vx-darwin-x64": "0.0.143",
23
+ "@vzn/vx-darwin-arm64": "0.0.143"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -38,7 +38,7 @@
38
38
  // otherwise silently loses the claim rather than acting on it.
39
39
 
40
40
  import { chmodSync, renameSync, statSync, utimesSync } from 'node:fs'
41
- import { mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
41
+ import { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
42
42
  import path from 'node:path'
43
43
  import { UserError } from '../util/index.js'
44
44
  import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
@@ -193,6 +193,13 @@ export interface PackArgs {
193
193
  outputs: ReadonlyMap<string, string>
194
194
  /** The producing execution's usage, when the runner reported it. */
195
195
  exec?: ExecUsage | undefined
196
+ /**
197
+ * The project directory a symlinked output must resolve inside. vx reads
198
+ * outputs outside the task's sandbox, so a link a sandboxed task planted
199
+ * to a file it could not read (another project's, the user's home) packed
200
+ * that file into the artifact and the remote (L-23).
201
+ */
202
+ within?: string
196
203
  }
197
204
 
198
205
  /** A packed artifact's plan: its entries with their stats, and the tar's exact size. */
@@ -206,11 +213,12 @@ export interface ArtifactPlan {
206
213
  * mtime) and lay out the tar — stdout first, the outputs, the sidecar
207
214
  * last — with its exact size, so the caller can choose the one-call pack
208
215
  * for a small artifact and the streamed one for a large one before a
209
- * byte is read. A symlinked output is stored as its target's content, as
210
- * it always was.
216
+ * byte is read. A symlinked output is stored as its target's content,
217
+ * when that target is inside `within`.
211
218
  */
212
219
  export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
213
220
  const meta: MetaFile = { version: 1, files: {} }
221
+ let withinReal: Promise<string> | undefined
214
222
  if (args.key !== undefined) meta.key = args.key
215
223
  const exec = usageOf(args.exec)
216
224
  if (exec !== undefined) meta.exec = exec
@@ -221,14 +229,31 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
221
229
  // directory, or a dangling one, has no bytes to pack: refuse loudly
222
230
  // rather than store an entry that restores to nothing.
223
231
  const shown = name.startsWith('outputs/') ? name.slice('outputs/'.length) : name
224
- const st = await stat(abs).catch((err: NodeJS.ErrnoException) => {
232
+ const dangling = (err: NodeJS.ErrnoException): never => {
225
233
  if (err.code === 'ENOENT') {
226
234
  throw new UserError(
227
235
  `output ${shown} is a dangling symlink: vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
228
236
  )
229
237
  }
230
238
  throw err
231
- })
239
+ }
240
+ // lstat, so a regular file costs no second call; only a link is
241
+ // resolved, and its body is read from the path this check resolved.
242
+ let src = abs
243
+ let st = await lstat(abs)
244
+ if (st.isSymbolicLink()) {
245
+ src = await realpath(abs).catch(dangling)
246
+ if (args.within !== undefined) {
247
+ withinReal ??= realpath(args.within)
248
+ const root = await withinReal
249
+ if (!src.startsWith(root + path.sep)) {
250
+ throw new UserError(
251
+ `output ${shown} is a symlink to ${src}, outside the project: vx packs a symlinked output as its target's bytes, and a project's outputs come from its own directory — emit a file there, or take it back with a '!' entry`,
252
+ )
253
+ }
254
+ }
255
+ st = await stat(src).catch(dangling)
256
+ }
232
257
  if (!st.isFile()) {
233
258
  throw new UserError(
234
259
  `output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
@@ -240,7 +265,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
240
265
  meta.files[name] = [mode, Math.floor(st.mtimeMs)]
241
266
  return {
242
267
  name,
243
- abs,
268
+ abs: src,
244
269
  size: st.size,
245
270
  mode,
246
271
  // ustar's octal field holds no sign: an mtime before 1970 made the
@@ -1304,6 +1304,7 @@ export class Cache implements CacheLayer {
1304
1304
  stdout: args.entry.stdout ?? '',
1305
1305
  outputs: this.outputsOf(args),
1306
1306
  exec: usageOfEntry(args.entry),
1307
+ within: args.projectDir,
1307
1308
  })
1308
1309
  if (plan.size > this.artifactCeiling) {
1309
1310
  throw new Error(
package/src/cli/help.ts CHANGED
@@ -157,7 +157,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
157
157
  'Migration:',
158
158
  ' vx init Generate vx.workspace.ts + one vx.config.ts per package',
159
159
  ' from package.json scripts. Unmappable settings become',
160
- ' TODO(vx-migrate) comments.',
160
+ ' TODO(vx-migrate) comments. Beside turbo.json or nx.json:',
161
+ ' only vx.workspace.ts, declaring turbo() or nx().',
161
162
  ' --dry Print the generated files instead of writing them.',
162
163
  ' --force Overwrite existing vx.config.* files.',
163
164
  ' --plugin <seam> Instead: write plugins/<seam>.ts, a runnable plugin, and its',
package/src/cli/init.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
6
6
  // test instead (plugin-templates.ts, the examples the gate runs).
7
7
 
8
- import { mkdir } from 'node:fs/promises'
8
+ import { existsSync, readFileSync } from 'node:fs'
9
+ import { mkdir, unlink } from 'node:fs/promises'
9
10
  import path from 'node:path'
10
11
  import { flagHint, seeHelp } from './help.js'
11
12
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
@@ -17,6 +18,7 @@ import {
17
18
  listProjects,
18
19
  loadWorkspace,
19
20
  migrateScripts,
21
+ WORKSPACE_CONFIG_FILENAMES,
20
22
  } from '../workspace/index.js'
21
23
 
22
24
  export interface InitArgs {
@@ -76,7 +78,6 @@ export async function initCmd(args: readonly string[]): Promise<number> {
76
78
  // richer source (dependsOn, inputs, outputs) and was ignored without a
77
79
  // word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
78
80
  // TODOs and none of the edges turbo.json already declared.
79
- const notes: string[] = []
80
81
  // Turbo 2.5+ reads `turbo.jsonc` as well (item 938).
81
82
  let turbo: string | undefined
82
83
  for (const name of ['turbo.json', 'turbo.jsonc']) {
@@ -85,21 +86,14 @@ export async function initCmd(args: readonly string[]): Promise<number> {
85
86
  break
86
87
  }
87
88
  }
88
- if (turbo !== undefined) {
89
- notes.push(
90
- `${turbo} found and not read — ` +
91
- '`bunx @vzn/vx-migrate` maps it (dependsOn, inputs, ' +
92
- 'outputs), or `plugins: [turbo()]` from @vzn/vx-migrate runs it with nothing written',
93
- )
94
- } else if (
95
- (await Bun.file(path.join(root, 'nx.json')).exists()) ||
96
- (await Bun.file(path.join(root, '.nx', 'workspace-data', 'project-graph.json')).exists())
97
- ) {
98
- notes.push(
99
- 'an Nx workspace found and not read — `bunx @vzn/vx-migrate --from nx` maps its ' +
100
- 'exported project graph, or `plugins: [nx()]` from @vzn/vx-migrate runs it with ' +
101
- 'nothing written',
102
- )
89
+ // A Turbo or Nx repo already says its tasks: the runner's own config is
90
+ // the source, and `turbo()` / `nx()` read it live. Writing a config per
91
+ // package from the scripts dropped every edge turbo.json declares (the
92
+ // first-five-minutes walk, 2026-09-28); the workspace file alone is the
93
+ // whole adoption, and `bunx @vzn/vx-migrate` stays for freezing it.
94
+ if (turbo !== undefined) return adopt(root, 'turbo', turbo, parsed)
95
+ if (await Bun.file(path.join(root, 'nx.json')).exists()) {
96
+ return adopt(root, 'nx', 'nx.json', parsed)
103
97
  }
104
98
  return applyMigration({
105
99
  root,
@@ -110,7 +104,6 @@ export async function initCmd(args: readonly string[]): Promise<number> {
110
104
  dry: parsed.dry,
111
105
  force: parsed.force,
112
106
  init: true,
113
- notes,
114
107
  format: parsed.mjs ? 'mjs' : 'ts',
115
108
  })
116
109
  }
@@ -143,3 +136,92 @@ async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
143
136
  )
144
137
  return 0
145
138
  }
139
+
140
+ /**
141
+ * `vx init` in a Turbo or Nx repo: `vx.workspace.ts` declaring the plugin
142
+ * that runs the repo as it is, nothing else, and the one command that gets
143
+ * from here to a run.
144
+ */
145
+ async function adopt(
146
+ root: string,
147
+ runner: 'turbo' | 'nx',
148
+ source: string,
149
+ args: InitArgs,
150
+ ): Promise<number> {
151
+ const name = `vx.workspace.${args.mjs ? 'mjs' : 'ts'}`
152
+ const body = `import { ${runner} } from '@vzn/vx-migrate'\n\nexport default { plugins: [${runner}()] }`
153
+ const text = args.mjs
154
+ ? `${body}\n`
155
+ : `import type { WorkspaceConfig } from '@vzn/vx'\n${body} satisfies WorkspaceConfig\n`
156
+ const existing = WORKSPACE_CONFIG_FILENAMES.find((f) => existsSync(path.join(root, f)))
157
+ if (existing !== undefined && !args.force) {
158
+ const declared = readFileSync(path.join(root, existing), 'utf8').includes(`${runner}(`)
159
+ if (!declared) {
160
+ throw new UserError(
161
+ `vx init: ${existing} exists; add ${runner}() from @vzn/vx-migrate to its plugins, or --force replaces it`,
162
+ )
163
+ }
164
+ } else if (args.dry) {
165
+ process.stdout.write(`── ${name} ──\n${text}\n`)
166
+ } else {
167
+ if (existing !== undefined && existing !== name) await unlink(path.join(root, existing))
168
+ await Bun.write(path.join(root, name), text)
169
+ }
170
+ const wrote =
171
+ existing !== undefined && !args.force
172
+ ? `${existing} already declares ${runner}().`
173
+ : args.dry
174
+ ? `would write ${name} (dry run, nothing written).`
175
+ : `wrote ${name}.`
176
+ process.stdout.write(
177
+ `vx init: ${source} found — ${runner}() from @vzn/vx-migrate runs this repo as it is; nothing else written.\n` +
178
+ `${wrote}\n\nnext: ${adoptionNext(root, runner, source)}\n`,
179
+ )
180
+ return 0
181
+ }
182
+
183
+ type PackageManager = 'pnpm' | 'yarn' | 'bun' | 'npm'
184
+
185
+ const LOCKFILES: ReadonlyArray<[string, PackageManager]> = [
186
+ ['pnpm-lock.yaml', 'pnpm'],
187
+ ['yarn.lock', 'yarn'],
188
+ ['bun.lock', 'bun'],
189
+ ['bun.lockb', 'bun'],
190
+ ['package-lock.json', 'npm'],
191
+ ]
192
+
193
+ const INSTALL: Record<PackageManager, string> = {
194
+ pnpm: 'pnpm add -D -w',
195
+ yarn: 'yarn add -D',
196
+ bun: 'bun add -d',
197
+ npm: 'npm install -D',
198
+ }
199
+
200
+ const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
201
+
202
+ /** Install what the workspace file imports, if missing, then run the repo's build. */
203
+ export function adoptionNext(root: string, runner: 'turbo' | 'nx', source: string): string {
204
+ const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
205
+ const missing = ['@vzn/vx', '@vzn/vx-migrate'].filter(
206
+ (p) => !existsSync(path.join(root, 'node_modules', p, 'package.json')),
207
+ )
208
+ // A global vx (no runner started this one) runs the workspace's plugins as they are.
209
+ const vx = process.env['npm_config_user_agent'] === undefined ? 'vx' : `${EXEC[pm]} vx`
210
+ const task = firstTask(path.join(root, source), runner)
211
+ const run = `${vx} run ${task} --all`
212
+ return missing.length === 0 ? run : `${INSTALL[pm]} ${missing.join(' ')} && ${run}`
213
+ }
214
+
215
+ /** `build` when the runner's config names it, else the first task it names. */
216
+ function firstTask(file: string, runner: 'turbo' | 'nx'): string {
217
+ let text: string
218
+ try {
219
+ text = readFileSync(file, 'utf8')
220
+ } catch {
221
+ return 'build'
222
+ }
223
+ if (/"build"\s*:/.test(text)) return 'build'
224
+ const field = runner === 'turbo' ? '(?:tasks|pipeline)' : 'targetDefaults'
225
+ const first = new RegExp(`"${field}"\\s*:\\s*\\{\\s*"([^"]+)"`).exec(text)?.[1]
226
+ return first ?? 'build'
227
+ }