@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 +2 -1
- package/package.json +5 -5
- package/src/cache/archive.ts +31 -6
- package/src/cache/cache.ts +1 -0
- package/src/cli/help.ts +2 -1
- package/src/cli/init.ts +100 -18
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.
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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"
|
package/src/cache/archive.ts
CHANGED
|
@@ -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,
|
|
210
|
-
*
|
|
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
|
|
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
|
package/src/cache/cache.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|