@solaqua/gji 0.12.0 → 0.12.2

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.
@@ -1,13 +1,16 @@
1
1
  import { execFile } from "node:child_process";
2
- import { lstat, readFile, realpath, rm } from "node:fs/promises";
3
- import { join, relative, resolve, sep } from "node:path";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readdir, readFile, realpath, rm } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
4
5
  import { promisify } from "node:util";
5
- import { cloneFailureScope, defaultCloneFailureStore, } from "./clone-failure-store.js";
6
6
  import { runCommand } from "./command-runner.js";
7
- import { cloneDir, isCloneDestinationExistsError, isCloneInProgressError, isCloneUnsupportedError, } from "./dir-clone.js";
8
- import { pathExists } from "./fs-utils.js";
7
+ import { cloneDir, isCloneDestinationExistsError, isCloneInProgressError, } from "./dir-clone.js";
8
+ import { isNotFoundError, pathExists } from "./fs-utils.js";
9
9
  import { inspectDestination } from "./safe-destination.js";
10
10
  const execFileAsync = promisify(execFile);
11
+ const UV_VALIDATION_MAX_ENTRIES = 10_000;
12
+ const UV_VALIDATION_MAX_FILE_BYTES = 1024 * 1024;
13
+ const UV_VALIDATION_MAX_TOTAL_BYTES = 16 * 1024 * 1024;
11
14
  export async function prepareDependencyBootstrap(mode, context) {
12
15
  if (mode === "off")
13
16
  return { mode, targets: [] };
@@ -26,7 +29,7 @@ export async function prepareDependencyBootstrap(mode, context) {
26
29
  for (const adapter of adapters) {
27
30
  if (plannedRelativePaths.has(adapter.relativePath))
28
31
  continue;
29
- let fallback;
32
+ let firstDetected;
30
33
  let selected;
31
34
  for (const sourceRoot of sourceRoots) {
32
35
  const target = await adapter.detect({
@@ -37,14 +40,14 @@ export async function prepareDependencyBootstrap(mode, context) {
37
40
  if (!target)
38
41
  continue;
39
42
  const seedable = mode !== "install-only" && (await adapter.canSeed(target));
40
- const candidate = { adapter, target, seedable };
41
- fallback ??= candidate;
43
+ const candidate = { target, seedable };
44
+ firstDetected ??= candidate;
42
45
  if (mode === "install-only" || seedable) {
43
46
  selected = candidate;
44
47
  break;
45
48
  }
46
49
  }
47
- const plannedTarget = selected ?? fallback;
50
+ const plannedTarget = selected ?? firstDetected;
48
51
  if (plannedTarget) {
49
52
  targets.push(plannedTarget);
50
53
  plannedRelativePaths.add(adapter.relativePath);
@@ -58,9 +61,9 @@ export async function detectDependencyBootstrapCandidate(context) {
58
61
  if (!planned)
59
62
  return null;
60
63
  return {
61
- adapter: planned.adapter.name,
62
- kind: planned.adapter.kind,
63
- lockfile: planned.adapter.lockfile,
64
+ adapter: planned.target.adapter,
65
+ kind: planned.target.kind,
66
+ lockfile: adapterForTarget(planned.target).lockfile,
64
67
  target: planned.target.relativePath,
65
68
  repairCommand: planned.target.repairCommand,
66
69
  seedable: planned.seedable,
@@ -69,10 +72,10 @@ export async function detectDependencyBootstrapCandidate(context) {
69
72
  export function previewDependencyBootstrap(plan) {
70
73
  return {
71
74
  mode: plan.mode,
72
- targets: plan.targets.map(({ adapter, target, seedable }) => ({
73
- adapter: adapter.output(target).adapter,
74
- kind: adapter.kind,
75
- target: adapter.output(target).target,
75
+ targets: plan.targets.map(({ target, seedable }) => ({
76
+ adapter: target.adapter,
77
+ kind: target.kind,
78
+ target: target.relativePath,
76
79
  repairCommand: target.repairCommand,
77
80
  seedable,
78
81
  strategy: bootstrapStrategy(plan.mode, target, seedable),
@@ -83,7 +86,6 @@ export async function executeDependencyBootstrap(plan, options) {
83
86
  if (plan.mode === "off")
84
87
  return { mode: plan.mode, ready: true, events: [] };
85
88
  const events = [];
86
- const failureStore = options.failureStore ?? defaultCloneFailureStore;
87
89
  const cloneDirectory = options.cloneDirectory ?? cloneDir;
88
90
  const execution = {
89
91
  runCommand: options.runCommand ?? runCommand,
@@ -102,8 +104,9 @@ export async function executeDependencyBootstrap(plan, options) {
102
104
  });
103
105
  return { mode: plan.mode, ready: true, events };
104
106
  }
105
- for (const { adapter, target, seedable } of plan.targets) {
106
- await executeBootstrapTarget(adapter, target, seedable, plan.mode, cloneDirectory, failureStore, execution, seededDirectories.has(target.relativePath), events, options.reporter, options.repoRoot);
107
+ for (const { target, seedable } of plan.targets) {
108
+ const adapter = adapterForTarget(target);
109
+ await executeBootstrapTarget(adapter, target, seedable, plan.mode, cloneDirectory, execution, seededDirectories.has(target.relativePath), events, options.reporter);
107
110
  }
108
111
  return {
109
112
  mode: plan.mode,
@@ -111,25 +114,19 @@ export async function executeDependencyBootstrap(plan, options) {
111
114
  events,
112
115
  };
113
116
  }
114
- async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDirectory, failureStore, execution, seededBySyncDirs, events, reporter, repoRoot) {
117
+ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDirectory, execution, seededBySyncDirs, events, reporter) {
115
118
  const destinationInspection = await inspectDestination(target.worktreePath, target.targetPath);
116
119
  if (destinationInspection.kind === "unsafe") {
117
120
  recordBootstrapFailure(events, reporter, adapter, target, destinationInspection.reason, "destination-unsafe");
118
121
  return;
119
122
  }
120
123
  if (mode === "install-only") {
121
- await repairTarget(adapter, target, execution, false, target.existingBeforeBootstrap ? "preserve" : "empty", events, reporter);
124
+ await repairTarget(adapter, target, execution, destinationInspection.kind === "exists" ? "preserve" : "clean", events, reporter);
122
125
  return;
123
126
  }
124
- let seeded = false;
125
- const failureScope = seedable
126
- ? await cloneFailureScope(adapter.seedPath(target), target.targetPath)
127
- : undefined;
128
- let ownership = target.existingBeforeBootstrap
129
- ? "preserve"
130
- : "empty";
127
+ let input = destinationInspection.kind === "exists" ? "preserve" : "clean";
131
128
  const destinationExistsNow = destinationInspection.kind === "exists";
132
- if (target.existingBeforeBootstrap) {
129
+ if (target.initialInput === "preserve" && destinationExistsNow) {
133
130
  recordBootstrapEvent(events, reporter, {
134
131
  adapter: adapter.name,
135
132
  kind: adapter.kind,
@@ -141,8 +138,7 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
141
138
  }
142
139
  else if (seededBySyncDirs && destinationExistsNow) {
143
140
  if (seedable) {
144
- seeded = true;
145
- ownership = "syncDirs";
141
+ input = "seed";
146
142
  recordBootstrapEvent(events, reporter, {
147
143
  adapter: adapter.name,
148
144
  kind: adapter.kind,
@@ -153,19 +149,19 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
153
149
  });
154
150
  }
155
151
  else {
156
- ownership = "preserve";
152
+ input = "preserve";
157
153
  recordBootstrapEvent(events, reporter, {
158
154
  adapter: adapter.name,
159
155
  kind: adapter.kind,
160
156
  reason: "generic-seed",
161
- state: "fallback",
157
+ state: "repair-only",
162
158
  target: target.relativePath,
163
159
  message: "syncDirs created a generic target; this adapter uses repair without CoW",
164
160
  });
165
161
  }
166
162
  }
167
163
  else if (destinationExistsNow) {
168
- ownership = "preserve";
164
+ input = "preserve";
169
165
  recordBootstrapEvent(events, reporter, {
170
166
  adapter: adapter.name,
171
167
  kind: adapter.kind,
@@ -175,26 +171,13 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
175
171
  message: "target appeared during bootstrap; preserving it as the repair input",
176
172
  });
177
173
  }
178
- else if (seedable &&
179
- (await failureStore.isCached(repoRoot, target.relativePath, failureScope))) {
180
- recordBootstrapEvent(events, reporter, {
181
- adapter: adapter.name,
182
- kind: adapter.kind,
183
- reason: "cow-failure-cached",
184
- state: "fallback",
185
- target: target.relativePath,
186
- message: "previous CoW failure is cached; repairing from an empty target",
187
- });
188
- }
189
174
  else if (seedable) {
190
175
  try {
191
176
  await cloneDirectory(adapter.seedPath(target), target.targetPath, {
192
177
  destinationRoot: target.worktreePath,
193
178
  measureBytes: reporter.measureCloneSize,
194
179
  });
195
- await failureStore.clear(repoRoot, target.relativePath, failureScope);
196
- seeded = true;
197
- ownership = "adapter";
180
+ input = "seed";
198
181
  recordBootstrapEvent(events, reporter, {
199
182
  adapter: adapter.name,
200
183
  kind: adapter.kind,
@@ -205,7 +188,7 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
205
188
  }
206
189
  catch (error) {
207
190
  if (isCloneDestinationExistsError(error)) {
208
- ownership = "preserve";
191
+ input = "preserve";
209
192
  recordBootstrapEvent(events, reporter, {
210
193
  adapter: adapter.name,
211
194
  kind: adapter.kind,
@@ -213,23 +196,27 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
213
196
  target: target.relativePath,
214
197
  message: "target appeared during CoW seeding; preserving it as the repair input",
215
198
  });
216
- await repairTarget(adapter, target, execution, false, ownership, events, reporter);
199
+ await repairTarget(adapter, target, execution, input, events, reporter);
217
200
  return;
218
201
  }
219
202
  if (isCloneInProgressError(error)) {
220
203
  recordBootstrapFailure(events, reporter, adapter, target, `CoW seed is already in progress: ${toErrorMessage(error)}`, "clone-in-progress");
221
204
  return;
222
205
  }
223
- if (isCloneUnsupportedError(error)) {
224
- await failureStore.cache(repoRoot, target.relativePath, toErrorMessage(error), failureScope);
206
+ const failedCloneInspection = await inspectDestination(target.worktreePath, target.targetPath);
207
+ if (failedCloneInspection.kind === "unsafe") {
208
+ recordBootstrapFailure(events, reporter, adapter, target, failedCloneInspection.reason, "destination-unsafe");
209
+ return;
225
210
  }
211
+ input = failedCloneInspection.kind === "exists" ? "preserve" : "clean";
212
+ const repairInput = input === "preserve" ? "the preserved target" : "an empty target";
226
213
  recordBootstrapEvent(events, reporter, {
227
214
  adapter: adapter.name,
228
215
  kind: adapter.kind,
229
- reason: "cow-unsupported",
230
- state: "fallback",
216
+ reason: "cow-seed-failed",
217
+ state: "repair-only",
231
218
  target: target.relativePath,
232
- message: `CoW seed failed; repairing from an empty target (${toErrorMessage(error)})`,
219
+ message: `CoW seed failed; repairing from ${repairInput} (${toErrorMessage(error)})`,
233
220
  });
234
221
  }
235
222
  }
@@ -238,37 +225,38 @@ async function executeBootstrapTarget(adapter, target, seedable, mode, cloneDire
238
225
  adapter: adapter.name,
239
226
  kind: adapter.kind,
240
227
  reason: "seed-unavailable",
241
- state: "fallback",
228
+ state: "repair-only",
242
229
  target: target.relativePath,
243
230
  message: "CoW seed is unavailable; repairing from an empty target",
244
231
  });
245
232
  }
246
- await repairTarget(adapter, target, execution, seeded, ownership, events, reporter);
233
+ await repairTarget(adapter, target, execution, input, events, reporter);
247
234
  }
248
- async function repairTarget(adapter, target, execution, seeded, ownership, events, reporter) {
235
+ async function repairTarget(adapter, target, execution, input, events, reporter) {
249
236
  const beforeRepairInspection = await inspectDestination(target.worktreePath, target.targetPath);
250
237
  if (beforeRepairInspection.kind === "unsafe") {
251
238
  recordBootstrapFailure(events, reporter, adapter, target, beforeRepairInspection.reason, "destination-unsafe");
252
239
  return;
253
240
  }
254
- const presentBeforeRepair = beforeRepairInspection.kind === "exists";
241
+ const effectiveInput = beforeRepairInspection.kind === "exists"
242
+ ? input === "seed"
243
+ ? "seed"
244
+ : "preserve"
245
+ : "clean";
255
246
  try {
256
- await adapter.repair(target, execution);
247
+ await adapter.repair(target, { ...execution, input: effectiveInput });
257
248
  recordBootstrapEvent(events, reporter, {
258
249
  adapter: adapter.name,
259
250
  kind: adapter.kind,
260
251
  state: target.repairState,
261
252
  target: target.relativePath,
262
- message: seeded
263
- ? "reused and repaired"
264
- : "installed or repaired from a clean target",
253
+ message: repairSuccessMessage(target, effectiveInput),
265
254
  });
266
255
  }
267
256
  catch (firstError) {
268
257
  const firstFailureContext = {
258
+ input: effectiveInput,
269
259
  target,
270
- ownership,
271
- targetExistedBeforeRepair: presentBeforeRepair,
272
260
  };
273
261
  const cleanupError = await adapter
274
262
  .cleanupAfterRepairFailure(firstFailureContext)
@@ -286,7 +274,7 @@ async function repairTarget(adapter, target, execution, seeded, ownership, event
286
274
  adapter: adapter.name,
287
275
  kind: adapter.kind,
288
276
  reason: "seed-repair-failed",
289
- state: "fallback",
277
+ state: "repair-only",
290
278
  target: target.relativePath,
291
279
  message: `seed repair failed; removed the seed and retrying clean (${toErrorMessage(firstError)})`,
292
280
  });
@@ -295,24 +283,23 @@ async function repairTarget(adapter, target, execution, seeded, ownership, event
295
283
  recordBootstrapFailure(events, reporter, adapter, target, beforeRetryInspection.reason, "destination-unsafe");
296
284
  return;
297
285
  }
298
- const presentBeforeRetry = beforeRetryInspection.kind === "exists";
286
+ const retryInput = beforeRetryInspection.kind === "exists" ? "preserve" : "clean";
299
287
  try {
300
- await adapter.repair(target, execution);
288
+ await adapter.repair(target, { ...execution, input: retryInput });
301
289
  recordBootstrapEvent(events, reporter, {
302
290
  adapter: adapter.name,
303
291
  kind: adapter.kind,
304
292
  reason: "repair-retry",
305
293
  state: target.repairState,
306
294
  target: target.relativePath,
307
- message: "installed or repaired from a clean target",
295
+ message: repairSuccessMessage(target, retryInput),
308
296
  });
309
297
  }
310
298
  catch (secondError) {
311
299
  const cleanupError = await adapter
312
300
  .cleanupAfterRepairFailure({
301
+ input: retryInput,
313
302
  target,
314
- ownership,
315
- targetExistedBeforeRepair: presentBeforeRetry,
316
303
  })
317
304
  .then(() => undefined)
318
305
  .catch((error) => toErrorMessage(error));
@@ -320,6 +307,15 @@ async function repairTarget(adapter, target, execution, seeded, ownership, event
320
307
  }
321
308
  }
322
309
  }
310
+ function repairSuccessMessage(target, input) {
311
+ if (input === "seed")
312
+ return "reused and repaired";
313
+ if (input === "preserve")
314
+ return "repaired the existing target";
315
+ return target.repairState === "installed"
316
+ ? "installed from a clean target"
317
+ : "repaired from a clean target";
318
+ }
323
319
  function recordBootstrapFailure(events, reporter, adapter, target, message, reason) {
324
320
  recordBootstrapEvent(events, reporter, {
325
321
  adapter: adapter.name,
@@ -344,6 +340,12 @@ function bootstrapStrategy(mode, target, seedable) {
344
340
  }
345
341
  return seedable ? "cow-then-repair" : "repair-only";
346
342
  }
343
+ function adapterForTarget(target) {
344
+ const adapter = createBootstrapAdapters(defaultCheckUvRuntime).find((candidate) => candidate.name === target.adapter);
345
+ if (!adapter)
346
+ throw new Error(`unsupported bootstrap adapter: ${target.adapter}`);
347
+ return adapter;
348
+ }
347
349
  function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
348
350
  return [
349
351
  new LockfileBootstrapAdapter({
@@ -353,8 +355,8 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
353
355
  relativePath: "node_modules",
354
356
  repairCommand: "pnpm install --frozen-lockfile",
355
357
  shell: false,
356
- beforeRepair: async (target) => {
357
- if (!target.existingBeforeBootstrap) {
358
+ beforeRepair: async (target, context) => {
359
+ if (context.input === "seed") {
358
360
  await rm(join(target.targetPath, ".modules.yaml"), {
359
361
  force: true,
360
362
  });
@@ -368,6 +370,13 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
368
370
  relativePath: "node_modules",
369
371
  repairCommand: "yarn install --immutable",
370
372
  shell: false,
373
+ beforeRepair: async (target, context) => {
374
+ if (context.input === "seed") {
375
+ await rm(join(target.targetPath, ".yarn-state.yml"), {
376
+ force: true,
377
+ });
378
+ }
379
+ },
371
380
  }),
372
381
  new LockfileBootstrapAdapter({
373
382
  name: "npm",
@@ -377,7 +386,7 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
377
386
  repairCommand: "npm ci",
378
387
  shell: false,
379
388
  seedPolicy: "never",
380
- repairCommandOverride: (target) => target.existingBeforeBootstrap ? "npm install" : "npm ci",
389
+ selectRepairCommand: (_target, input) => input === "preserve" ? "npm install" : "npm ci",
381
390
  repairState: "installed",
382
391
  }),
383
392
  new LockfileBootstrapAdapter({
@@ -390,6 +399,11 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
390
399
  commandOptions: () => ({
391
400
  env: { BUNDLE_PATH: "vendor/bundle" },
392
401
  }),
402
+ beforeRepair: async (target, context) => {
403
+ if (context.input === "seed") {
404
+ await removeBundlerExtensionMarkers(target.targetPath);
405
+ }
406
+ },
393
407
  }),
394
408
  new LockfileBootstrapAdapter({
395
409
  name: "uv",
@@ -399,6 +413,8 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
399
413
  repairCommand: "uv sync --locked",
400
414
  shell: false,
401
415
  canSeedOverride: checkUvRuntime,
416
+ beforeRepair: validateUvStructure,
417
+ afterRepair: validateUvRelocation,
402
418
  }),
403
419
  new LockfileBootstrapAdapter({
404
420
  name: "cargo",
@@ -406,7 +422,7 @@ function createBootstrapAdapters(checkUvRuntime, cargoBuildCommand) {
406
422
  lockfile: "Cargo.lock",
407
423
  relativePath: "target",
408
424
  repairCommand: cargoBuildCommand?.trim() || "cargo check",
409
- shell: cargoBuildCommand ? undefined : false,
425
+ shell: Boolean(cargoBuildCommand),
410
426
  }),
411
427
  ];
412
428
  }
@@ -420,8 +436,9 @@ class LockfileBootstrapAdapter {
420
436
  seedPolicy;
421
437
  canSeedOverride;
422
438
  beforeRepair;
439
+ afterRepair;
423
440
  commandOptions;
424
- repairCommandOverride;
441
+ selectRepairCommand;
425
442
  repairState;
426
443
  constructor(spec) {
427
444
  this.name = spec.name;
@@ -433,8 +450,9 @@ class LockfileBootstrapAdapter {
433
450
  this.seedPolicy = spec.seedPolicy ?? "always";
434
451
  this.canSeedOverride = spec.canSeedOverride;
435
452
  this.beforeRepair = spec.beforeRepair;
453
+ this.afterRepair = spec.afterRepair;
436
454
  this.commandOptions = spec.commandOptions;
437
- this.repairCommandOverride = spec.repairCommandOverride;
455
+ this.selectRepairCommand = spec.selectRepairCommand;
438
456
  this.repairState = spec.repairState ?? "repaired";
439
457
  }
440
458
  async detect(context) {
@@ -444,6 +462,7 @@ class LockfileBootstrapAdapter {
444
462
  const sourcePath = join(context.sourceRoot, this.relativePath);
445
463
  const targetPath = join(context.worktreePath, this.relativePath);
446
464
  const destinationInspection = await inspectDestination(context.worktreePath, targetPath);
465
+ const initialInput = destinationInspection.kind === "exists" ? "preserve" : "clean";
447
466
  const target = {
448
467
  adapter: this.name,
449
468
  kind: this.kind,
@@ -454,22 +473,26 @@ class LockfileBootstrapAdapter {
454
473
  targetPath,
455
474
  repairCommand: this.defaultRepairCommand,
456
475
  repairState: this.repairState,
457
- existingBeforeBootstrap: destinationInspection.kind === "exists",
476
+ shell: this.shell,
477
+ initialInput,
458
478
  };
459
479
  return {
460
480
  ...target,
461
- repairCommand: this.repairCommandOverride?.(target) ?? target.repairCommand,
481
+ repairCommand: this.selectRepairCommand?.(target, initialInput) ??
482
+ target.repairCommand,
462
483
  };
463
484
  }
464
485
  seedPath(target) {
465
486
  return target.sourcePath;
466
487
  }
467
488
  async repair(target, context) {
468
- await this.beforeRepair?.(target);
469
- await context.runCommand(target.repairCommand, target.worktreePath, context.stderr, context.stdout, {
489
+ await this.beforeRepair?.(target, context);
490
+ const repairCommand = this.selectRepairCommand?.(target, context.input) ?? target.repairCommand;
491
+ await context.runCommand(repairCommand, target.worktreePath, context.stderr, context.stdout, {
470
492
  ...this.commandOptions?.(target),
471
- shell: this.shell,
493
+ shell: target.shell,
472
494
  });
495
+ await this.afterRepair?.(target, context);
473
496
  }
474
497
  async canSeed(target) {
475
498
  if (this.seedPolicy === "never")
@@ -478,28 +501,13 @@ class LockfileBootstrapAdapter {
478
501
  return false;
479
502
  return (await this.canSeedOverride?.(target)) ?? true;
480
503
  }
481
- output(target) {
482
- return {
483
- adapter: this.name,
484
- kind: this.kind,
485
- target: target.relativePath,
486
- repairCommand: target.repairCommand,
487
- };
488
- }
489
504
  shouldRetryAfterRepairFailure(context) {
490
- return context.ownership === "adapter" || context.ownership === "syncDirs";
505
+ return context.input === "seed";
491
506
  }
492
507
  async cleanupAfterRepairFailure(context) {
493
- if (context.target.existingBeforeBootstrap) {
508
+ if (context.input !== "seed")
494
509
  return;
495
- }
496
- if (context.ownership === "adapter" || context.ownership === "syncDirs") {
497
- await rm(context.target.targetPath, { force: true, recursive: true });
498
- return;
499
- }
500
- if (context.ownership === "empty" && !context.targetExistedBeforeRepair) {
501
- await rm(context.target.targetPath, { force: true, recursive: true });
502
- }
510
+ await rm(context.target.targetPath, { force: true, recursive: true });
503
511
  }
504
512
  }
505
513
  async function safeSourceDirectory(target) {
@@ -518,24 +526,251 @@ async function safeSourceDirectory(target) {
518
526
  async function defaultCheckUvRuntime(target) {
519
527
  try {
520
528
  const config = await readFile(join(target.sourcePath, "pyvenv.cfg"), "utf8");
521
- const expected = config.match(/^version\s*=\s*(\d+\.\d+)/mu)?.[1];
529
+ const expected = config.match(/^version(?:_info)?\s*=\s*(\d+\.\d+)/mu)?.[1];
522
530
  if (!expected)
523
531
  return false;
532
+ const expectedImplementation = config
533
+ .match(/^implementation\s*=\s*([^\s#]+)/imu)?.[1]
534
+ ?.toLowerCase();
524
535
  const sourceInterpreter = join(target.sourcePath, process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
525
- const fingerprintScript = "import platform, sys; print(f'{sys.version_info.major}.{sys.version_info.minor}|{platform.machine()}|{sys.implementation.cache_tag}')";
526
- const [source, current] = await Promise.all([
527
- execFileAsync(sourceInterpreter, ["-c", fingerprintScript]),
528
- execFileAsync("python3", ["-c", fingerprintScript]),
536
+ const fingerprintScript = "import platform, sys; print(f'{sys.version_info.major}.{sys.version_info.minor}|{platform.machine()}|{sys.implementation.name}')";
537
+ const source = await execFileAsync(sourceInterpreter, [
538
+ "-c",
539
+ fingerprintScript,
529
540
  ]);
530
541
  const sourceFingerprint = source.stdout.trim();
531
- const currentFingerprint = current.stdout.trim();
532
- return (sourceFingerprint === currentFingerprint &&
533
- sourceFingerprint.startsWith(`${expected}|`));
542
+ const [sourceVersion, sourceMachine, sourceImplementation, extra] = sourceFingerprint.split("|");
543
+ return (extra === undefined &&
544
+ sourceVersion === expected &&
545
+ Boolean(sourceMachine) &&
546
+ Boolean(sourceImplementation) &&
547
+ (expectedImplementation === undefined ||
548
+ sourceImplementation.toLowerCase() === expectedImplementation));
534
549
  }
535
550
  catch {
536
551
  return false;
537
552
  }
538
553
  }
554
+ function virtualEnvironmentPrefixes(text) {
555
+ const prefixes = [];
556
+ for (const line of text.split(/\r?\n/u)) {
557
+ if (!/\bVIRTUAL_ENV\b/u.test(line))
558
+ continue;
559
+ const quotedAssignment = line.match(/\bVIRTUAL_ENV(?:\s*=|\s+)\s*(?:"([^"]+)"|'([^']+)')/u);
560
+ const quotedPrefix = quotedAssignment?.[1] ?? quotedAssignment?.[2];
561
+ if (quotedPrefix && isAbsolute(quotedPrefix))
562
+ prefixes.push(quotedPrefix);
563
+ const unquoted = line.match(/\bVIRTUAL_ENV(?:\s*=|\s+)\s*([^\s"']+)/u)?.[1];
564
+ if (unquoted && isAbsolute(unquoted))
565
+ prefixes.push(unquoted);
566
+ }
567
+ return uniquePaths(prefixes);
568
+ }
569
+ function pythonPrefixFromShebang(text) {
570
+ const command = text
571
+ .match(/^#!\s*([^\r\n]+)/u)?.[1]
572
+ ?.trim()
573
+ .split(/\s+/u)[0];
574
+ if (!command || !isAbsolute(command))
575
+ return undefined;
576
+ if (!/^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(command.split(sep).at(-1) ?? "")) {
577
+ return undefined;
578
+ }
579
+ return dirname(dirname(command));
580
+ }
581
+ function pythonPrefixesFromText(text) {
582
+ const interpreters = [];
583
+ for (const match of text.matchAll(/["'](\/[^"']+)["']/gu)) {
584
+ if (match[1])
585
+ interpreters.push(match[1]);
586
+ }
587
+ for (const match of text.matchAll(/(?:^|\s)(\/[^\s"']+)/gmu)) {
588
+ if (match[1])
589
+ interpreters.push(match[1]);
590
+ }
591
+ return uniquePaths(interpreters.flatMap((interpreter) => {
592
+ const name = interpreter.split(sep).at(-1) ?? "";
593
+ return /^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(name)
594
+ ? [dirname(dirname(interpreter))]
595
+ : [];
596
+ }));
597
+ }
598
+ async function validateUvRelocation(target, context) {
599
+ if (context.input === "preserve")
600
+ return;
601
+ const scriptsDirectory = join(target.targetPath, process.platform === "win32" ? "Scripts" : "bin");
602
+ const interpreter = join(scriptsDirectory, process.platform === "win32" ? "python.exe" : "python");
603
+ const { stdout } = await execFileAsync(interpreter, [
604
+ "-c",
605
+ "import os, sys; print(os.path.realpath(sys.prefix))",
606
+ ]);
607
+ const [actualPrefix, expectedPrefix] = await Promise.all([
608
+ realpath(stdout.trim()),
609
+ realpath(target.targetPath),
610
+ ]);
611
+ if (actualPrefix !== expectedPrefix) {
612
+ throw new Error(`uv environment still points to its source prefix: ${actualPrefix}`);
613
+ }
614
+ const sourcePaths = uniquePaths([
615
+ target.sourcePath,
616
+ await realpath(target.sourcePath).catch(() => undefined),
617
+ ]);
618
+ const entries = await readdir(scriptsDirectory, { withFileTypes: true });
619
+ assertSafeUvScriptEntries(entries);
620
+ const environmentName = basename(target.targetPath);
621
+ const acceptedPrefixes = new Set([
622
+ resolve(target.targetPath),
623
+ await realpath(target.targetPath),
624
+ ]);
625
+ let validatedBytes = 0;
626
+ for (const path of [
627
+ join(target.targetPath, "pyvenv.cfg"),
628
+ ...entries
629
+ .filter((entry) => entry.isFile())
630
+ .map((entry) => join(scriptsDirectory, entry.name)),
631
+ ]) {
632
+ const validated = await readBoundedUvTextFile(path, UV_VALIDATION_MAX_TOTAL_BYTES - validatedBytes);
633
+ if (!validated)
634
+ continue;
635
+ validatedBytes += validated.bytes;
636
+ if (validated.text === undefined)
637
+ continue;
638
+ const { text } = validated;
639
+ if (sourcePaths.some((sourcePath) => text.includes(sourcePath))) {
640
+ throw new Error(`uv environment contains a stale source path: ${path}`);
641
+ }
642
+ const shebangPrefix = pythonPrefixFromShebang(text);
643
+ if (shebangPrefix &&
644
+ basename(shebangPrefix) === environmentName &&
645
+ !acceptedPrefixes.has(resolve(shebangPrefix))) {
646
+ throw new Error(`uv launcher points outside its environment: ${path}`);
647
+ }
648
+ if (pythonPrefixesFromText(text)
649
+ .filter((prefix) => basename(prefix) === environmentName)
650
+ .some((prefix) => !acceptedPrefixes.has(resolve(prefix)))) {
651
+ throw new Error(`uv launcher points outside its environment: ${path}`);
652
+ }
653
+ if (virtualEnvironmentPrefixes(text).some((prefix) => !acceptedPrefixes.has(resolve(prefix)))) {
654
+ throw new Error(`uv activation script points outside its environment: ${path}`);
655
+ }
656
+ }
657
+ }
658
+ async function validateUvStructure(target, context) {
659
+ if (context.input === "preserve")
660
+ return;
661
+ const targetStats = await lstat(target.targetPath).catch(() => undefined);
662
+ if (!targetStats)
663
+ return;
664
+ if (!targetStats.isDirectory() || targetStats.isSymbolicLink()) {
665
+ throw new Error("uv environment must be a real directory");
666
+ }
667
+ const configStats = await lstat(join(target.targetPath, "pyvenv.cfg")).catch(() => undefined);
668
+ if (configStats && (!configStats.isFile() || configStats.isSymbolicLink())) {
669
+ throw new Error("uv pyvenv.cfg must be a regular file");
670
+ }
671
+ const scriptsDirectory = join(target.targetPath, process.platform === "win32" ? "Scripts" : "bin");
672
+ const scriptsStats = await lstat(scriptsDirectory).catch(() => undefined);
673
+ if (!scriptsStats)
674
+ return;
675
+ if (!scriptsStats.isDirectory() || scriptsStats.isSymbolicLink()) {
676
+ throw new Error("uv scripts path must be a real directory");
677
+ }
678
+ assertSafeUvScriptEntries(await readdir(scriptsDirectory, { withFileTypes: true }));
679
+ }
680
+ function assertSafeUvScriptEntries(entries) {
681
+ if (entries.length > UV_VALIDATION_MAX_ENTRIES) {
682
+ throw new Error("uv scripts directory exceeds the validation entry limit");
683
+ }
684
+ for (const entry of entries) {
685
+ if (entry.isSymbolicLink() && !isUvInterpreterName(entry.name)) {
686
+ throw new Error(`uv script must not be a symbolic link: ${entry.name}`);
687
+ }
688
+ }
689
+ }
690
+ async function readBoundedUvTextFile(path, remainingBytes) {
691
+ let handle;
692
+ try {
693
+ handle = await open(path, constants.O_RDONLY |
694
+ (process.platform === "win32" ? 0 : constants.O_NOFOLLOW));
695
+ }
696
+ catch (error) {
697
+ if (isNotFoundError(error))
698
+ return undefined;
699
+ throw error;
700
+ }
701
+ try {
702
+ const stats = await handle.stat();
703
+ if (!stats.isFile())
704
+ return undefined;
705
+ if (stats.size > UV_VALIDATION_MAX_FILE_BYTES) {
706
+ throw new Error(`uv launcher exceeds the validation size limit: ${path}`);
707
+ }
708
+ if (stats.size > remainingBytes) {
709
+ throw new Error("uv launchers exceed the total validation size limit");
710
+ }
711
+ const readLimit = Math.min(UV_VALIDATION_MAX_FILE_BYTES, remainingBytes);
712
+ const contents = await readFilePrefix(handle, readLimit + 1);
713
+ if (contents.byteLength > UV_VALIDATION_MAX_FILE_BYTES) {
714
+ throw new Error(`uv launcher exceeds the validation size limit: ${path}`);
715
+ }
716
+ if (contents.byteLength > remainingBytes) {
717
+ throw new Error("uv launchers exceed the total validation size limit");
718
+ }
719
+ const text = contents.toString("utf8");
720
+ return Buffer.from(text, "utf8").equals(contents)
721
+ ? { bytes: contents.byteLength, text }
722
+ : { bytes: contents.byteLength };
723
+ }
724
+ finally {
725
+ await handle.close();
726
+ }
727
+ }
728
+ async function readFilePrefix(handle, maxBytes) {
729
+ const contents = Buffer.allocUnsafe(maxBytes);
730
+ let offset = 0;
731
+ while (offset < contents.byteLength) {
732
+ const { bytesRead } = await handle.read(contents, offset, contents.byteLength - offset, offset);
733
+ if (bytesRead === 0)
734
+ break;
735
+ offset += bytesRead;
736
+ }
737
+ return contents.subarray(0, offset);
738
+ }
739
+ function isUvInterpreterName(name) {
740
+ return /^python(?:\d+(?:\.\d+)?)?(?:\.exe)?$/iu.test(name);
741
+ }
742
+ async function removeBundlerExtensionMarkers(root) {
743
+ const pending = [root];
744
+ while (pending.length > 0) {
745
+ const current = pending.pop();
746
+ if (!current)
747
+ continue;
748
+ let entries;
749
+ try {
750
+ entries = await readdir(current, { withFileTypes: true });
751
+ }
752
+ catch (error) {
753
+ if (isNotFoundError(error))
754
+ continue;
755
+ throw error;
756
+ }
757
+ for (const entry of entries) {
758
+ const path = join(current, entry.name);
759
+ if (entry.isDirectory())
760
+ pending.push(path);
761
+ else if (entry.isFile() && isBundlerExtensionMarker(root, path)) {
762
+ await rm(path, { force: true });
763
+ }
764
+ }
765
+ }
766
+ }
767
+ function isBundlerExtensionMarker(root, path) {
768
+ const segments = relative(root, path).split(sep);
769
+ return (segments.length >= 7 &&
770
+ segments[0] === "ruby" &&
771
+ segments[2] === "extensions" &&
772
+ segments.at(-1) === "gem.build_complete");
773
+ }
539
774
  function uniquePaths(paths) {
540
775
  return [...new Set(paths.filter((path) => Boolean(path)))];
541
776
  }