@vzn/vx 0.0.109 → 0.0.110

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.109",
3
+ "version": "0.0.110",
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.109",
21
- "@vzn/vx-linux-arm64": "0.0.109",
22
- "@vzn/vx-darwin-x64": "0.0.109",
23
- "@vzn/vx-darwin-arm64": "0.0.109"
20
+ "@vzn/vx-linux-x64": "0.0.110",
21
+ "@vzn/vx-linux-arm64": "0.0.110",
22
+ "@vzn/vx-darwin-x64": "0.0.110",
23
+ "@vzn/vx-darwin-arm64": "0.0.110"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -49,6 +49,10 @@ const META_ENTRY = '.vx-meta.json'
49
49
  /** Archive entry name of the always-present stdout record. */
50
50
  const STDOUT_ENTRY = 'stdout'
51
51
 
52
+ const WIN32 = process.platform === 'win32'
53
+ // A Windows link target may be spelled with either separator.
54
+ const LINK_SEP = WIN32 ? /[\\/]/ : '/'
55
+
52
56
  /**
53
57
  * Sidecar shape. `files` maps an entry name to `[mode, mtimeMs]` — a
54
58
  * pair, not an object, because the sidecar is real entropy inside an
@@ -157,12 +161,15 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
157
161
  `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 (output globs take no '!')`,
158
162
  )
159
163
  }
160
- meta.files[name] = [st.mode & 0o777, Math.floor(st.mtimeMs)]
164
+ // Windows reports 0o666 (0o444 read-only) and never an exec bit: a
165
+ // Linux restore of that would be world-writable.
166
+ const mode = WIN32 ? 0o644 : st.mode & 0o777
167
+ meta.files[name] = [mode, Math.floor(st.mtimeMs)]
161
168
  return {
162
169
  name,
163
170
  abs,
164
171
  size: st.size,
165
- mode: st.mode & 0o777,
172
+ mode,
166
173
  // ustar's octal field holds no sign: an mtime before 1970 made the
167
174
  // header unreadable and every save of it a "corrupt artifact". The
168
175
  // sidecar above carries the real value.
@@ -640,7 +647,9 @@ class Extractor {
640
647
  let n = 0
641
648
  for (const s of this.staged) {
642
649
  const [mode, mtimeMs] = metaFor(s.name)
643
- if ((mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
650
+ // Windows has no mode bits: chmod there only sets the read-only
651
+ // attribute, and a read-only restore refuses the next rename over it.
652
+ if ((mode & 0o777) !== createdMode && !WIN32) chmodSync(s.tmp, mode & 0o777)
644
653
  if (mtimeMs !== undefined) {
645
654
  // A Date, not seconds: Bun reads a negative number of seconds as
646
655
  // "now", and a Date before 1970 as itself (Bun 1.4.2).
@@ -725,8 +734,10 @@ async function linkOutError(base: string, probe: string, realBase: string): Prom
725
734
  * A cycle is the user's tree, not the artifact, so it is refused by name.
726
735
  */
727
736
  async function resolveThrough(p: string): Promise<string> {
728
- const pending = path.resolve(p).split(path.sep).filter(Boolean).reverse()
729
- let at: string = path.sep
737
+ const abs = path.resolve(p)
738
+ // The root is `/` or a drive (`C:\`): joining a drive onto `\` loses it.
739
+ let at = path.parse(abs).root
740
+ const pending = abs.slice(at.length).split(path.sep).filter(Boolean).reverse()
730
741
  let hops = 0
731
742
  while (pending.length > 0) {
732
743
  const next = path.join(at, pending.pop()!)
@@ -747,8 +758,12 @@ async function resolveThrough(p: string): Promise<string> {
747
758
  'write through it. Remove the link and re-run.',
748
759
  )
749
760
  }
750
- if (path.isAbsolute(link)) at = path.sep
751
- pending.push(...link.split(path.sep).filter(Boolean).reverse())
761
+ let rest = link
762
+ if (path.isAbsolute(link)) {
763
+ at = path.parse(link).root
764
+ rest = link.slice(at.length)
765
+ }
766
+ pending.push(...rest.split(LINK_SEP).filter(Boolean).reverse())
752
767
  }
