@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/util/config-frame.ts
CHANGED
|
@@ -13,6 +13,19 @@ import path from 'node:path'
|
|
|
13
13
|
const HEAD =
|
|
14
14
|
/^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (?:(tasks\.[^\s:]+)(:?)|`([^`\n]+)`|task name ("(?:[^"\\\n]|\\.)*")))? /
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* A quoted name as the source spells it. A glob refusal quotes the glob
|
|
18
|
+
* raw, so `"app/\\[id\\]"` is no JSON string: parsed alone, vx lock crashed
|
|
19
|
+
* on it in place of the refusal.
|
|
20
|
+
*/
|
|
21
|
+
function unquote(quoted: string): string {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(quoted) as string
|
|
24
|
+
} catch {
|
|
25
|
+
return quoted.slice(1, -1)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
/**
|
|
17
30
|
* The message with its file named at `line:col`, relative to `cwd`, and a
|
|
18
31
|
* frame of the source under it; undefined when the message names no
|
|
@@ -32,11 +45,25 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
|
|
|
32
45
|
const rest = message.slice(head[0].length)
|
|
33
46
|
// The field the refusal is about, past the path that leads to it:
|
|
34
47
|
// `has unknown field "comand"`, or `tasks.build: \`cache\` is not allowed`.
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
// A refused key is quoted: `tasks.b.exec.env.define: "A=B" is not …`.
|
|
49
|
+
const said =
|
|
50
|
+
colon === ':'
|
|
51
|
+
? /^(?:`([^`]+)`|("(?:[^"\\\n]|\\.)*"))/.exec(rest)
|
|
52
|
+
: /^has (?:unknown )?field "([^"]+)"/.exec(rest)
|
|
53
|
+
const named = said === null ? null : [said[0], said[1] ?? unquote(said[2]!)]
|
|
54
|
+
// `plugins[2].name`: an element of an array, framed at the element (a
|
|
55
|
+
// plugin is a call, so no key inside it is searched).
|
|
56
|
+
const element = setting === undefined ? null : /^([^[]+)\[(\d+)\]/.exec(setting)
|
|
37
57
|
const fieldPath =
|
|
38
|
-
taskName
|
|
39
|
-
|
|
58
|
+
taskName !== undefined
|
|
59
|
+
? ['tasks', unquote(taskName)]
|
|
60
|
+
: element !== null
|
|
61
|
+
? element[1]!.split('.')
|
|
62
|
+
: (tasksPath ?? setting)?.split('.')
|
|
63
|
+
const segments = [
|
|
64
|
+
...(fieldPath ?? []),
|
|
65
|
+
...(named === null || element !== null ? [] : [named[1]!]),
|
|
66
|
+
]
|
|
40
67
|
if (segments.length === 0) return undefined
|
|
41
68
|
let source: string
|
|
42
69
|
try {
|
|
@@ -44,7 +71,7 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
|
|
|
44
71
|
} catch {
|
|
45
72
|
return undefined
|
|
46
73
|
}
|
|
47
|
-
const at = locate(source, segments)
|
|
74
|
+
const at = locate(source, segments, element === null ? undefined : Number(element[2]))
|
|
48
75
|
if (at === undefined) return undefined
|
|
49
76
|
return framed(file, source, at, message.slice(file.length), cwd)
|
|
50
77
|
}
|
|
@@ -54,8 +81,71 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
|
|
|
54
81
|
const POSITION =
|
|
55
82
|
/^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s):(\d+):(\d+)(: [^]*)$/
|
|
56
83
|
|
|
84
|
+
// The loader's `Project config <file> threw: <message> (at <file>:<line>:<col>)`:
|
|
85
|
+
// the config's own throw, placed by its stack in the config itself.
|
|
86
|
+
const THREW =
|
|
87
|
+
/^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s) threw: ([^\n]*?) \(at (\/[^\n]*?):(\d+):(\d+)\)([^]*)$/
|
|
88
|
+
|
|
89
|
+
// The loader's `<kind> config <file>: cannot find '<pkg>' …`: placed at the
|
|
90
|
+
// specifier. And `<kind> config at <file> did not export a default object`:
|
|
91
|
+
// at the `export default` that exported something else, when there is one.
|
|
92
|
+
const UNPROVIDED =
|
|
93
|
+
/^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s): (cannot find '([^'\n]+)'[^]*)$/
|
|
94
|
+
const NOT_OBJECT =
|
|
95
|
+
/^(?:Project|Workspace) config at (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s) (did not export a default object[^]*)$/
|
|
96
|
+
|
|
97
|
+
/** The file, the source offset `find` places in it, and what follows `<file>:<line>:<col>`. */
|
|
98
|
+
function sourceFrame(
|
|
99
|
+
file: string,
|
|
100
|
+
find: (source: string) => number,
|
|
101
|
+
rest: string,
|
|
102
|
+
cwd: string,
|
|
103
|
+
): string | undefined {
|
|
104
|
+
let source: string
|
|
105
|
+
try {
|
|
106
|
+
source = readFileSync(file, 'utf8')
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
const at = find(source)
|
|
111
|
+
if (at < 0) return undefined
|
|
112
|
+
const before = source.slice(0, at)
|
|
113
|
+
const line = before.split('\n').length
|
|
114
|
+
return framed(file, source, { line, col: at - before.lastIndexOf('\n') }, rest, cwd)
|
|
115
|
+
}
|
|
116
|
+
|
|
57
117
|
function positionFrame(message: string, cwd: string): string | undefined {
|
|
58
|
-
const
|
|
118
|
+
const unprovided = UNPROVIDED.exec(message)
|
|
119
|
+
if (unprovided !== null) {
|
|
120
|
+
const [, file, said, pkg] = unprovided as unknown as [string, string, string, string]
|
|
121
|
+
const spec = new RegExp(
|
|
122
|
+
`(?<=\\b(?:from|import|import\\(|require\\()\\s*)(['"])${escapeRe(pkg)}\\1`,
|
|
123
|
+
)
|
|
124
|
+
return sourceFrame(file, (src) => spec.exec(src)?.index ?? -1, `: ${said}`, cwd)
|
|
125
|
+
}
|
|
126
|
+
const notObject = NOT_OBJECT.exec(message)
|
|
127
|
+
if (notObject !== null) {
|
|
128
|
+
const [, file, said] = notObject as unknown as [string, string, string]
|
|
129
|
+
return sourceFrame(
|
|
130
|
+
file,
|
|
131
|
+
// No default: at the named export written instead, when there is one.
|
|
132
|
+
(src) => (/^export\s+default\b/m.exec(src) ?? /^export\b/m.exec(src))?.index ?? -1,
|
|
133
|
+
`: ${said}`,
|
|
134
|
+
cwd,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
const threw = THREW.exec(message)
|
|
138
|
+
const pos =
|
|
139
|
+
threw !== null && threw[3] === threw[1]
|
|
140
|
+
? [
|
|
141
|
+
threw[0],
|
|
142
|
+
threw[1],
|
|
143
|
+
threw[4],
|
|
144
|
+
threw[5],
|
|
145
|
+
// A cause's own `(at …)` names the same file: relative there too.
|
|
146
|
+
`: threw: ${threw[2]}${threw[6]!.replaceAll(threw[1]!, path.relative(cwd, threw[1]!))}`,
|
|
147
|
+
]
|
|
148
|
+
: POSITION.exec(message)
|
|
59
149
|
if (pos === null) return undefined
|
|
60
150
|
const [, file, line, col, rest] = pos as unknown as [string, string, string, string, string]
|
|
61
151
|
let source: string
|
|
@@ -105,6 +195,8 @@ function framed(
|
|
|
105
195
|
function locate(
|
|
106
196
|
source: string,
|
|
107
197
|
segments: readonly string[],
|
|
198
|
+
/** The element of the array the last segment holds; past its end, the key. */
|
|
199
|
+
index?: number,
|
|
108
200
|
): { line: number; col: number } | undefined {
|
|
109
201
|
let pos = 0
|
|
110
202
|
let end = source.length
|
|
@@ -137,6 +229,10 @@ function locate(
|
|
|
137
229
|
i = next
|
|
138
230
|
}
|
|
139
231
|
if (found < 0) return undefined
|
|
232
|
+
if (index !== undefined && i === segments.length) {
|
|
233
|
+
const el = elementStart(source, pos, index)
|
|
234
|
+
if (el !== undefined) found = el
|
|
235
|
+
}
|
|
140
236
|
const before = source.slice(0, found)
|
|
141
237
|
const line = before.split('\n').length
|
|
142
238
|
return { line, col: found - before.lastIndexOf('\n') }
|
|
@@ -172,6 +268,46 @@ function literalEnd(source: string, from: number): number {
|
|
|
172
268
|
return source.length
|
|
173
269
|
}
|
|
174
270
|
|
|
271
|
+
/** Where element `index` of the array literal at `from` (past blanks) starts, if it has one. */
|
|
272
|
+
function elementStart(source: string, from: number, index: number): number | undefined {
|
|
273
|
+
let i = from
|
|
274
|
+
while (i < source.length && /\s/.test(source[i]!)) i++
|
|
275
|
+
if (source[i] !== '[') return undefined
|
|
276
|
+
let depth = 0
|
|
277
|
+
let n = 0
|
|
278
|
+
// Each element starts at the first code past `[` or a top-level `,`.
|
|
279
|
+
let pending = true
|
|
280
|
+
for (i++; i < source.length; i++) {
|
|
281
|
+
const c = source[i]!
|
|
282
|
+
if (c === '/' && source[i + 1] === '/') {
|
|
283
|
+
i = source.indexOf('\n', i)
|
|
284
|
+
if (i < 0) return undefined
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
if (c === '/' && source[i + 1] === '*') {
|
|
288
|
+
i = source.indexOf('*/', i + 2)
|
|
289
|
+
if (i < 0) return undefined
|
|
290
|
+
i++
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
if (/\s/.test(c)) continue
|
|
294
|
+
if (depth === 0 && (c === ']' || c === ')' || c === '}')) return undefined
|
|
295
|
+
if (pending) {
|
|
296
|
+
if (n === index) return i
|
|
297
|
+
pending = false
|
|
298
|
+
}
|
|
299
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
300
|
+
for (i++; i < source.length && source[i] !== c; i++) if (source[i] === '\\') i++
|
|
301
|
+
} else if (c === '{' || c === '[' || c === '(') depth++
|
|
302
|
+
else if (c === '}' || c === ']' || c === ')') depth--
|
|
303
|
+
else if (c === ',' && depth === 0) {
|
|
304
|
+
n++
|
|
305
|
+
pending = true
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
|
|
175
311
|
function escapeRe(s: string): string {
|
|
176
312
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
177
313
|
}
|
|
@@ -85,11 +85,27 @@ export function nearMatches(name: string, candidates: Iterable<string>, limit =
|
|
|
85
85
|
if (out.length >= limit) break
|
|
86
86
|
if (out.includes(c)) continue
|
|
87
87
|
const n = c.toLowerCase()
|
|
88
|
-
if (n.includes(q) || q
|
|
88
|
+
if (n.includes(q) || wordIn(q, n)) out.push(c)
|
|
89
89
|
}
|
|
90
90
|
return out.slice(0, limit)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* `name` stands in `query` as a whole word (`build` in `build-and-test`):
|
|
95
|
+
* a bare substring offered `ui` for `guide` and `a` for anything.
|
|
96
|
+
*/
|
|
97
|
+
function wordIn(query: string, name: string): boolean {
|
|
98
|
+
for (let i = query.indexOf(name); i !== -1; i = query.indexOf(name, i + 1)) {
|
|
99
|
+
const end = i + name.length
|
|
100
|
+
if (
|
|
101
|
+
(i === 0 || !/[a-z0-9]/.test(query[i - 1]!)) &&
|
|
102
|
+
(end === query.length || !/[a-z0-9]/.test(query[end]!))
|
|
103
|
+
)
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
|
|
93
109
|
/**
|
|
94
110
|
* The names to offer when no one name is near: sorted, the first `limit`,
|
|
95
111
|
* then a count. A typo past two edits named nothing to pick from (M-56).
|
package/src/util/errors.ts
CHANGED
|
@@ -86,16 +86,22 @@ export function errorCode(err: unknown): string {
|
|
|
86
86
|
* Does this argv ask for JSON (`--format json`, `--format=json`,
|
|
87
87
|
* `--dry=json`)? Then a refusal is a JSON document on stdout too, so a
|
|
88
88
|
* reader of stdout gets an answer, not nothing. Args after `--` are the
|
|
89
|
-
* task's, not vx's.
|
|
89
|
+
* task's, not vx's. Each flag's LAST value counts, as every verb's parser
|
|
90
|
+
* keeps it: `--format json --format pretty` prints pretty. `--dry` alone is
|
|
91
|
+
* run's pretty plan, and a switch beside `--format` elsewhere.
|
|
90
92
|
*/
|
|
91
93
|
export function wantsJson(argv: readonly string[]): boolean {
|
|
94
|
+
let format: string | undefined
|
|
95
|
+
let dry: string | undefined
|
|
92
96
|
for (let i = 0; i < argv.length; i++) {
|
|
93
97
|
const a = argv[i]!
|
|
94
|
-
if (a === '--')
|
|
95
|
-
if (a === '--format
|
|
96
|
-
if (a
|
|
98
|
+
if (a === '--') break
|
|
99
|
+
if (a === '--format') format = argv[++i]
|
|
100
|
+
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
101
|
+
else if (a === '--dry') dry = 'pretty'
|
|
102
|
+
else if (a.startsWith('--dry=')) dry = a.slice('--dry='.length)
|
|
97
103
|
}
|
|
98
|
-
return
|
|
104
|
+
return format === 'json' || dry === 'json'
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
/**
|
|
@@ -185,9 +191,18 @@ export function isOutOfFds(err: unknown): err is NodeJS.ErrnoException {
|
|
|
185
191
|
export const OUT_OF_FDS_HINT =
|
|
186
192
|
'the process is out of file descriptors; raise the limit (ulimit -n 4096) and re-run'
|
|
187
193
|
|
|
188
|
-
/**
|
|
194
|
+
/**
|
|
195
|
+
* `ENAMETOOLONG`: a path past what the file system takes (PATH_MAX, 4096
|
|
196
|
+
* bytes on Linux; NAME_MAX for one name). The path is the user's: an
|
|
197
|
+
* output that deep failed its save as an internal error.
|
|
198
|
+
*/
|
|
199
|
+
function isNameTooLong(err: unknown): err is NodeJS.ErrnoException {
|
|
200
|
+
return err instanceof Error && (err as NodeJS.ErrnoException).code === 'ENAMETOOLONG'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A file system refusing a path for a reason no code path caused: permission, space or length. */
|
|
189
204
|
export function isFsRefusal(err: unknown): err is NodeJS.ErrnoException {
|
|
190
|
-
return isPermissionError(err) || isDiskFull(err)
|
|
205
|
+
return isPermissionError(err) || isDiskFull(err) || isNameTooLong(err)
|
|
191
206
|
}
|
|
192
207
|
|
|
193
208
|
export const DISK_FULL_HINT = 'the disk that path is on is full'
|
|
@@ -205,6 +220,7 @@ const READ_SYSCALLS = new Set(['scandir', 'stat', 'lstat', 'readlink', 'read', '
|
|
|
205
220
|
export function fsRefusalHint(err: NodeJS.ErrnoException): string {
|
|
206
221
|
const { syscall } = err
|
|
207
222
|
if (isDiskFull(err)) return DISK_FULL_HINT
|
|
223
|
+
if (isNameTooLong(err)) return 'that path is longer than the file system allows; shorten it'
|
|
208
224
|
// A listing told to be a write sent the user to chmod the wrong thing.
|
|
209
225
|
return READ_SYSCALLS.has(syscall ?? '') ? READ_PERMISSION_HINT : PERMISSION_HINT
|
|
210
226
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { UserError } from './errors.js'
|
|
5
|
+
|
|
6
|
+
export const OWN_DIR_HINT =
|
|
7
|
+
'Point `cache.dir` in .vx/workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
|
|
8
|
+
|
|
9
|
+
/** Cache directories this process already found to be vx's own, with the workspace root asked for. */
|
|
10
|
+
const passed = new Set<string>()
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Refuses a cache directory that resolves to the home directory, the file
|
|
14
|
+
* system root, or a directory holding the workspace or a repository:
|
|
15
|
+
* compared unresolved, a `.vx/cache` linked to `~` or `..` passed, the run
|
|
16
|
+
* wrote its index and config bundle there, and `vx cache clean` deleted
|
|
17
|
+
* every entry (the home directory; the workspace's `.git` and config). The
|
|
18
|
+
* config bundle asks first and the cache's open again: one realpath, as a
|
|
19
|
+
* pass is kept for the process; a refusal is decided afresh each time. A
|
|
20
|
+
* directory not there yet is made as named, by its caller.
|
|
21
|
+
*/
|
|
22
|
+
export function refuseForeignCacheDir(cacheDir: string, workspaceRoot: string | undefined): void {
|
|
23
|
+
const memo = `${path.resolve(cacheDir)}\0${workspaceRoot ?? ''}`
|
|
24
|
+
if (passed.has(memo)) return
|
|
25
|
+
let real: string
|
|
26
|
+
try {
|
|
27
|
+
real = realpathSync(cacheDir)
|
|
28
|
+
} catch {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const home = process.env['HOME'] || homedir()
|
|
32
|
+
let what: string | undefined
|
|
33
|
+
if (real === path.parse(real).root) what = 'the file system root'
|
|
34
|
+
else if (sameDir(real, home)) what = 'the home directory'
|
|
35
|
+
else if (
|
|
36
|
+
(workspaceRoot !== undefined && !path.relative(real, workspaceRoot).startsWith('..')) ||
|
|
37
|
+
existsSync(path.join(real, '.git'))
|
|
38
|
+
)
|
|
39
|
+
what = 'a directory holding the workspace or a repository'
|
|
40
|
+
if (what === undefined) {
|
|
41
|
+
passed.add(memo)
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
throw new UserError(
|
|
45
|
+
`cache directory ${cacheDir} resolves to ${real}, ${what}: vx would keep its index there and \`vx cache clean\` would delete from it. ${OWN_DIR_HINT}`,
|
|
46
|
+
'VX_E_CONFIG',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sameDir(a: string, b: string): boolean {
|
|
51
|
+
if (a === path.resolve(b)) return true
|
|
52
|
+
// A home named through a link: stats only when the names could agree.
|
|
53
|
+
if (path.basename(a) !== path.basename(b)) return false
|
|
54
|
+
try {
|
|
55
|
+
const [x, y] = [statSync(a), statSync(b)]
|
|
56
|
+
return x.ino === y.ino && x.dev === y.dev
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/util/git-ignored.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// knows every rule source (nested files, `info/exclude`, `core.excludesFile`)
|
|
5
5
|
// and that a directory holding a tracked file is not ignored.
|
|
6
6
|
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
7
9
|
import { gitEnv } from './hide-env.js'
|
|
8
10
|
import { isLiteralPattern, normalizeGlob } from './paths.js'
|
|
9
11
|
import { executablePath } from './which.js'
|
|
@@ -37,6 +39,56 @@ export function ignoreProbes(glob: string): { paths: string[]; deep: boolean; di
|
|
|
37
39
|
|
|
38
40
|
const CHECK_IGNORE = ['check-ignore', '--stdin', '-z']
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Per directory git runs in, whether its `core.ignorecase=true` is false on
|
|
44
|
+
* disk: a `.git` copied from macOS to Linux keeps the setting, and git then
|
|
45
|
+
* takes a new `a.txt` beside the tracked `A.txt` for the tracked file and
|
|
46
|
+
* lists it nowhere. Asked only where git's config was read anyway.
|
|
47
|
+
*/
|
|
48
|
+
const caseLies = new Map<string, boolean>()
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether `core.ignorecase=true`, which git in `dir` reported, is false on
|
|
52
|
+
* disk: one stat of `probe` (an existing path) with its name's case
|
|
53
|
+
* swapped, once per `dir`. A name with no cased letter cannot tell, so git
|
|
54
|
+
* is trusted.
|
|
55
|
+
*/
|
|
56
|
+
export function ignoreCaseLies(dir: string, probe: string): boolean {
|
|
57
|
+
const at = path.resolve(dir)
|
|
58
|
+
let lies = caseLies.get(at)
|
|
59
|
+
if (lies === undefined) {
|
|
60
|
+
const name = path.basename(probe)
|
|
61
|
+
const lower = name.toLowerCase()
|
|
62
|
+
const twin = lower === name ? name.toUpperCase() : lower
|
|
63
|
+
lies = twin !== name && !existsSync(path.join(path.dirname(probe), twin))
|
|
64
|
+
caseLies.set(at, lies)
|
|
65
|
+
}
|
|
66
|
+
return lies
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What a git spawn in `cwd` prepends so a wrong `core.ignorecase` hides no
|
|
71
|
+
* file: `-c core.ignorecase=false` at or under a directory `ignoreCaseLies`
|
|
72
|
+
* found lying (the disk there tells case apart, so the override is git's
|
|
73
|
+
* own answer for any repository on it), else nothing.
|
|
74
|
+
*/
|
|
75
|
+
export function gitCaseArgs(cwd: string): readonly string[] {
|
|
76
|
+
if (caseLies.size === 0) return []
|
|
77
|
+
const at = path.resolve(cwd)
|
|
78
|
+
for (const [dir, lies] of caseLies)
|
|
79
|
+
if (lies && (at === dir || at.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)))
|
|
80
|
+
return CASE_SENSITIVE
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const CASE_SENSITIVE = ['-c', 'core.ignorecase=false']
|
|
85
|
+
|
|
86
|
+
// git reads each line as a pathspec, so `:top/dist/` was asked as
|
|
87
|
+
// `top/dist/`. `./` keeps it the path it is (this command refuses
|
|
88
|
+
// `:(literal)`), and git echoes each path back as given.
|
|
89
|
+
const stdinOf = (paths: readonly string[]): Buffer =>
|
|
90
|
+
Buffer.from(paths.map((p) => `./${p}\0`).join(''))
|
|
91
|
+
|
|
40
92
|
function ignoredOf(exitCode: number, stdout: Uint8Array): Set<string> | null {
|
|
41
93
|
// 0: some ignored; 1: none; anything else (no work tree): undecided.
|
|
42
94
|
if (exitCode === 1) return new Set()
|
|
@@ -45,7 +97,8 @@ function ignoredOf(exitCode: number, stdout: Uint8Array): Set<string> | null {
|
|
|
45
97
|
new TextDecoder()
|
|
46
98
|
.decode(stdout)
|
|
47
99
|
.split('\0')
|
|
48
|
-
.filter((p) => p !== '')
|
|
100
|
+
.filter((p) => p !== '')
|
|
101
|
+
.map((p) => p.slice(2)),
|
|
49
102
|
)
|
|
50
103
|
}
|
|
51
104
|
|
|
@@ -57,10 +110,10 @@ export async function checkIgnore(
|
|
|
57
110
|
if (paths.length === 0) return new Set()
|
|
58
111
|
try {
|
|
59
112
|
const proc = Bun.spawn({
|
|
60
|
-
cmd: [executablePath('git'), ...CHECK_IGNORE],
|
|
113
|
+
cmd: [executablePath('git'), ...gitCaseArgs(cwd), ...CHECK_IGNORE],
|
|
61
114
|
env: gitEnv(),
|
|
62
115
|
cwd,
|
|
63
|
-
stdin:
|
|
116
|
+
stdin: stdinOf(paths),
|
|
64
117
|
stdout: 'pipe',
|
|
65
118
|
stderr: 'ignore',
|
|
66
119
|
})
|
|
@@ -76,10 +129,10 @@ export function checkIgnoreSync(cwd: string, paths: readonly string[]): Set<stri
|
|
|
76
129
|
if (paths.length === 0) return new Set()
|
|
77
130
|
try {
|
|
78
131
|
const r = Bun.spawnSync({
|
|
79
|
-
cmd: [executablePath('git'), ...CHECK_IGNORE],
|
|
132
|
+
cmd: [executablePath('git'), ...gitCaseArgs(cwd), ...CHECK_IGNORE],
|
|
80
133
|
env: gitEnv(),
|
|
81
134
|
cwd,
|
|
82
|
-
stdin:
|
|
135
|
+
stdin: stdinOf(paths),
|
|
83
136
|
stdout: 'pipe',
|
|
84
137
|
stderr: 'ignore',
|
|
85
138
|
})
|
|
@@ -99,6 +152,7 @@ export function listedSync(cwd: string, dirs: readonly string[]): string[] | nul
|
|
|
99
152
|
const r = Bun.spawnSync({
|
|
100
153
|
cmd: [
|
|
101
154
|
executablePath('git'),
|
|
155
|
+
...gitCaseArgs(cwd),
|
|
102
156
|
'ls-files',
|
|
103
157
|
'-z',
|
|
104
158
|
'--cached',
|
package/src/util/index.ts
CHANGED
|
@@ -75,6 +75,7 @@ export {
|
|
|
75
75
|
dieOf,
|
|
76
76
|
exitClaimedBySignal,
|
|
77
77
|
killGraceMs,
|
|
78
|
+
POST_EXIT_DRAIN_MS,
|
|
78
79
|
settleWithin,
|
|
79
80
|
teardownTimeoutMs,
|
|
80
81
|
testTimerMs,
|
|
@@ -101,18 +102,30 @@ export { gitEnv, helperEnv, hideEnvFromTasks } from './hide-env.js'
|
|
|
101
102
|
export {
|
|
102
103
|
checkIgnore,
|
|
103
104
|
checkIgnoreSync,
|
|
105
|
+
gitCaseArgs,
|
|
106
|
+
ignoreCaseLies,
|
|
104
107
|
ignoreProbes,
|
|
105
108
|
listedSync,
|
|
106
109
|
wholeIgnored,
|
|
107
110
|
} from './git-ignored.js'
|
|
111
|
+
export { OWN_DIR_HINT, refuseForeignCacheDir } from './foreign-cache-dir.js'
|
|
108
112
|
export { realPath, realpathOf } from './real-path.js'
|
|
109
|
-
export {
|
|
113
|
+
export {
|
|
114
|
+
IGNORE_ALL,
|
|
115
|
+
ignoreDir,
|
|
116
|
+
type PendingWrite,
|
|
117
|
+
refuseSymlink,
|
|
118
|
+
writeAllOrNone,
|
|
119
|
+
writeNoFollow,
|
|
120
|
+
} from './write-file.js'
|
|
110
121
|
export {
|
|
111
122
|
heldSecrets,
|
|
123
|
+
holdSecret,
|
|
112
124
|
maskedCommand,
|
|
113
125
|
maskedStreams,
|
|
114
126
|
maskedLine,
|
|
115
127
|
MASKED,
|
|
128
|
+
maskStreams,
|
|
116
129
|
secretMask,
|
|
117
130
|
secretNamed,
|
|
118
131
|
type SecretMask,
|
|
@@ -134,3 +147,4 @@ export {
|
|
|
134
147
|
parseDependencySpec,
|
|
135
148
|
type DependencySpec,
|
|
136
149
|
} from './dependency-spec.js'
|
|
150
|
+
export { stripEscapes } from './plain-text.js'
|
package/src/util/paths.ts
CHANGED
|
@@ -202,7 +202,7 @@ export function isLiteralPattern(glob: string): boolean {
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
/**
|
|
205
|
-
* The alphabet of a TASK glob (`inputs.files`, `
|
|
205
|
+
* The alphabet of a TASK glob (`inputs.files`, `outputs.files`,
|
|
206
206
|
* `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
|
|
207
207
|
* need the POSITION of the first wildcard rather than a verdict. Every site
|
|
208
208
|
* that asks "must this declaration be matched?" reads this one regex: item
|
|
@@ -306,13 +306,50 @@ function compileAnyTaskGlob(rawPatterns: readonly string[]): (rel: string) => bo
|
|
|
306
306
|
if (patterns.length === 0) return () => false
|
|
307
307
|
const sources = patterns.map(regExpSource)
|
|
308
308
|
const globs = patterns.map(taskGlob)
|
|
309
|
-
if (sources.every((r) => r !== null))
|
|
310
|
-
|
|
309
|
+
if (!sources.every((r) => r !== null)) return (rel) => globs.some((g) => g.match(rel))
|
|
310
|
+
// Each `**/` alternative opened with its own prefix, retried at every
|
|
311
|
+
// slash of the path: 1000 `**/fileN.ts` globs took 24 s over 200,000
|
|
312
|
+
// paths. One shared prefix before their alternation takes 50 ms.
|
|
313
|
+
const deep: string[] = []
|
|
314
|
+
const rest: string[] = []
|
|
315
|
+
for (const s of sources as string[]) {
|
|
316
|
+
if (s.startsWith(LEADING_GLOBSTAR)) deep.push(s.slice(LEADING_GLOBSTAR.length))
|
|
317
|
+
else rest.push(s)
|
|
318
|
+
}
|
|
319
|
+
const res = alternations(rest)
|
|
320
|
+
for (let i = 0; i < deep.length; i += ALTERNATIVES_PER_REGEXP) {
|
|
321
|
+
const part = deep.slice(i, i + ALTERNATIVES_PER_REGEXP)
|
|
322
|
+
res.push(new RegExp(`^${LEADING_GLOBSTAR}(?:${part.join('|')})$`, 'u'))
|
|
323
|
+
}
|
|
324
|
+
if (res.length === 1) {
|
|
325
|
+
const re = res[0]!
|
|
311
326
|
return (rel) => (rel.endsWith('/') ? globs.some((g) => g.match(rel)) : re.test(rel))
|
|
312
327
|
}
|
|
313
|
-
return (rel) =>
|
|
328
|
+
return (rel) =>
|
|
329
|
+
rel.endsWith('/') ? globs.some((g) => g.match(rel)) : res.some((re) => re.test(rel))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Alternatives per compiled RegExp. Bun's engine falls off a cliff past a
|
|
334
|
+
* few thousand: over 200k paths, 2000 literal-file alternatives took 32 ms,
|
|
335
|
+
* 4000 took 482 ms and 8000 took 15 s, so a project listing 4000 files in
|
|
336
|
+
* `inputs.files` paid that on every key. Below the cliff, the cost of
|
|
337
|
+
* splitting is flat.
|
|
338
|
+
*/
|
|
339
|
+
const ALTERNATIVES_PER_REGEXP = 1000
|
|
340
|
+
|
|
341
|
+
function alternations(sources: readonly string[]): RegExp[] {
|
|
342
|
+
const out: RegExp[] = []
|
|
343
|
+
for (let i = 0; i < sources.length; i += ALTERNATIVES_PER_REGEXP) {
|
|
344
|
+
const part = sources.slice(i, i + ALTERNATIVES_PER_REGEXP)
|
|
345
|
+
out.push(new RegExp(`^(?:${part.join('|')})$`, 'u'))
|
|
346
|
+
}
|
|
347
|
+
return out
|
|
314
348
|
}
|
|
315
349
|
|
|
350
|
+
/** What {@link regExpSource} writes for a leading `**` segment. */
|
|
351
|
+
const LEADING_GLOBSTAR = '(?:[^]*/)?'
|
|
352
|
+
|
|
316
353
|
const REGEXP_SYNTAX = /[\\^$.*+?()[\]{}|]/g
|
|
317
354
|
|
|
318
355
|
/**
|
|
@@ -336,7 +373,7 @@ function regExpSource(pattern: string): string | null {
|
|
|
336
373
|
// more names (`src/**` does not match `src`).
|
|
337
374
|
if (segments.length === 1) out += '[^]*'
|
|
338
375
|
else if (last) out += '/[^]+'
|
|
339
|
-
else out += i === 0 ?
|
|
376
|
+
else out += i === 0 ? LEADING_GLOBSTAR : `/${LEADING_GLOBSTAR}`
|
|
340
377
|
continue
|
|
341
378
|
}
|
|
342
379
|
if (seg === '' || seg === '.' || seg === '..' || seg.includes('**')) return null
|
|
@@ -535,9 +572,15 @@ function classEnd(pattern: string, i: number): number {
|
|
|
535
572
|
return end === -1 ? i : end
|
|
536
573
|
}
|
|
537
574
|
|
|
575
|
+
// A `\x` is one character to both scans: it opens, closes and cuts nothing,
|
|
576
|
+
// and stays in its alternative for the glob to read. Expansion turned off
|
|
577
|
+
// at any escape, `{src,app/\[id\]}` named neither tree.
|
|
538
578
|
function expandBraces(pattern: string, slashOnly: boolean): string[] {
|
|
539
|
-
if (pattern.includes('\\')) return [pattern]
|
|
540
579
|
for (let open = 0; open < pattern.length; open++) {
|
|
580
|
+
if (pattern[open] === '\\') {
|
|
581
|
+
open++
|
|
582
|
+
continue
|
|
583
|
+
}
|
|
541
584
|
if (pattern[open] === '[') {
|
|
542
585
|
open = classEnd(pattern, open)
|
|
543
586
|
continue
|
|
@@ -548,7 +591,8 @@ function expandBraces(pattern: string, slashOnly: boolean): string[] {
|
|
|
548
591
|
let close = -1
|
|
549
592
|
for (let i = open; i < pattern.length && close === -1; i++) {
|
|
550
593
|
const c = pattern[i]
|
|
551
|
-
if (c === '
|
|
594
|
+
if (c === '\\') i++
|
|
595
|
+
else if (c === '[') i = classEnd(pattern, i)
|
|
552
596
|
else if (c === '{') depth++
|
|
553
597
|
else if (c === '}' && --depth === 0) close = i
|
|
554
598
|
else if (c === ',' && depth === 1) cuts.push(i)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A string escape (OSC, DCS, SOS, PM, APC) through its terminator, or to the
|
|
2
|
+
// line's end when it has none: Bun.stripANSI drops an unterminated one with
|
|
3
|
+
// everything after it, so a stray title escape ate the failure reason
|
|
4
|
+
// printed below it. An ESC that is not ST ends it too and is left for
|
|
5
|
+
// Bun.stripANSI, as a terminal aborts the string there. C1 openers are not
|
|
6
|
+
// looked for: output is decoded UTF-8, where an 8-bit C1 byte reads as
|
|
7
|
+
// U+FFFD, and scanning for them doubled the cost on non-Latin-1 text.
|
|
8
|
+
// eslint-disable-next-line no-control-regex -- ESC is the point
|
|
9
|
+
const STRING_OPENER = /\x1b[\]P_^X]/
|
|
10
|
+
// eslint-disable-next-line no-control-regex -- ESC is the point
|
|
11
|
+
const STRING_ESCAPE = /\x1b[\]P_^X][^\x07\x1b\x9c\n]*(?:\x07|\x1b\\|\x9c)?/g
|
|
12
|
+
|
|
13
|
+
/** `text` without terminal escapes, never losing a line to an unterminated one. */
|
|
14
|
+
export function stripEscapes(text: string): string {
|
|
15
|
+
// Tested first, after the cheaper ESC search: the replace alone cost half
|
|
16
|
+
// again as much on coloured text, the regex alone 3x on plain text.
|
|
17
|
+
if (text.indexOf('\x1b') === -1 || !STRING_OPENER.test(text)) return Bun.stripANSI(text)
|
|
18
|
+
return Bun.stripANSI(text.replace(STRING_ESCAPE, ''))
|
|
19
|
+
}
|