pyfig 0.7.0 → 0.7.1

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.
Files changed (79) hide show
  1. package/README.md +76 -178
  2. package/SECURITY.md +16 -5
  3. package/dist/cjs/index.js +482 -1131
  4. package/dist/cjs/index.js.map +6 -16
  5. package/dist/index.d.ts +2 -8
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -5
  8. package/dist/index.js.map +1 -1
  9. package/dist/process.d.ts +6 -12
  10. package/dist/process.d.ts.map +1 -1
  11. package/dist/process.js +35 -54
  12. package/dist/process.js.map +1 -1
  13. package/dist/pyfig.d.ts +31 -0
  14. package/dist/pyfig.d.ts.map +1 -0
  15. package/dist/pyfig.js +275 -0
  16. package/dist/pyfig.js.map +1 -0
  17. package/dist/uv.d.ts +3 -0
  18. package/dist/uv.d.ts.map +1 -0
  19. package/dist/uv.js +423 -0
  20. package/dist/uv.js.map +1 -0
  21. package/package.json +6 -12
  22. package/dist/cjs/sync-resolver.js +0 -1342
  23. package/dist/cjs/sync-resolver.js.map +0 -20
  24. package/dist/cli.d.ts +0 -3
  25. package/dist/cli.d.ts.map +0 -1
  26. package/dist/cli.js +0 -57
  27. package/dist/cli.js.map +0 -1
  28. package/dist/client.d.ts +0 -15
  29. package/dist/client.d.ts.map +0 -1
  30. package/dist/client.js +0 -35
  31. package/dist/client.js.map +0 -1
  32. package/dist/config.d.ts +0 -69
  33. package/dist/config.d.ts.map +0 -1
  34. package/dist/config.js +0 -159
  35. package/dist/config.js.map +0 -1
  36. package/dist/package-target.d.ts +0 -8
  37. package/dist/package-target.d.ts.map +0 -1
  38. package/dist/package-target.js +0 -81
  39. package/dist/package-target.js.map +0 -1
  40. package/dist/pep440.d.ts +0 -12
  41. package/dist/pep440.d.ts.map +0 -1
  42. package/dist/pep440.js +0 -21
  43. package/dist/pep440.js.map +0 -1
  44. package/dist/probes.d.ts +0 -37
  45. package/dist/probes.d.ts.map +0 -1
  46. package/dist/probes.js +0 -145
  47. package/dist/probes.js.map +0 -1
  48. package/dist/project-root.d.ts +0 -3
  49. package/dist/project-root.d.ts.map +0 -1
  50. package/dist/project-root.js +0 -22
  51. package/dist/project-root.js.map +0 -1
  52. package/dist/provision-lock.d.ts +0 -10
  53. package/dist/provision-lock.d.ts.map +0 -1
  54. package/dist/provision-lock.js +0 -24
  55. package/dist/provision-lock.js.map +0 -1
  56. package/dist/provision.d.ts +0 -20
  57. package/dist/provision.d.ts.map +0 -1
  58. package/dist/provision.js +0 -673
  59. package/dist/provision.js.map +0 -1
  60. package/dist/python-venv.d.ts +0 -12
  61. package/dist/python-venv.d.ts.map +0 -1
  62. package/dist/python-venv.js +0 -52
  63. package/dist/python-venv.js.map +0 -1
  64. package/dist/runners.d.ts +0 -14
  65. package/dist/runners.d.ts.map +0 -1
  66. package/dist/runners.js +0 -71
  67. package/dist/runners.js.map +0 -1
  68. package/dist/runtime-files.d.ts +0 -3
  69. package/dist/runtime-files.d.ts.map +0 -1
  70. package/dist/runtime-files.js +0 -12
  71. package/dist/runtime-files.js.map +0 -1
  72. package/dist/sync-resolver.d.ts +0 -2
  73. package/dist/sync-resolver.d.ts.map +0 -1
  74. package/dist/sync-resolver.js +0 -15
  75. package/dist/sync-resolver.js.map +0 -1
  76. package/dist/venv-identity.d.ts +0 -36
  77. package/dist/venv-identity.d.ts.map +0 -1
  78. package/dist/venv-identity.js +0 -80
  79. package/dist/venv-identity.js.map +0 -1