753
768
  return at
754
769
  }
@@ -1211,12 +1211,12 @@ export class Cache implements CacheLayer {
1211
1211
  }): Map<string, string> {
1212
1212
  const outputs = new Map<string, string>()
1213
1213
  for (const f of args.outputFiles) {
1214
- outputs.set(`outputs/${path.relative(args.projectDir, f)}`, f)
1214
+ outputs.set(`outputs/${relPosix(args.projectDir, f)}`, f)
1215
1215
  }
1216
1216
  // Caller passes workspaceRoot whenever workspaceOutputFiles is
1217
1217
  // non-empty; the rels are root-anchored by construction.
1218
1218
  for (const f of args.workspaceOutputFiles ?? []) {
1219
- outputs.set(`${WORKSPACE_OUTPUT_PREFIX}${path.relative(args.workspaceRoot!, f)}`, f)
1219
+ outputs.set(`${WORKSPACE_OUTPUT_PREFIX}${relPosix(args.workspaceRoot!, f)}`, f)
1220
1220
  }
1221
1221
  return outputs
1222
1222
  }
@@ -113,12 +113,10 @@ export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
113
113
  export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]> {
114
114
  const ws = await workspacePlugins(cwd).catch(() => null)
115
115
  if (ws === null || 'loadError' in ws) return []
116
+ // A verb has one owner: the workspace load refuses two plugins declaring it.
116
117
  const lines: string[] = []
117
- const seen = new Set<string>()
118
118
  for (const plugin of ws.plugins) {
119
119
  for (const [verb, command] of Object.entries(plugin.commands ?? {})) {
120
- if (seen.has(verb)) continue
121
- seen.add(verb)
122
120
  lines.push(` vx ${verb.padEnd(17)} ${command.description} (${plugin.name})`)
123
121
  }
124
122
  }
@@ -240,7 +240,7 @@ async function place(lockDir: string): Promise<string | null> {
240
240
  await mkdir(staging)
241
241
  try {
242
242
  await writeFile(path.join(staging, entry), '')
243
- await rename(staging, lockDir)
243
+ await (WIN32 ? renameWin32(staging, lockDir) : rename(staging, lockDir))
244
244
  return entry
245
245
  } catch (err) {
246
246
  await rm(staging, { recursive: true, force: true })
@@ -250,6 +250,36 @@ async function place(lockDir: string): Promise<string | null> {
250
250
  }
251
251
  }
252
252
 
253
+ const WIN32 = process.platform === 'win32'
254
+
255
+ /**
256
+ * Windows renames no directory onto another, even an empty one: EPERM
257
+ * where POSIX gives ENOTEMPTY, which read as a refusal and ran every
258
+ * contender unlocked. A name that exists is taken; an empty one (a
259
+ * holder's leave that lost its rmdir) is removed and the rename tried
260
+ * once more, as POSIX's rename onto it would replace it.
261
+ */
262
+ async function renameWin32(staging: string, lockDir: string): Promise<void> {
263
+ try {
264
+ await rename(staging, lockDir)
265
+ return
266
+ } catch (err) {
267
+ const code = (err as NodeJS.ErrnoException).code
268
+ if (code !== 'EPERM' && code !== 'EACCES') throw err
269
+ const empty = await rmdir(lockDir).then(
270
+ () => true,
271
+ () => false,
272
+ )
273
+ if (!empty) throw Object.assign(new Error('run lock taken'), { code: 'EEXIST' })
274
+ }
275
+ await rename(staging, lockDir).catch((err: NodeJS.ErrnoException) => {
276
+ if (err.code === 'EPERM' || err.code === 'EACCES') {
277
+ throw Object.assign(new Error('run lock taken'), { code: 'EEXIST' })
278
+ }
279
+ throw err
280
+ })
281
+ }
282
+
253
283
  /**
254
284
  * Unlink one taking's entry, then the directory if that left it empty.
255
285
  * Gone already is not an error (another waiter reclaimed it first); a