@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.
- package/README.md +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
package/src/workspace/index.ts
CHANGED
|
@@ -7,10 +7,12 @@ export {
|
|
|
7
7
|
affectedProjects,
|
|
8
8
|
defaultAffectedBase,
|
|
9
9
|
type FingerprintClaims,
|
|
10
|
-
|
|
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)
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
-
* (
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
389
|
+
ui.print('out', ui.excerpt(f.relPath, f.contents))
|
|
346
390
|
}
|
|
347
391
|
} else if (!dry) {
|
|
348
|
-
for (const f of files)
|
|
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 (
|
|
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:
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
// No explanatory comment: the file is the user's (
|
|
633
|
-
function
|
|
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
|
|
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
|
-
|
|
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).
|
|
24
|
-
*
|
|
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
|
|
34
|
-
//
|
|
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[]>()
|