@vzn/vx 0.0.652 → 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 +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- 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 +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- 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 +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- 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 +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- 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 +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- 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 +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- 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-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- 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 +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- 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 +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- 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 +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -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/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- 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 +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- 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 +17 -3
- 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 +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- 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 +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- 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 +46 -25
- 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
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
UserError,
|
|
24
24
|
BUN_GLOB_WILDCARDS,
|
|
25
25
|
EXTGLOB,
|
|
26
|
+
braceAlternatives,
|
|
26
27
|
braceRange,
|
|
27
28
|
BRACE_RANGE_LIMIT,
|
|
28
29
|
anyTaskGlob,
|
|
@@ -179,15 +180,17 @@ function validateSettingsAt(value: unknown, at: string, configPath: string): voi
|
|
|
179
180
|
}
|
|
180
181
|
return
|
|
181
182
|
}
|
|
182
|
-
if (at === 'run.rules.dependencies') validateDependencyRules(value, where)
|
|
183
|
+
if (at === 'run.rules.dependencies') validateDependencyRules(value, where, configPath)
|
|
183
184
|
else validateSettingValue(value, at, where)
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
const RULE_FIELDS = new Set(['from', 'allow', 'deny'])
|
|
187
188
|
|
|
188
|
-
function validateDependencyRules(value: unknown, where: string): void {
|
|
189
|
+
function validateDependencyRules(value: unknown, where: string, configPath: string): void {
|
|
189
190
|
const shape = `an array of { from, allow?, deny? }, each a selector like 'tag:app' or '@acme/*'`
|
|
190
191
|
if (!Array.isArray(value)) throw new UserError(`${where} must be ${shape}`)
|
|
192
|
+
// The whole path inside one code span, as the frame reads a setting.
|
|
193
|
+
const at = (i: number, rest = '') => `${configPath}: \`run.rules.dependencies[${i}]${rest}\``
|
|
191
194
|
const selector = (v: unknown) => typeof v === 'string' && v.trim() !== '' && v !== 'tag:'
|
|
192
195
|
const matchable = (v: string, field: string) => {
|
|
193
196
|
const why = ruleSelectorProblem(v)
|
|
@@ -198,24 +201,24 @@ function validateDependencyRules(value: unknown, where: string): void {
|
|
|
198
201
|
)
|
|
199
202
|
}
|
|
200
203
|
for (const [i, rule] of (value as unknown[]).entries()) {
|
|
201
|
-
const at = `${where}[${i}]`
|
|
202
204
|
if (typeof rule !== 'object' || rule === null || Array.isArray(rule))
|
|
203
|
-
throw new UserError(`${at} must be { from, allow?, deny? }`)
|
|
204
|
-
assertKnownFields(rule, RULE_FIELDS, at)
|
|
205
|
+
throw new UserError(`${at(i)} must be { from, allow?, deny? }`)
|
|
206
|
+
assertKnownFields(rule, RULE_FIELDS, at(i))
|
|
205
207
|
const { from, allow, deny } = rule as Record<string, unknown>
|
|
206
|
-
if (!selector(from)) throw new UserError(`${at
|
|
207
|
-
matchable(from as string,
|
|
208
|
+
if (!selector(from)) throw new UserError(`${at(i, '.from')} must be a selector like 'tag:app'`)
|
|
209
|
+
matchable(from as string, at(i, '.from'))
|
|
208
210
|
for (const [k, list] of [
|
|
209
211
|
['allow', allow],
|
|
210
212
|
['deny', deny],
|
|
211
213
|
] as const) {
|
|
214
|
+
const field = at(i, `.${k}`)
|
|
212
215
|
if (list !== undefined && (!Array.isArray(list) || !list.every(selector)))
|
|
213
|
-
throw new UserError(`${
|
|
214
|
-
for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v,
|
|
216
|
+
throw new UserError(`${field} must be an array of selectors like 'tag:lib'`)
|
|
217
|
+
for (const [j, v] of ((list ?? []) as string[]).entries()) matchable(v, at(i, `.${k}[${j}]`))
|
|
215
218
|
}
|
|
216
219
|
// A rule that says nothing reads as a constraint and checks nothing.
|
|
217
220
|
if (allow === undefined && deny === undefined)
|
|
218
|
-
throw new UserError(`${at} names neither \`allow\` nor \`deny\``)
|
|
221
|
+
throw new UserError(`${at(i)} names neither \`allow\` nor \`deny\``)
|
|
219
222
|
}
|
|
220
223
|
}
|
|
221
224
|
|
|
@@ -514,12 +517,14 @@ export function validateProjectConfig(
|
|
|
514
517
|
config: ProjectConfig,
|
|
515
518
|
configPath: string,
|
|
516
519
|
ownConfig: string | null = configPath,
|
|
520
|
+
/** The caller refused non-JSON values already and passes a JSON copy (project-loader.ts). */
|
|
521
|
+
jsonCopy = false,
|
|
517
522
|
): void {
|
|
518
523
|
// Before the schema, on the object as it stands: the key folds its JSON,
|
|
519
524
|
// so a value JSON cannot carry is refused here and in the config worker
|
|
520
525
|
// alike (json-data.ts). A plugin's `project` edit and a lock entry cross
|
|
521
526
|
// this line too.
|
|
522
|
-
const [nonJson] = nonJsonPaths(config)
|
|
527
|
+
const [nonJson] = jsonCopy ? [] : nonJsonPaths(config)
|
|
523
528
|
if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
|
|
524
529
|
// The top level too: `task:` (singular) loaded as a project with no
|
|
525
530
|
// tasks and every request against it said "no projects declare".
|
|
@@ -597,7 +602,13 @@ export function validateProjectConfig(
|
|
|
597
602
|
}
|
|
598
603
|
if (exec !== undefined) {
|
|
599
604
|
if (typeof exec !== 'object' || exec === null) {
|
|
600
|
-
|
|
605
|
+
// A command written as the exec itself, as the task-as-a-string
|
|
606
|
+
// refusal above spells its fix.
|
|
607
|
+
throw new UserError(
|
|
608
|
+
typeof exec === 'string'
|
|
609
|
+
? `${where}.exec must be an object — a command is { exec: { command: ${JSON.stringify(exec)} } }`
|
|
610
|
+
: `${where}.exec must be an object with a \`command\` string`,
|
|
611
|
+
)
|
|
601
612
|
}
|
|
602
613
|
assertKnownFields(exec, EXEC_FIELDS, `${where}.exec`)
|
|
603
614
|
const sandbox = (exec as { sandbox?: unknown }).sandbox
|
|
@@ -648,11 +659,33 @@ export function validateProjectConfig(
|
|
|
648
659
|
)
|
|
649
660
|
}
|
|
650
661
|
}
|
|
662
|
+
const worker = (exec as { worker?: unknown }).worker
|
|
663
|
+
if (worker !== undefined && (typeof worker !== 'string' || worker.trim().length === 0)) {
|
|
664
|
+
throw new UserError(`${where}.exec.worker must be a worker type's name (or omitted)`)
|
|
665
|
+
}
|
|
651
666
|
const env = (exec as { env?: unknown }).env
|
|
652
667
|
if (env !== undefined) {
|
|
653
668
|
if (typeof env !== 'object' || env === null) {
|
|
654
669
|
throw new UserError(`${where}.exec.env must be an object (or omitted)`)
|
|
655
670
|
}
|
|
671
|
+
// `env: { NODE_ENV: 'production' }` (Turbo's and Nx's shape): name
|
|
672
|
+
// the level the variable belongs at, not only the allowed fields.
|
|
673
|
+
const plain = Array.isArray(env)
|
|
674
|
+
? undefined
|
|
675
|
+
: Object.entries(env).find(
|
|
676
|
+
([k, v]) =>
|
|
677
|
+
!ENV_FIELDS.has(k) &&
|
|
678
|
+
// `passthrough` is a typo of a field, not a variable.
|
|
679
|
+
nearest(k, ENV_FIELDS) === undefined &&
|
|
680
|
+
['string', 'number', 'boolean'].includes(typeof v),
|
|
681
|
+
)
|
|
682
|
+
if (plain !== undefined) {
|
|
683
|
+
throw new UserError(
|
|
684
|
+
`${where}.exec.env has unknown field "${plain[0]}" ` +
|
|
685
|
+
`(allowed: ${[...ENV_FIELDS].sort().join(', ')}) — vx spells it ` +
|
|
686
|
+
`exec.env.define: { ${plain[0]}: ${JSON.stringify(String(plain[1]))} }`,
|
|
687
|
+
)
|
|
688
|
+
}
|
|
656
689
|
// `env: { set: {...} }` loaded and defined nothing — the one level
|
|
657
690
|
// without this check until 2026-09-10 (tests/schema-unknown-keys).
|
|
658
691
|
assertKnownFields(env, ENV_FIELDS, `${where}.exec.env`)
|
|
@@ -806,9 +839,12 @@ export function validateProjectConfig(
|
|
|
806
839
|
}
|
|
807
840
|
if (dependsOn !== undefined) {
|
|
808
841
|
if (!Array.isArray(dependsOn) || dependsOn.some((s) => typeof s !== 'string')) {
|
|
842
|
+
// One name, not in a list: the fix is that list.
|
|
809
843
|
throw new UserError(
|
|
810
|
-
|
|
811
|
-
|
|
844
|
+
typeof dependsOn === 'string'
|
|
845
|
+
? `${where}.dependsOn must be an array — write dependsOn: [${JSON.stringify(dependsOn)}]`
|
|
846
|
+
: `${where}.dependsOn must be an array of strings ` +
|
|
847
|
+
`(Turbo/Nx micro-syntax: 'name', '^name', 'pkg#name')`,
|
|
812
848
|
)
|
|
813
849
|
}
|
|
814
850
|
}
|
|
@@ -924,6 +960,10 @@ export function validateProjectConfig(
|
|
|
924
960
|
throw err
|
|
925
961
|
}
|
|
926
962
|
}
|
|
963
|
+
// The selection starts empty and a `!` entry only subtracts, so a
|
|
964
|
+
// list of negations folded no dependency and the key stopped moving
|
|
965
|
+
// with every upstream, as a negation-only inputs.files does.
|
|
966
|
+
assertTaskFiltersNotNegationOnly(taskFilters, `${where}.inputs.tasks`)
|
|
927
967
|
assertFilterNamesDeclaredDeps(taskFilters, task.dependsOn, where)
|
|
928
968
|
}
|
|
929
969
|
for (const field of ['runtime', 'workspaceRuntime'] as const) {
|
|
@@ -1078,6 +1118,7 @@ const EXEC_FIELDS = new Set([
|
|
|
1078
1118
|
'remote',
|
|
1079
1119
|
'sandbox',
|
|
1080
1120
|
'platforms',
|
|
1121
|
+
'worker',
|
|
1081
1122
|
])
|
|
1082
1123
|
const PERSISTENT_FIELDS = new Set(['readyWhen'])
|
|
1083
1124
|
const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
|
|
@@ -1274,7 +1315,11 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
|
|
|
1274
1315
|
if (!allowed.has(key)) {
|
|
1275
1316
|
// The nearest accepted spelling first: the list says what the level
|
|
1276
1317
|
// takes, the hint says which one was meant.
|
|
1277
|
-
|
|
1318
|
+
// Own keys only: `constructor` read Object's and printed its source.
|
|
1319
|
+
const spellings = FOREIGN_FIELDS.get(allowed)
|
|
1320
|
+
const foreign =
|
|
1321
|
+
(spellings !== undefined && Object.hasOwn(spellings, key) ? spellings[key] : undefined) ??
|
|
1322
|
+
nestedHome(allowed, key)
|
|
1278
1323
|
const near =
|
|
1279
1324
|
nearest(key, allowed) ?? (allowed === WORKSPACE_FIELDS ? nearestSetting(key) : undefined)
|
|
1280
1325
|
throw new UserError(
|
|
@@ -1384,14 +1429,25 @@ function assertNoForeignToken(glob: string, where: string): void {
|
|
|
1384
1429
|
* and names both.
|
|
1385
1430
|
*/
|
|
1386
1431
|
function assertNoGluedGlobstar(glob: string, where: string): void {
|
|
1432
|
+
if (gluesGlobstar(glob)) throw gluedGlobstar(glob, `"${glob}"`, where)
|
|
1433
|
+
// Split on `{` `}` `,` alone, `src/{a,**}.ts` passed and expanded to
|
|
1434
|
+
// `src/**.ts`, one level.
|
|
1435
|
+
const alt = braceAlternatives(glob).find(gluesGlobstar)
|
|
1436
|
+
if (alt !== undefined) throw gluedGlobstar(alt, `"${glob}" expands to "${alt}": that`, where)
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function gluesGlobstar(glob: string): boolean {
|
|
1387
1440
|
const body = glob.startsWith('!') ? glob.slice(1) : glob
|
|
1388
|
-
|
|
1441
|
+
return body.split(/[/{},]/).some((seg) => seg.includes('**') && /[^*]/.test(seg))
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function gluedGlobstar(glob: string, subject: string, where: string): UserError {
|
|
1389
1445
|
const deep = glob.replace(/\*\*([^*/{},]+)/g, '**/*$1').replace(/([^*/{},]+)\*\*/g, '$1*/**')
|
|
1390
1446
|
const one = glob.replace(/\*{2,}/g, (run, at: number) =>
|
|
1391
1447
|
/[^*/{},]/.test(glob[at - 1] ?? '') || /[^*/{},]/.test(glob[at + run.length] ?? '') ? '*' : run,
|
|
1392
1448
|
)
|
|
1393
|
-
|
|
1394
|
-
`${where}:
|
|
1449
|
+
return new UserError(
|
|
1450
|
+
`${where}: ${subject} glues ** to other text in one path segment, which vx's glob engine ` +
|
|
1395
1451
|
`reads as one *, a single directory level. Write "${deep}" for every depth, or "${one}" ` +
|
|
1396
1452
|
`for one level.`,
|
|
1397
1453
|
)
|
|
@@ -1663,8 +1719,8 @@ function isEnvName(name: unknown): name is string {
|
|
|
1663
1719
|
* A task name `dependsOn`, `inputs.tasks` and the CLI can name. Each
|
|
1664
1720
|
* refused shape loaded and could not be referenced: `x#y` read as project
|
|
1665
1721
|
* `x`, `^gen` as every dependency's `gen`, a `*` as a pattern, `''` ran
|
|
1666
|
-
* under `--all` as `a#` and `vx run a#` refused it,
|
|
1667
|
-
* trimmed away by the reader (item 1000).
|
|
1722
|
+
* under `--all` as `a#` and `vx run a#` refused it, `-x` as a flag, and a
|
|
1723
|
+
* padded name was trimmed away by the reader (item 1000).
|
|
1668
1724
|
*/
|
|
1669
1725
|
function assertTaskName(name: string, configPath: string): void {
|
|
1670
1726
|
const why = taskNameProblem(name)
|
|
@@ -1687,9 +1743,11 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1687
1743
|
? "holds '*', which makes it a pattern"
|
|
1688
1744
|
: name.startsWith('^') || name.startsWith('!')
|
|
1689
1745
|
? `starts with '${name[0]}', which names dependencies' tasks or negates`
|
|
1690
|
-
:
|
|
1691
|
-
? '
|
|
1692
|
-
:
|
|
1746
|
+
: name.startsWith('-')
|
|
1747
|
+
? "starts with '-', which the CLI reads as a flag"
|
|
1748
|
+
: hasControl(name)
|
|
1749
|
+
? 'holds a control character'
|
|
1750
|
+
: null
|
|
1693
1751
|
}
|
|
1694
1752
|
|
|
1695
1753
|
/**
|
|
@@ -1741,6 +1799,18 @@ function assertNotNegationOnly(globs: readonly string[], where: string): void {
|
|
|
1741
1799
|
)
|
|
1742
1800
|
}
|
|
1743
1801
|
|
|
1802
|
+
function assertTaskFiltersNotNegationOnly(filters: readonly string[], where: string): void {
|
|
1803
|
+
if (filters.length === 0) return
|
|
1804
|
+
if (filters.some((f) => !f.startsWith('!'))) return
|
|
1805
|
+
throw new UserError(
|
|
1806
|
+
`${where}: every entry is a negation, which selects NOTHING (got ${JSON.stringify(filters)}) — ` +
|
|
1807
|
+
`negations only subtract from the dependencies a positive entry already selected, so this ` +
|
|
1808
|
+
`task would fold no upstream hash and its cache key would stop tracking its dependencies. ` +
|
|
1809
|
+
`Add the positive entry you meant, e.g. ['*', '^*', ${JSON.stringify(filters[0])}], ` +
|
|
1810
|
+
`or use [] to decouple on purpose.`,
|
|
1811
|
+
)
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1744
1814
|
/**
|
|
1745
1815
|
* A `!` entry subtracts wherever it sits in the list, so a file named
|
|
1746
1816
|
* outright after it (`['src/**', '!src/gen/**', 'src/gen/keep.ts']`) never
|
|
@@ -1860,6 +1930,19 @@ function assertStringArray(v: unknown, where: string): void {
|
|
|
1860
1930
|
}
|
|
1861
1931
|
}
|
|
1862
1932
|
|
|
1933
|
+
/**
|
|
1934
|
+
* Only `~` and `~/…` mean the home: `~root/x/` was read as `$HOME/root/x`,
|
|
1935
|
+
* and vx created that directory on the host.
|
|
1936
|
+
*/
|
|
1937
|
+
function assertOwnHome(v: readonly string[], where: string): void {
|
|
1938
|
+
const other = v.find((p) => p.startsWith('~') && p !== '~' && !p.startsWith('~/'))
|
|
1939
|
+
if (other === undefined) return
|
|
1940
|
+
throw new UserError(
|
|
1941
|
+
`${where}: ${JSON.stringify(other)} names a user's home, which vx does not expand — ` +
|
|
1942
|
+
`write '~/…' for your own home, or an absolute path`,
|
|
1943
|
+
)
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1863
1946
|
/**
|
|
1864
1947
|
* Validate a `sandbox: {...}` block — one capability shape, whatever the
|
|
1865
1948
|
* platform ends up doing with it. Unknown keys are refused rather than
|
|
@@ -1892,7 +1975,22 @@ function validateSandbox(sandbox: unknown, where: string): void {
|
|
|
1892
1975
|
for (const f of GRANT_PATH_FIELDS) {
|
|
1893
1976
|
// Patterns are allowed here: macOS matches them natively, Linux
|
|
1894
1977
|
// expands them against the filesystem at resolve time.
|
|
1895
|
-
if (g[f]
|
|
1978
|
+
if (g[f] === undefined) continue
|
|
1979
|
+
assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
|
|
1980
|
+
assertOwnHome(g[f] as string[], `${where}.sandbox.allow.${f}`)
|
|
1981
|
+
}
|
|
1982
|
+
// SRT's Linux observer reports a write's path with each control
|
|
1983
|
+
// character turned into a space, so a write a grant holding one covers
|
|
1984
|
+
// read as a violation of the path spelled with a space, and a real
|
|
1985
|
+
// write there reads the same: no match passes one and not the other.
|
|
1986
|
+
// Reads are traced by strace, which escapes them.
|
|
1987
|
+
const ctl = ((g['write'] as string[] | undefined) ?? []).find(hasControl)
|
|
1988
|
+
if (ctl !== undefined) {
|
|
1989
|
+
throw new UserError(
|
|
1990
|
+
`${where}.sandbox.allow.write: ${JSON.stringify(ctl)} holds a control character, ` +
|
|
1991
|
+
`which the Linux sandbox's report of a write does not keep (it spells a tab as a ` +
|
|
1992
|
+
`space), so a write under it cannot be told from a refused one — rename it`,
|
|
1993
|
+
)
|
|
1896
1994
|
}
|
|
1897
1995
|
for (const f of GRANT_NAME_FIELDS) {
|
|
1898
1996
|
if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.allow.${f}`)
|
|
@@ -1949,7 +2047,10 @@ function validateSandbox(sandbox: unknown, where: string): void {
|
|
|
1949
2047
|
// Patterns, not prefixes — `ignore` is vx's own filter over lines it
|
|
1950
2048
|
// already holds, so a glob costs nothing and works on every platform.
|
|
1951
2049
|
for (const f of IGNORE_FIELDS) {
|
|
1952
|
-
if (g[f]
|
|
2050
|
+
if (g[f] === undefined) continue
|
|
2051
|
+
assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
|
|
2052
|
+
if (f === 'read' || f === 'write')
|
|
2053
|
+
assertOwnHome(g[f] as string[], `${where}.sandbox.ignore.${f}`)
|
|
1953
2054
|
}
|
|
1954
2055
|
}
|
|
1955
2056
|
}
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// changed lockfile's base digests are there, so the base side is never
|
|
12
12
|
// parsed. `vx lock --check` holds it to a fresh derivation.
|
|
13
13
|
|
|
14
|
-
import { rename, rm } from 'node:fs/promises'
|
|
15
14
|
import path from 'node:path'
|
|
15
|
+
import type { PendingWrite } from '../util/index.js'
|
|
16
16
|
import type { ProjectMeta } from './workspace.js'
|
|
17
17
|
import { lockKey } from './lockfile.js'
|
|
18
18
|
import { type LoadReads, readOnce } from './load-reads.js'
|
|
@@ -90,19 +90,12 @@ function dependenciesLockText(lock: DependenciesLock): string {
|
|
|
90
90
|
return `${JSON.stringify(lock)}\n`
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
export
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
await rename(tmp, file)
|
|
100
|
-
} catch (err) {
|
|
101
|
-
await rm(tmp, { force: true }).catch(() => {})
|
|
102
|
-
const e = err as NodeJS.ErrnoException
|
|
103
|
-
if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
|
|
104
|
-
if (e.path === tmp) e.path = file
|
|
105
|
-
throw err
|
|
93
|
+
/** The dependency lock's file and bytes, for `writeAllOrNone`. */
|
|
94
|
+
export function dependenciesLockWrite(root: string, lock: DependenciesLock): PendingWrite {
|
|
95
|
+
return {
|
|
96
|
+
file: path.join(root, DEPENDENCIES_LOCK_NAME),
|
|
97
|
+
text: dependenciesLockText(lock),
|
|
98
|
+
shown: DEPENDENCIES_LOCK_NAME,
|
|
106
99
|
}
|
|
107
100
|
}
|
|
108
101
|
|
package/src/workspace/filter.ts
CHANGED
|
@@ -131,9 +131,18 @@ export function parseFilter(
|
|
|
131
131
|
if (scoped !== null && !scoped[1]!.startsWith('./') && scoped[1] !== '.') {
|
|
132
132
|
s = scoped[1]!
|
|
133
133
|
gitSince = scoped[2]!
|
|
134
|
+
// Neither Turbo nor pnpm reads it (both keep `^...` in the name):
|
|
135
|
+
// `{dir}^...[ref]` dropped the `^...` and selected the inverse, and
|
|
136
|
+
// the name spellings matched nothing.
|
|
137
|
+
if (s.endsWith('^...'))
|
|
138
|
+
throw new UserError(
|
|
139
|
+
`filter "${raw}" puts ^... before a git ref, which no form reads: ` +
|
|
140
|
+
`<sel>...[<ref>] is <sel> where it or a dependency changed, ` +
|
|
141
|
+
`<sel>[<ref>]^... the dependencies of the changed ones`,
|
|
142
|
+
)
|
|
134
143
|
// The name glob kept the `...` and matched nothing (create-t3-turbo,
|
|
135
144
|
// `@acme/*...[HEAD]`).
|
|
136
|
-
if (s.endsWith('...')
|
|
145
|
+
if (s.endsWith('...')) {
|
|
137
146
|
s = s.slice(0, -3)
|
|
138
147
|
sinceViaDeps = true
|
|
139
148
|
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
lockKey,
|
|
29
29
|
NO_LOCK,
|
|
30
30
|
readLockfile,
|
|
31
|
+
lockfileWrite,
|
|
31
32
|
writeLockfile,
|
|
32
33
|
type Lockfile,
|
|
33
34
|
type LockedProject,
|
|
@@ -38,7 +39,7 @@ export {
|
|
|
38
39
|
parseDependenciesLock,
|
|
39
40
|
readDependenciesLock,
|
|
40
41
|
recordedDigests,
|
|
41
|
-
|
|
42
|
+
dependenciesLockWrite,
|
|
42
43
|
type DependenciesLock,
|
|
43
44
|
} from './dependencies-lock.js'
|
|
44
45
|
export {
|
|
@@ -60,6 +61,7 @@ export {
|
|
|
60
61
|
validateWorkspace,
|
|
61
62
|
} from './config-schema.js'
|
|
62
63
|
export { configImports } from './config-imports.js'
|
|
64
|
+
export { repoIdsOf } from './repo-id.js'
|
|
63
65
|
export {
|
|
64
66
|
checkProjectNames,
|
|
65
67
|
findWorkspaceRoot,
|
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
// worker's, which never saw it — or, where the validator does not type
|
|
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
|
-
// on the live object (
|
|
11
|
+
// on the live object before its JSON copy (project-loader.ts), the worker before its
|
|
12
12
|
// `JSON.stringify` (config-eval.ts). The worker embeds `nonJsonPaths` by its source text, so
|
|
13
13
|
// the function must stay self-contained: no import, no module-scope name.
|
|
14
14
|
//
|
|
15
|
+
// JSON writes only own enumerable string keys: a symbol key or a
|
|
16
|
+
// non-enumerable property vanished from the key and the lock
|
|
17
|
+
// (`"config":{}`), so it is refused too.
|
|
18
|
+
//
|
|
15
19
|
// An `undefined` PROPERTY is allowed: JSON drops it, the validator reads
|
|
16
20
|
// it as absent, and configs write it with conditional spreads. Inside an
|
|
17
21
|
// array it is refused, because JSON writes it as `null`.
|
|
@@ -29,13 +33,20 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
|
29
33
|
// The key or index at each level, rendered only for a finding: the walk
|
|
30
34
|
// runs on every first load, and a path string built per node cost three
|
|
31
35
|
// times the schema check itself.
|
|
32
|
-
const keys: Array<string | number> = []
|
|
36
|
+
const keys: Array<string | number | symbol> = []
|
|
33
37
|
// The objects on the path to the one being walked: a cycle throws in
|
|
34
38
|
// `JSON.stringify` and would recurse here forever. A DAG is data.
|
|
35
39
|
const ancestors: object[] = []
|
|
40
|
+
const MAX_DEPTH = 256
|
|
36
41
|
const found = (is: string): void => {
|
|
37
42
|
let path = ''
|
|
38
|
-
for (const k of keys)
|
|
43
|
+
for (const k of keys)
|
|
44
|
+
path +=
|
|
45
|
+
typeof k === 'number' || typeof k === 'symbol'
|
|
46
|
+
? `[${String(k)}]`
|
|
47
|
+
: path === ''
|
|
48
|
+
? k
|
|
49
|
+
: `.${k}`
|
|
39
50
|
out.push({ path: path === '' ? 'the default export' : path, is })
|
|
40
51
|
}
|
|
41
52
|
const walk = (v: unknown, inArray: boolean): void => {
|
|
@@ -45,6 +56,12 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
|
45
56
|
else if (typeof v === 'number' && !Number.isFinite(v)) found(String(v))
|
|
46
57
|
else if (v === undefined && inArray) found('undefined in an array, which JSON writes as null')
|
|
47
58
|
else if (typeof v === 'object' && v !== null) {
|
|
59
|
+
// Deeper, the walk and `JSON.stringify` overflowed the stack: a
|
|
60
|
+
// config 100,000 levels deep was vx's own RangeError (VX_E_INTERNAL).
|
|
61
|
+
if (ancestors.length >= MAX_DEPTH) {
|
|
62
|
+
found(`nested deeper than ${MAX_DEPTH} levels`)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
48
65
|
if (ancestors.includes(v)) {
|
|
49
66
|
found('a cyclic reference')
|
|
50
67
|
return
|
|
@@ -57,6 +74,13 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
|
57
74
|
walk(v[i], true)
|
|
58
75
|
keys.pop()
|
|
59
76
|
}
|
|
77
|
+
// Symbols only: listing an array's own names allocates every index
|
|
78
|
+
// string, +1-4% on a 20k-task lock, for a named property no config writes.
|
|
79
|
+
for (const key of Object.getOwnPropertySymbols(v)) {
|
|
80
|
+
keys.push(key)
|
|
81
|
+
found('a symbol key, which JSON drops')
|
|
82
|
+
keys.pop()
|
|
83
|
+
}
|
|
60
84
|
ancestors.pop()
|
|
61
85
|
return
|
|
62
86
|
}
|
|
@@ -71,16 +95,18 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
|
|
|
71
95
|
return
|
|
72
96
|
}
|
|
73
97
|
ancestors.push(v)
|
|
74
|
-
for (const key of
|
|
98
|
+
for (const key of Reflect.ownKeys(v)) {
|
|
75
99
|
keys.push(key)
|
|
76
100
|
// Read by descriptor: a getter is code, and each read may answer
|
|
77
101
|
// differently. `vx show` read one three times, so the key, the
|
|
78
102
|
// check and the command could each see another value, and a
|
|
79
103
|
// throwing one surfaced as vx's own stack (D-67). Never called here.
|
|
80
|
-
const d = Object.getOwnPropertyDescriptor(v, key)
|
|
81
|
-
if (
|
|
82
|
-
else if (d
|
|
83
|
-
else
|
|
104
|
+
const d = Object.getOwnPropertyDescriptor(v, key) as PropertyDescriptor
|
|
105
|
+
if (typeof key === 'symbol') found('a symbol key, which JSON drops')
|
|
106
|
+
else if (d.enumerable !== true) found('a non-enumerable property, which JSON drops')
|
|
107
|
+
else if (d.get !== undefined) found('a getter')
|
|
108
|
+
else if (d.set !== undefined) found('a setter')
|
|
109
|
+
else walk(d.value, false)
|
|
84
110
|
keys.pop()
|
|
85
111
|
}
|
|
86
112
|
ancestors.pop()
|
|
@@ -11,11 +11,18 @@
|
|
|
11
11
|
// eval-free.
|
|
12
12
|
// - `vx lock --check` AUDITS it: full re-evaluation + deep equality.
|
|
13
13
|
|
|
14
|
-
import { readFileSync } from 'node:fs'
|
|
15
|
-
import { rename, rm } from 'node:fs/promises'
|
|
14
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
16
15
|
import path from 'node:path'
|
|
17
16
|
import type { ProjectConfig } from '../config.js'
|
|
18
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
keysSorted,
|
|
19
|
+
type PendingWrite,
|
|
20
|
+
relPosix,
|
|
21
|
+
UserError,
|
|
22
|
+
writeAllOrNone,
|
|
23
|
+
xxh3,
|
|
24
|
+
xxh3hex,
|
|
25
|
+
} from '../util/index.js'
|
|
19
26
|
import { VERSION } from '../version.js'
|
|
20
27
|
import { validateProjectConfig } from './config-schema.js'
|
|
21
28
|
import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
|
|
@@ -38,6 +45,12 @@ export interface Lockfile {
|
|
|
38
45
|
* by directory, so a name a plugin gives never invalidates the lock.
|
|
39
46
|
*/
|
|
40
47
|
projects: Record<string, LockedProject>
|
|
48
|
+
/**
|
|
49
|
+
* Root-relative POSIX directories holding their own `.vx/workspace.*`,
|
|
50
|
+
* where every outer project's outputs stop. Absent when there is none, so a
|
|
51
|
+
* workspace without one locks the bytes it always did.
|
|
52
|
+
*/
|
|
53
|
+
nestedWorkspaces?: string[]
|
|
41
54
|
/** Written by `writeLockfile` from the bytes; never read off the object. */
|
|
42
55
|
validated?: string
|
|
43
56
|
}
|
|
@@ -101,7 +114,16 @@ export function lockDigest(lock: Lockfile): string | undefined {
|
|
|
101
114
|
*/
|
|
102
115
|
export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
103
116
|
const file = Bun.file(lockfilePath(root))
|
|
104
|
-
if (!(await file.exists()))
|
|
117
|
+
if (!(await file.exists())) {
|
|
118
|
+
// A directory reads as no file: `--check` said "run `vx lock`", which cannot write over it.
|
|
119
|
+
if (statSync(lockfilePath(root), { throwIfNoEntry: false })?.isDirectory()) {
|
|
120
|
+
throw new UserError(
|
|
121
|
+
`${LOCKFILE_NAME} is a directory — remove it and run \`vx lock\``,
|
|
122
|
+
'VX_E_LOCK',
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
105
127
|
let parsed: unknown
|
|
106
128
|
let text: string
|
|
107
129
|
try {
|
|
@@ -123,7 +145,7 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
123
145
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
124
146
|
throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``, 'VX_E_LOCK')
|
|
125
147
|
}
|
|
126
|
-
const lock = parsed as { version?: unknown; projects?: unknown }
|
|
148
|
+
const lock = parsed as { version?: unknown; projects?: unknown; nestedWorkspaces?: unknown }
|
|
127
149
|
if (lock.version !== LOCKFILE_VERSION) {
|
|
128
150
|
throw new UserError(
|
|
129
151
|
`${LOCKFILE_NAME} has unsupported version ${String(lock.version)} ` +
|
|
@@ -153,21 +175,47 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
153
175
|
Array.isArray(entry) ||
|
|
154
176
|
typeof file !== 'string' ||
|
|
155
177
|
!PROJECT_CONFIG_FILENAMES.includes(file) ||
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
!isObj(config) ||
|
|
179
|
+
// A run trusts these shapes: a string `name`, a `tags` list, a `tasks` object.
|
|
180
|
+
(config['name'] !== undefined && typeof config['name'] !== 'string') ||
|
|
181
|
+
(config['tags'] !== undefined && !Array.isArray(config['tags'])) ||
|
|
182
|
+
(config['tasks'] !== undefined && !isObj(config['tasks']))
|
|
159
183
|
) {
|
|
160
|
-
throw
|
|
161
|
-
`${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
|
|
162
|
-
'VX_E_LOCK',
|
|
163
|
-
)
|
|
184
|
+
throw malformed(dir)
|
|
164
185
|
}
|
|
165
186
|
}
|
|
187
|
+
const nested = lock.nestedWorkspaces
|
|
188
|
+
if (
|
|
189
|
+
nested !== undefined &&
|
|
190
|
+
(!Array.isArray(nested) ||
|
|
191
|
+
nested.some((d) => typeof d !== 'string' || d === '.' || !isLockKey(d)))
|
|
192
|
+
) {
|
|
193
|
+
throw new UserError(
|
|
194
|
+
`${LOCKFILE_NAME}: \`nestedWorkspaces\` must list directories under the workspace root — re-run \`vx lock\``,
|
|
195
|
+
'VX_E_LOCK',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
166
198
|
pooledIn(parsed as Pooled)
|
|
199
|
+
// After the pool is put back, as a task may be an index into it. A
|
|
200
|
+
// string `dependsOn` was walked per character: "dependsOn b#a".
|
|
201
|
+
for (const [dir, entry] of Object.entries(lock.projects as Record<string, LockedProject>)) {
|
|
202
|
+
const tasks = entry.config.tasks as Obj | undefined
|
|
203
|
+
for (const name in tasks) {
|
|
204
|
+
const t = tasks[name]
|
|
205
|
+
if (!isObj(t) || (t['dependsOn'] !== undefined && !Array.isArray(t['dependsOn'])))
|
|
206
|
+
throw malformed(dir)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
167
209
|
digests.set(parsed as Lockfile, xxh3hex(text))
|
|
168
210
|
return parsed as Lockfile
|
|
169
211
|
}
|
|
170
212
|
|
|
213
|
+
const malformed = (dir: string): UserError =>
|
|
214
|
+
new UserError(
|
|
215
|
+
`${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
|
|
216
|
+
'VX_E_LOCK',
|
|
217
|
+
)
|
|
218
|
+
|
|
171
219
|
/**
|
|
172
220
|
* The project entries of a lock's bytes with its pool put back, or null
|
|
173
221
|
* for bytes that do not parse as a lock: an entry compares across two
|
|
@@ -335,31 +383,32 @@ function taskKeysSorted(key: string, value: unknown): unknown {
|
|
|
335
383
|
return Object.fromEntries(Object.entries(value).map(([name, task]) => [name, keysSorted(task)]))
|
|
336
384
|
}
|
|
337
385
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
386
|
+
/**
|
|
387
|
+
* Written beside its name and renamed over it: `Bun.write` truncates in
|
|
388
|
+
* place, so a run, or `vx watch` woken by the write itself (item 971),
|
|
389
|
+
* could read an empty or half-written lock and fail on "not valid JSON"
|
|
390
|
+
* (item 972).
|
|
391
|
+
*/
|
|
392
|
+
export function writeLockfile(root: string, lock: Lockfile): Promise<void> {
|
|
393
|
+
return writeAllOrNone([lockfileWrite(root, lock)], root)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The lock's file and bytes, for `writeAllOrNone`. */
|
|
397
|
+
export function lockfileWrite(root: string, lock: Lockfile): PendingWrite {
|
|
398
|
+
// Minified: the file only vx reads, and it loads faster than pretty
|
|
399
|
+
// JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
|
|
400
|
+
// beside each config is the copy for people.
|
|
401
|
+
const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
|
|
402
|
+
const nested = lock.nestedWorkspaces ?? []
|
|
403
|
+
const body = JSON.stringify({
|
|
404
|
+
version: lock.version,
|
|
405
|
+
...(nested.length > 0 ? { nestedWorkspaces: nested } : {}),
|
|
406
|
+
...pooledOut(sorted),
|
|
407
|
+
})
|
|
408
|
+
return {
|
|
409
|
+
file: lockfilePath(root),
|
|
410
|
+
text: `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`,
|
|
411
|
+
shown: LOCKFILE_NAME,
|
|
363
412
|
}
|
|
364
413
|
}
|
|
365
414
|
|