@vzn/vx 0.0.473 → 0.0.474

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.473",
3
+ "version": "0.0.474",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.473",
25
- "@vzn/vx-linux-arm64": "0.0.473",
26
- "@vzn/vx-darwin-x64": "0.0.473",
27
- "@vzn/vx-darwin-arm64": "0.0.473"
24
+ "@vzn/vx-linux-x64": "0.0.474",
25
+ "@vzn/vx-linux-arm64": "0.0.474",
26
+ "@vzn/vx-darwin-x64": "0.0.474",
27
+ "@vzn/vx-darwin-arm64": "0.0.474"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -12,7 +12,9 @@
12
12
  // Named `upgrade` (not `update`) per CLI convention: bun upgrade,
13
13
  // deno upgrade — "update" is what package managers do to indexes.
14
14
 
15
- import { chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
15
+ import { constants } from 'node:fs'
16
+ import { access, chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
17
+ import path from 'node:path'
16
18
  import { flagHint, seeHelp } from './help.js'
17
19
  import { UserError } from '../util/index.js'
18
20
  import { VERSION } from '../version.js'
@@ -218,6 +220,15 @@ export async function replaceBinary(
218
220
  sha256: string,
219
221
  starts?: (dest: string) => boolean,
220
222
  ): Promise<void> {
223
+ // The swap writes beside the binary: a directory this user cannot write
224
+ // (a root-owned /usr/local/bin) failed at the rename, the whole release
225
+ // downloaded for nothing, and the hint named npm, which this binary is not.
226
+ const dir = path.dirname(dest)
227
+ await access(dir, constants.W_OK).catch((err: NodeJS.ErrnoException) => {
228
+ throw new UserError(
229
+ `vx upgrade: cannot write to ${dir} (${err.code ?? err.message}), where this vx lives — nothing downloaded; re-run as a user who can (sudo vx upgrade), or install vx somewhere you can write`,
230
+ )
231
+ })
221
232
  const res = await fetchOrRefuse(url, { redirect: 'follow' }, 'download the release asset')
222
233
  if (!res.ok) {
223
234
  throw new UserError(
@@ -258,8 +269,7 @@ export async function replaceBinary(
258
269
  if (kept) await rm(old, { force: true })
259
270
  const msg = err instanceof Error ? err.message : String(err)
260
271
  throw new UserError(
261
- `vx upgrade: could not replace ${dest} (${msg}) — ` +
262
- `check permissions, or reinstall with npm install -g @vzn/vx`,
272
+ `vx upgrade: could not replace ${dest} (${msg}) — nothing replaced; check the permissions of ${dest} and its directory`,
263
273
  )
264
274
  }
265
275
  if (starts === undefined || starts(dest)) {
@@ -18,8 +18,11 @@ function settledState(abs: string): bigint {
18
18
  return ABSENT
19
19
  }
20
20
  if (!st.isDirectory()) {
21
+ // The mode too: the key reads it (an executable bit can change what a
22
+ // task does), and with the bytes alone a judged file's `chmod -x`
23
+ // settled to "the same" and ran nothing.
21
24
  try {
22
- return xxh3(fs.readFileSync(abs))
25
+ return xxh3(`${xxh3(fs.readFileSync(abs))}:${st.mode & 0o7777}`)
23
26
  } catch {
24
27
  return ABSENT
25
28
  }
@@ -223,6 +223,23 @@ export interface TaskExecutor {
223
223
  execute(req: ExecuteRequest): Promise<ExecuteResult>
224
224
  }
225
225
 
226
+ /**
227
+ * What a remote executor rejects with when it gives a task back: core runs
228
+ * the same request on the local floor and says `reason` once (a remote that
229
+ * never started it, B-100). A task placed `remote: 'only'` must not run
230
+ * here, so it fails naming `reason` instead. Matched by name, as
231
+ * `isUserError` is: a plugin's `@vzn/vx` can be another copy of this class.
232
+ */
233
+ export function executorFallback(reason: string): Error {
234
+ const err = new Error(reason)
235
+ err.name = 'ExecutorFallback'
236
+ return err
237
+ }
238
+
239
+ export function isExecutorFallback(err: unknown): err is Error {
240
+ return err instanceof Error && err.name === 'ExecutorFallback'
241
+ }
242
+
226
243
  /**
227
244
  * The result an executor resolves with is a plugin's, so its shape is a
228
245
  * boundary: a plugin that resolved `{}` met `res.violations` in core and
package/src/exec/index.ts CHANGED
@@ -41,6 +41,8 @@ export { isLocalExecutor, localExecutor } from './local-executor.js'
41
41
  export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
42
42
  export {
43
43
  assertExecuteResult,
44
+ executorFallback,
45
+ isExecutorFallback,
44
46
  selectExecutor,
45
47
  type ExecuteRequest,
46
48
  type ExecuteResult,
package/src/index.ts CHANGED
@@ -171,6 +171,9 @@ export type {
171
171
  // (`runCommand` / `runSandboxed`, the local executor's own primitives, left
172
172
  // the façade 2026-09-10: no executor plugin built on them — `@vzn/vx-reapi`
173
173
  // speaks a wire — and a seam with no consumer is a special case in waiting.)
174
+ // A remote executor gives a task back to the local floor by rejecting with
175
+ // this (B-100): core runs it here and says why once.
176
+ export { executorFallback } from './exec/index.js'
174
177
  export type {
175
178
  ExecuteRequest,
176
179
  ExecuteResult,
@@ -25,6 +25,8 @@ import {
25
25
  wrapSandboxedCommand,
26
26
  signalExitCode,
27
27
  isLocalExecutor,
28
+ isExecutorFallback,
29
+ localExecutor,
28
30
  type CaptureConfig,
29
31
  type ExecuteRequest,
30
32
  assertExecuteResult,
@@ -45,6 +47,7 @@ import {
45
47
  secretMask,
46
48
  secretNamed,
47
49
  span,
50
+ UserError,
48
51
  } from '../util/index.js'
49
52
  import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
50
53
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
@@ -895,7 +898,23 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
895
898
  assertExecuteResult(args.executor.name, node.id, r)
896
899
  return r
897
900
  })
898
- .catch(async (raw: unknown) => {
901
+ .catch(async (thrown: unknown) => {
902
+ let raw = thrown
903
+ // A remote that gives the task back (it never started it): run it on
904
+ // the local floor with a request of its own, so its timeout counts
905
+ // from now. `remote: 'only'` keeps it off this machine: refused.
906
+ if (isExecutorFallback(raw) && args.executor.remote === true) {
907
+ const why = secrets?.mask(raw.message) ?? raw.message
908
+ if (remoteOnly) {
909
+ raw = new UserError(`${why}, and remote: 'only' keeps it off this machine`)
910
+ } else {
911
+ log.status(`[vx] ${node.id}: ${why} — running it here`)
912
+ clearTimeout(timeoutTimer)
913
+ const local = await localExecutor().execute(await buildRequest())
914
+ assertExecuteResult('local', node.id, local)
915
+ return local
916
+ }
917
+ }
899
918
  const err = nameExecutorFailure(args.executor, raw)
900
919
  // A remote executor's message carries the server's own text, which
901
920
  // may echo the env it was sent: masked here, where it is printed,