package/dist/provision.js DELETED
@@ -1,673 +0,0 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { createReadStream, createWriteStream } from 'node:fs';
3
- import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile, } from 'node:fs/promises';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import { Readable } from 'node:stream';
7
- import { pipeline } from 'node:stream/promises';
8
- import { unzipSync } from 'fflate';
9
- import * as tar from 'tar';
10
- import { EnvHttpProxyAgent, fetch } from 'undici';
11
- import { hasRuntimeRequirements, resolveOptions, uvProcessEnvironment, } from './config.js';
12
- import { createVenvIdentity, requirementsDigest, venvMatches, writeVenvMetadata, } from './venv-identity.js';
13
- import { withProvisioningLock } from './provision-lock.js';
14
- import { sanitizeUvProcessEnvironment, uvCommandProcessEnvironment, } from './package-target.js';
15
- import { maxPep440Version, normalizePep440Version, } from './pep440.js';
16
- import { findUv, probePython, probeUv, pythonSatisfiesPolicy, } from './probes.js';
17
- import { runAsync } from './process.js';
18
- import { canonicalPath, externalPythonVenvDirectories, pathsEqual, pythonVenvDirectory, pythonVenvExecutable, } from './python-venv.js';
19
- /** Ensure a compatible uv executable, preferring an existing installation. */
20
- export async function ensureUv(options = {}) {
21
- return ensureUvResolved(resolveOptions(options));
22
- }
23
- /**
24
- * Select an external dependency-free venv or a private content-addressed venv.
25
- * Only caller-provided dependency specifiers and requirements files are resolved.
26
- */
27
- export async function ensurePython(options = {}) {
28
- const resolved = resolveOptions(options);
29
- await validateRequirementsFiles(resolved.requirements);
30
- const uv = await ensureUvResolved(resolved);
31
- if (!hasRuntimeRequirements(resolved)) {
32
- const external = await findReusableExternalPythonVenv(resolved);
33
- if (external) {
34
- return { managed: false, python: external, uv, venvId: null };
35
- }
36
- }
37
- const basePython = await ensureBasePythonResolved(uv, resolved);
38
- const resolution = await resolveRuntimeDependencies(uv, basePython, resolved);
39
- const venv = createVenvIdentity(basePython, resolution.sha256);
40
- const python = await ensureManagedPythonVenv(uv, basePython, venv, resolution, resolved);
41
- return { managed: true, python, uv, venvId: venv.id };
42
- }
43
- async function ensureManagedPythonVenv(uv, basePython, venv, resolution, options) {
44
- const directory = pythonVenvDirectory(options.projectRoot, venv.directoryName);
45
- const check = async () => {
46
- if (!await venvMatches(directory, venv))
47
- return null;
48
- const candidate = await inspectPythonVenv(directory, options);
49
- return candidate && pythonSatisfiesPolicy(candidate, options) ? candidate : null;
50
- };
51
- const existing = await check();
52
- if (existing)
53
- return existing;
54
- if (!options.createVirtualEnvironment) {
55
- throw new Error(`No compatible environment is available and creation is disabled. ` +
56
- `Pyfig would create ${directory}.`);
57
- }
58
- return withProvisioningLock({
59
- key: `venv:${canonicalPath(directory)}`,
60
- timeoutMs: options.coordinationTimeoutMs,
61
- check,
62
- mutate: async () => {
63
- await rm(directory, { recursive: true, force: true });
64
- await mkdir(path.dirname(directory), { recursive: true });
65
- try {
66
- await runAsync(uv.executable, [
67
- 'venv',
68
- '--no-project',
69
- '--no-config',
70
- '--no-python-downloads',
71
- '--python',
72
- basePython.executable,
73
- directory,
74
- ], isolatedUvProcessEnvironment(options), options.installTimeoutMs);
75
- const created = await inspectPythonVenv(directory, options);
76
- if (!created || !pythonSatisfiesPolicy(created, options)) {
77
- throw new Error(`uv created ${directory}, but its Python does not satisfy the requested capabilities.`);
78
- }
79
- await installResolvedDependencies(uv, created, resolution, options);
80
- await writeVenvMetadata(directory, venv);
81
- const installed = await check();
82
- if (!installed)
83
- throw new Error(`Pyfig could not validate environment ${directory}.`);
84
- return installed;
85
- }
86
- catch (error) {
87
- await rm(directory, { recursive: true, force: true });
88
- throw error;
89
- }
90
- },
91
- });
92
- }
93
- async function findReusableExternalPythonVenv(options) {
94
- if (options.python) {
95
- const configured = await probePython({ executable: options.python, args: [] }, options);
96
- if (!configured || !pythonSatisfiesPolicy(configured, options)) {
97
- throw incompatibleConfiguredPython(options);
98
- }
99
- if (configured.virtualEnvironment)
100
- return configured;
101
- }
102
- for (const directory of externalPythonVenvDirectories(options.projectRoot, options.env)) {
103
- const info = await inspectPythonVenv(directory, options);
104
- if (info && pythonSatisfiesPolicy(info, options))
105
- return info;
106
- }
107
- return null;
108
- }
109
- async function ensureBasePythonResolved(uv, options) {
110
- if (options.python) {
111
- const configured = await probePython({ executable: options.python, args: [] }, options);
112
- if (!configured || !pythonSatisfiesPolicy(configured, options)) {
113
- throw incompatibleConfiguredPython(options);
114
- }
115
- if (!configured.virtualEnvironment)
116
- return configured;
117
- const base = await probePython({ executable: configured.baseExecutable, args: [] }, options);
118
- if (base && pythonSatisfiesPolicy(base, options))
119
- return base;
120
- throw incompatibleConfiguredPython(options);
121
- }
122
- const externalBase = await findExternalBasePython(options);
123
- if (externalBase)
124
- return externalBase;
125
- // Corporate and offline hosts get a strict system-only pass before Pyfig
126
- // considers any already-managed interpreter or network installation.
127
- const system = await findPythonWithUv(uv, options, 'system');
128
- if (system)
129
- return system;
130
- const cachedManaged = await findPythonWithUv(uv, options, 'managed');
131
- if (cachedManaged)
132
- return cachedManaged;
133
- if (!options.installPython) {
134
- throw new Error(missingPythonMessage(options, 'managed Python installation is disabled'));
135
- }
136
- return withProvisioningLock({
137
- key: `python:${canonicalPath(path.join(options.cacheDir, 'python'))}`,
138
- timeoutMs: options.coordinationTimeoutMs,
139
- check: () => findPythonWithUv(uv, options, 'managed'),
140
- mutate: async () => {
141
- await runAsync(uv.executable, ['python', 'install', '--no-config', options.managedPython], isolatedUvProcessEnvironment(options), options.installTimeoutMs);
142
- const installed = await findPythonWithUv(uv, options, 'managed');
143
- if (!installed) {
144
- throw new Error(missingPythonMessage(options, `uv installed ${options.managedPython}, but it remains incompatible`));
145
- }
146
- return installed;
147
- },
148
- });
149
- }
150
- async function findExternalBasePython(options) {
151
- for (const directory of externalPythonVenvDirectories(options.projectRoot, options.env)) {
152
- const environment = await inspectPythonVenv(directory, options);
153
- if (!environment || !pythonSatisfiesPolicy(environment, options))
154
- continue;
155
- const base = await probePython({ executable: environment.baseExecutable, args: [] }, options);
156
- if (base && !base.virtualEnvironment && pythonSatisfiesPolicy(base, options))
157
- return base;
158
- }
159
- return null;
160
- }
161
- async function findPythonWithUv(uv, options, source) {
162
- for (const request of ['', options.managedPython]) {
163
- const args = [
164
- 'python',
165
- 'find',
166
- '--no-project',
167
- '--no-config',
168
- '--no-python-downloads',
169
- source === 'system' ? '--system' : '--managed-python',
170
- ];
171
- if (request)
172
- args.push(request);
173
- try {
174
- const { stdout } = await runAsync(uv.executable, args, explicitPythonSourceProcessEnvironment(options), options.probeTimeoutMs);
175
- const info = await probePython({ executable: stdout.trim(), args: [] }, options);
176
- if (info && pythonSatisfiesPolicy(info, options))
177
- return info;
178
- }
179
- catch {
180
- // Continue through the generic and configured version requests.
181
- }
182
- }
183
- // `uv python find` returns one match. That match can lack development
184
- // headers even when another installed interpreter satisfies every requested
185
- // capability, so inspect uv's complete installed-only inventory before
186
- // moving from system Python to managed Python or from cache to download.
187
- try {
188
- const { stdout } = await runAsync(uv.executable, [
189
- 'python',
190
- 'list',
191
- '--no-config',
192
- '--no-python-downloads',
193
- '--only-installed',
194
- '--output-format',
195
- 'json',
196
- source === 'system' ? '--no-managed-python' : '--managed-python',
197
- ], explicitPythonSourceProcessEnvironment(options), options.probeTimeoutMs);
198
- const candidates = installedPythonPaths(stdout);
199
- for (const executable of candidates) {
200
- const info = await probePython({ executable, args: [] }, options);
201
- if (info && !info.virtualEnvironment && pythonSatisfiesPolicy(info, options)) {
202
- return info;
203
- }
204
- }
205
- }
206
- catch {
207
- // Older compatible uv releases can still use the find-based fallback.
208
- }
209
- return null;
210
- }
211
- function installedPythonPaths(output) {
212
- const parsed = JSON.parse(output);
213
- if (!Array.isArray(parsed))
214
- return [];
215
- const paths = parsed.flatMap((entry) => {
216
- if (!entry || typeof entry !== 'object' || !('path' in entry))
217
- return [];
218
- const candidate = entry.path;
219
- return typeof candidate === 'string' && candidate ? [candidate] : [];
220
- });
221
- return [...new Map(paths.map((candidate) => [canonicalPath(candidate), candidate])).values()];
222
- }
223
- async function inspectPythonVenv(directory, options) {
224
- const executable = pythonVenvExecutable(directory);
225
- const info = await probePython({ executable, args: [] }, options);
226
- return info?.virtualEnvironment && pathsEqual(info.virtualEnvironment, directory) ? info : null;
227
- }
228
- async function resolveRuntimeDependencies(uv, python, options) {
229
- if (!hasRuntimeRequirements(options)) {
230
- return { content: '', sha256: requirementsDigest('') };
231
- }
232
- const temporary = await mkdtemp(path.join(os.tmpdir(), 'pyfig-resolution-'));
233
- try {
234
- const additions = path.join(temporary, 'runtime-dependencies.txt');
235
- const output = path.join(temporary, 'resolved-dependencies.txt');
236
- const sources = [...options.requirements];
237
- if (options.dependencies.length > 0) {
238
- await writeFile(additions, `${options.dependencies.join('\n')}\n`, 'utf8');
239
- sources.push(additions);
240
- }
241
- await runAsync(uv.executable, [
242
- 'pip', 'compile', '--no-config', '--no-header', '--no-annotate',
243
- '--generate-hashes', '--python', python.executable,
244
- ...sources, '--output-file', output,
245
- ], isolatedUvProcessEnvironment(options), options.installTimeoutMs, undefined, options.projectRoot);
246
- const content = normalizeResolution(await readFile(output, 'utf8'));
247
- return { content, sha256: requirementsDigest(content) };
248
- }
249
- finally {
250
- await rm(temporary, { recursive: true, force: true });
251
- }
252
- }
253
- async function validateRequirementsFiles(requirements) {
254
- for (const requirement of requirements) {
255
- let information;
256
- try {
257
- information = await stat(requirement);
258
- }
259
- catch (error) {
260
- throw new Error(`Requirements file is unavailable: ${requirement}`, { cause: error });
261
- }
262
- if (!information.isFile()) {
263
- throw new Error(`Requirements path is not a file: ${requirement}`);
264
- }
265
- }
266
- }
267
- async function installResolvedDependencies(uv, python, resolution, options) {
268
- if (!resolution.content)
269
- return;
270
- if (!python.virtualEnvironment) {
271
- throw new Error('Runtime dependency installation requires a virtual environment.');
272
- }
273
- const temporary = await mkdtemp(path.join(os.tmpdir(), 'pyfig-install-'));
274
- try {
275
- const requirements = path.join(temporary, 'resolved-dependencies.txt');
276
- await writeFile(requirements, resolution.content, { encoding: 'utf8', flag: 'wx' });
277
- await runAsync(uv.executable, ['pip', 'install', '--no-config', '--python', python.executable, '-r', requirements], targetUvProcessEnvironment(options, python), options.installTimeoutMs, undefined, options.projectRoot);
278
- await runAsync(uv.executable, ['pip', 'check', '--no-config', '--python', python.executable], targetUvProcessEnvironment(options, python), options.installTimeoutMs, undefined, options.projectRoot);
279
- }
280
- finally {
281
- await rm(temporary, { recursive: true, force: true });
282
- }
283
- }
284
- function normalizeResolution(content) {
285
- const normalized = content.replace(/\r\n/gu, '\n').trimEnd();
286
- return normalized ? `${normalized}\n` : '';
287
- }
288
- async function ensureUvResolved(options) {
289
- const existing = await findAvailableUv(options);
290
- if (existing)
291
- return existing;
292
- if (!options.installUv) {
293
- throw new Error(`Compatible uv is unavailable (requires-uv ${options.requiresUv}), and uv installation is disabled.`);
294
- }
295
- const policyKey = createHash('sha256').update(options.requiresUv).digest('hex');
296
- return withProvisioningLock({
297
- key: `uv-policy:${canonicalPath(uvInstallRoot(options))}:${policyKey}`,
298
- timeoutMs: options.coordinationTimeoutMs,
299
- check: () => findAvailableUv(options),
300
- mutate: async () => {
301
- const release = await resolveUvRelease(options);
302
- const installDir = uvInstallDirectory(options, release.version);
303
- const executable = uvExecutable(options, release.version);
304
- return withProvisioningLock({
305
- key: `uv-release:${canonicalPath(installDir)}`,
306
- timeoutMs: options.coordinationTimeoutMs,
307
- check: () => findAvailableUv(options),
308
- mutate: async () => {
309
- await installUv(installDir, release, options);
310
- const result = probeUv(executable, options);
311
- if (!result || result.version !== release.version) {
312
- throw new Error(`Expected managed uv ${release.version}, but the installed executable did not match.`);
313
- }
314
- return result;
315
- },
316
- });
317
- },
318
- });
319
- }
320
- async function findAvailableUv(options) {
321
- const external = await findUv(options);
322
- if (external)
323
- return external;
324
- return findManagedUv(options);
325
- }
326
- async function findManagedUv(options) {
327
- const entries = await readdir(uvInstallRoot(options), { withFileTypes: true }).catch(() => []);
328
- let versions = entries
329
- .filter(entry => entry.isDirectory() && normalizePep440Version(entry.name) === entry.name)
330
- .map(entry => entry.name);
331
- while (versions.length > 0) {
332
- const selected = maxPep440Version(versions, options.requiresUv);
333
- if (!selected)
334
- return null;
335
- const candidate = probeUv(uvExecutable(options, selected), options);
336
- if (candidate?.version === selected)
337
- return candidate;
338
- versions = versions.filter(version => version !== selected);
339
- }
340
- return null;
341
- }
342
- function isolatedUvProcessEnvironment(options) {
343
- return sanitizeUvProcessEnvironment(uvProcessEnvironment(options));
344
- }
345
- /** Explicit --system/--managed-python selectors cannot be combined with uv's preference setting. */
346
- function explicitPythonSourceProcessEnvironment(options) {
347
- const env = isolatedUvProcessEnvironment(options);
348
- delete env['UV_PYTHON_PREFERENCE'];
349
- return env;
350
- }
351
- function targetUvProcessEnvironment(options, python) {
352
- return uvCommandProcessEnvironment(uvProcessEnvironment(options), python);
353
- }
354
- function incompatibleConfiguredPython(options) {
355
- return new Error(`Configured Python ${options.python} is unavailable or does not satisfy ` +
356
- `requires-python ${options.requiresPython}` +
357
- `${options.requireEmbedding ? ' with CPython embedding files' : ''}.`);
358
- }
359
- function missingPythonMessage(options, reason) {
360
- const embedding = options.requireEmbedding
361
- ? ' with compatible CPython headers, library, architecture, and GIL ABI'
362
- : '';
363
- return `No Python satisfies requires-python ${options.requiresPython}${embedding}; ${reason}.`;
364
- }
365
- const UV_RELEASE_PAGE_SIZE = 100;
366
- const UV_RELEASE_MAX_PAGES = 20;
367
- const UV_RELEASE_METADATA_TTL_MS = 24 * 60 * 60_000;
368
- async function resolveUvRelease(options) {
369
- const catalog = await ensureUvReleaseCatalog(options);
370
- const asset = uvAsset();
371
- const releases = catalog.releases.filter(release => release.assets.includes(asset) && release.assets.includes(`${asset}.sha256`));
372
- const version = maxPep440Version(releases.map(release => release.version), options.requiresUv);
373
- const release = releases.find(candidate => candidate.version === version);
374
- if (!release) {
375
- throw new Error(`No published uv release satisfies requires-uv ${options.requiresUv}.`);
376
- }
377
- return release;
378
- }
379
- async function ensureUvReleaseCatalog(options) {
380
- const file = uvReleaseMetadataFile(options);
381
- const stale = await readUvReleaseCatalog(file);
382
- try {
383
- return await withProvisioningLock({
384
- key: `uv-metadata:${canonicalPath(file)}`,
385
- timeoutMs: options.coordinationTimeoutMs,
386
- check: () => readFreshUvReleaseCatalog(file),
387
- mutate: async () => {
388
- const catalog = await fetchUvReleaseCatalog(options);
389
- await writeUvReleaseCatalog(file, catalog);
390
- return catalog;
391
- },
392
- });
393
- }
394
- catch (error) {
395
- const fallback = await readUvReleaseCatalog(file) ?? stale;
396
- if (fallback)
397
- return fallback;
398
- throw error;
399
- }
400
- }
401
- async function fetchUvReleaseCatalog(options) {
402
- const dispatcher = proxyDispatcher(options.env);
403
- const releases = new Map();
404
- let exhausted = false;
405
- try {
406
- for (let page = 1; page <= UV_RELEASE_MAX_PAGES; page += 1) {
407
- const text = await downloadText(`https://api.github.com/repos/astral-sh/uv/releases?per_page=${UV_RELEASE_PAGE_SIZE}&page=${page}`, options.downloadTimeoutMs, dispatcher, githubApiHeaders(options.env));
408
- const payload = JSON.parse(text);
409
- if (!Array.isArray(payload)) {
410
- throw new Error('GitHub returned invalid uv release metadata.');
411
- }
412
- for (const candidate of payload) {
413
- if (!candidate || typeof candidate !== 'object')
414
- continue;
415
- const release = candidate;
416
- if (release.draft === true || typeof release.tag_name !== 'string')
417
- continue;
418
- const assetNames = releaseAssetNames(release.assets);
419
- const version = normalizePep440Version(release.tag_name);
420
- if (version) {
421
- releases.set(version, { assets: assetNames, tag: release.tag_name, version });
422
- }
423
- }
424
- if (payload.length < UV_RELEASE_PAGE_SIZE) {
425
- exhausted = true;
426
- break;
427
- }
428
- }
429
- }
430
- finally {
431
- await dispatcher?.close();
432
- }
433
- if (!exhausted) {
434
- throw new Error(`uv release catalog exceeded ${UV_RELEASE_MAX_PAGES} pages.`);
435
- }
436
- if (releases.size === 0) {
437
- throw new Error('GitHub returned no usable uv releases.');
438
- }
439
- return { fetchedAt: Date.now(), releases: [...releases.values()] };
440
- }
441
- /** Build GitHub API headers without exposing credentials outside the request. */
442
- export function githubApiHeaders(env) {
443
- const token = [env['GITHUB_TOKEN'], env['GH_TOKEN']]
444
- .find(value => typeof value === 'string' && value.trim().length > 0);
445
- if (token && /[\r\n]/u.test(token)) {
446
- throw new TypeError('GitHub API token must not contain line breaks.');
447
- }
448
- return {
449
- Accept: 'application/vnd.github+json',
450
- 'User-Agent': 'pyfig',
451
- 'X-GitHub-Api-Version': '2022-11-28',
452
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
453
- };
454
- }
455
- async function readFreshUvReleaseCatalog(file) {
456
- const catalog = await readUvReleaseCatalog(file);
457
- if (!catalog)
458
- return null;
459
- const age = Date.now() - catalog.fetchedAt;
460
- return age >= 0 && age < UV_RELEASE_METADATA_TTL_MS ? catalog : null;
461
- }
462
- async function readUvReleaseCatalog(file) {
463
- try {
464
- return parseUvReleaseCatalog(JSON.parse(await readFile(file, 'utf8')));
465
- }
466
- catch {
467
- return null;
468
- }
469
- }
470
- function parseUvReleaseCatalog(value) {
471
- if (!value || typeof value !== 'object')
472
- return null;
473
- const catalog = value;
474
- if (!Number.isSafeInteger(catalog.fetchedAt) || catalog.fetchedAt < 0 ||
475
- !Array.isArray(catalog.releases)) {
476
- return null;
477
- }
478
- const releases = [];
479
- for (const value of catalog.releases) {
480
- if (!value || typeof value !== 'object')
481
- return null;
482
- const release = value;
483
- if (!Array.isArray(release.assets) ||
484
- !release.assets.every(asset => typeof asset === 'string') ||
485
- typeof release.tag !== 'string' ||
486
- !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/u.test(release.tag) ||
487
- typeof release.version !== 'string' ||
488
- normalizePep440Version(release.version) !== release.version) {
489
- return null;
490
- }
491
- releases.push({
492
- assets: release.assets,
493
- tag: release.tag,
494
- version: release.version,
495
- });
496
- }
497
- return { fetchedAt: catalog.fetchedAt, releases };
498
- }
499
- async function writeUvReleaseCatalog(file, catalog) {
500
- await mkdir(path.dirname(file), { recursive: true });
501
- const staged = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
502
- try {
503
- await writeFile(staged, `${JSON.stringify(catalog)}\n`, { encoding: 'utf8', flag: 'wx' });
504
- if (process.platform === 'win32')
505
- await rm(file, { force: true });
506
- await rename(staged, file);
507
- }
508
- finally {
509
- await rm(staged, { force: true });
510
- }
511
- }
512
- function uvReleaseMetadataFile(options) {
513
- return path.join(options.cacheDir, 'metadata', 'uv-releases.json');
514
- }
515
- function releaseAssetNames(value) {
516
- if (!Array.isArray(value))
517
- return [];
518
- return value.flatMap(entry => {
519
- if (!entry || typeof entry !== 'object')
520
- return [];
521
- const name = entry.name;
522
- return typeof name === 'string' ? [name] : [];
523
- });
524
- }
525
- function uvInstallRoot(options) {
526
- return path.join(options.cacheDir, 'tools', 'uv');
527
- }
528
- function uvInstallDirectory(options, version) {
529
- return path.join(uvInstallRoot(options), version);
530
- }
531
- function uvExecutable(options, version) {
532
- return path.join(uvInstallDirectory(options, version), process.platform === 'win32' ? 'uv.exe' : 'uv');
533
- }
534
- async function installUv(installDir, release, options) {
535
- const asset = uvAsset();
536
- const baseUrl = `https://github.com/astral-sh/uv/releases/download/${release.tag}`;
537
- const temporary = await mkdtemp(path.join(os.tmpdir(), 'pyfig-uv-'));
538
- const dispatcher = proxyDispatcher(options.env);
539
- try {
540
- const archive = path.join(temporary, asset);
541
- await download(`${baseUrl}/${asset}`, archive, options.downloadTimeoutMs, dispatcher);
542
- const expected = (await downloadText(`${baseUrl}/${asset}.sha256`, options.downloadTimeoutMs, dispatcher)).trim().split(/\s+/u)[0];
543
- const actual = await sha256File(archive);
544
- if (!expected || expected.toLowerCase() !== actual.toLowerCase()) {
545
- throw new Error(`uv archive checksum mismatch for ${asset}`);
546
- }
547
- const extracted = path.join(temporary, 'extracted');
548
- await mkdir(extracted, { recursive: true });
549
- const source = await extractUvExecutable(archive, asset, extracted);
550
- if (!source)
551
- throw new Error(`uv archive did not contain the expected executable: ${asset}`);
552
- await publishUvAtomically(source, installDir, release.version, options);
553
- }
554
- finally {
555
- await dispatcher?.close();
556
- await rm(temporary, { recursive: true, force: true });
557
- }
558
- }
559
- async function extractUvExecutable(archive, asset, destination) {
560
- const executableName = process.platform === 'win32' ? 'uv.exe' : 'uv';
561
- if (!asset.endsWith('.zip')) {
562
- await tar.x({ file: archive, cwd: destination, strict: true });
563
- return findFile(destination, executableName);
564
- }
565
- // ZIP entry paths are never written to disk. Extract only uv's bytes and
566
- // publish them at a path controlled by Pyfig, which prevents Zip Slip and
567
- // symlink entries from redirecting writes outside the temporary directory.
568
- const entries = unzipSync(await readFile(archive), {
569
- filter: ({ name }) => path.posix.basename(name.replaceAll('\\', '/')) === executableName,
570
- });
571
- const matches = Object.entries(entries)
572
- .filter(([name]) => path.posix.basename(name.replaceAll('\\', '/')) === executableName);
573
- if (matches.length !== 1 || !matches[0])
574
- return null;
575
- const source = path.join(destination, executableName);
576
- await writeFile(source, matches[0][1], { flag: 'wx' });
577
- return source;
578
- }
579
- async function publishUvAtomically(source, installDir, version, options) {
580
- await mkdir(installDir, { recursive: true });
581
- const executable = uvExecutable(options, version);
582
- const staged = path.join(installDir, `.${path.basename(executable)}.${process.pid}.${randomUUID()}.tmp`);
583
- try {
584
- await copyFile(source, staged);
585
- if (process.platform !== 'win32')
586
- await chmod(staged, 0o755);
587
- const stagedUv = probeUv(staged, options);
588
- if (!stagedUv || stagedUv.version !== version) {
589
- throw new Error(`Downloaded uv executable is not version ${version}.`);
590
- }
591
- if (process.platform === 'win32')
592
- await rm(executable, { force: true });
593
- await rename(staged, executable);
594
- }
595
- finally {
596
- await rm(staged, { force: true });
597
- }
598
- }
599
- function uvAsset() {
600
- const architecture = process.arch === 'x64'
601
- ? 'x86_64'
602
- : process.arch === 'arm64'
603
- ? 'aarch64'
604
- : null;
605
- if (!architecture)
606
- throw new Error(`uv bootstrap does not support architecture ${process.arch}.`);
607
- if (process.platform === 'win32')
608
- return `uv-${architecture}-pc-windows-msvc.zip`;
609
- if (process.platform === 'darwin')
610
- return `uv-${architecture}-apple-darwin.tar.gz`;
611
- if (process.platform === 'linux')
612
- return `uv-${architecture}-unknown-linux-musl.tar.gz`;
613
- throw new Error(`uv bootstrap does not support platform ${process.platform}.`);
614
- }
615
- async function download(url, destination, timeoutMs, dispatcher) {
616
- const response = await fetch(url, {
617
- dispatcher,
618
- redirect: 'follow',
619
- signal: AbortSignal.timeout(timeoutMs),
620
- });
621
- if (!response.ok || !response.body) {
622
- await response.body?.cancel();
623
- throw new Error(`Could not download ${url}: HTTP ${response.status}`);
624
- }
625
- await pipeline(Readable.fromWeb(response.body), createWriteStream(destination, { flags: 'wx' }));
626
- }
627
- async function downloadText(url, timeoutMs, dispatcher, headers) {
628
- const response = await fetch(url, {
629
- dispatcher,
630
- headers,
631
- redirect: 'follow',
632
- signal: AbortSignal.timeout(timeoutMs),
633
- });
634
- if (!response.ok) {
635
- await response.body?.cancel();
636
- throw new Error(`Could not download ${url}: HTTP ${response.status}`);
637
- }
638
- return response.text();
639
- }
640
- function proxyDispatcher(env) {
641
- const httpProxy = processEnvironmentValue(env, 'http_proxy', 'HTTP_PROXY');
642
- const httpsProxy = processEnvironmentValue(env, 'https_proxy', 'HTTPS_PROXY');
643
- if (!httpProxy && !httpsProxy)
644
- return undefined;
645
- return new EnvHttpProxyAgent({
646
- httpProxy,
647
- httpsProxy,
648
- noProxy: processEnvironmentValue(env, 'no_proxy', 'NO_PROXY'),
649
- });
650
- }
651
- function processEnvironmentValue(env, lowercase, uppercase) {
652
- return env[lowercase] ?? env[uppercase];
653
- }
654
- async function sha256File(file) {
655
- const hash = createHash('sha256');
656
- for await (const chunk of createReadStream(file))
657
- hash.update(chunk);
658
- return hash.digest('hex');
659
- }
660
- async function findFile(directory, name) {
661
- for (const entry of await readdir(directory, { withFileTypes: true })) {
662
- const target = path.join(directory, entry.name);
663
- if (entry.isFile() && entry.name === name)
664
- return target;
665
- if (entry.isDirectory()) {
666
- const nested = await findFile(target, name);
667
- if (nested)
668
- return nested;
669
- }
670
- }
671
- return null;
672
- }
673
- //# sourceMappingURL=provision.js.map