-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathstandalone.mts
More file actions
434 lines (381 loc) · 12.6 KB
/
standalone.mts
File metadata and controls
434 lines (381 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
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
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
/**
* @fileoverview Python runtime management using python-build-standalone for Socket CLI.
*
* This module provides automatic Python runtime management for socket-python-cli integration.
* It handles:
* - System Python detection (3.10+)
* - Portable Python download from python-build-standalone
* - Python package management (socketsecurity)
* - Subprocess spawning for Python CLI operations
*
* Architecture:
* - Check for system Python 3.10+ first
* - If not found, download portable Python from astral-sh/python-build-standalone
* - Cache portable Python in ~/.socket/cache/dlx/python/
* - Install socketsecurity package for socket-python-cli
* - Forward unknown Socket CLI commands to Python CLI
*
* Python Version Support:
* Minimum: 3.10 (required by socketsecurity package)
* Portable: 3.10.18 (from python-build-standalone)
*
* Cache Structure:
* ~/.socket/cache/dlx/python/
* └── 3.10.18-20250918-darwin-arm64/
* └── python/
* ├── bin/python3
* └── lib/python3.10/site-packages/socketsecurity/
*
* Environment Variables:
* INLINED_SOCKET_CLI_PYTHON_VERSION - Python version to download
* INLINED_SOCKET_CLI_PYTHON_BUILD_TAG - Python build standalone release tag
*
* External Dependencies:
* - python-build-standalone: https://github.com/astral-sh/python-build-standalone
* - socketsecurity: https://pypi.org/project/socketsecurity/
* - socket-python-cli: https://github.com/SocketDev/socket-python-cli
*
* See also:
* - Socket pip command: src/commands/pip/cmd-pip.mts
* - DLX binary utilities: src/utils/dlx-binary.mts
*/
import { existsSync, promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import semver from 'semver'
import { whichBin } from '@socketsecurity/lib/bin'
import { WIN32 } from '@socketsecurity/lib/constants/platform'
import { downloadBinary, getDlxCachePath } from '@socketsecurity/lib/dlx-binary'
import { safeMkdir } from '@socketsecurity/lib/fs'
import { spawn } from '@socketsecurity/lib/spawn'
import ENV from '../../constants/env.mts'
import { PYTHON_MIN_VERSION } from '../../constants/packages.mts'
import { resolvePyCli } from '../dlx/resolve-binary.mjs'
import { getErrorCause, InputError } from '../error/errors.mts'
import { spawnNode } from '../spawn/spawn-node.mts'
import type { CResult } from '../../types.mjs'
/**
* SHA-256 checksums for Python 3.10.18+20250918 install_only builds.
* Retrieved from: https://github.com/astral-sh/python-build-standalone/releases/tag/20250918
*/
const PYTHON_CHECKSUMS: Record<string, string> = {
// @ts-expect-error - __proto__: null creates object without prototype.
__proto__: null,
'aarch64-apple-darwin':
'5f2a620c5278967c7fe666c9d41dc5d653c1829b3c26f62ece0d818e1a5ff9f8',
'aarch64-unknown-linux-gnu':
'16cb27d4d4d03cfb68d55d64e83a96aa3cd93c002c1de8ab7ddf3aa867c9e5f0',
'x86_64-apple-darwin':
'4ee44bf41d06626ad2745375d690679587c12d375e41a78f857a9660ce88e255',
'x86_64-pc-windows-msvc':
'7f8b19397a39188d07c68d83fd88b8da35ef1e007bdb066ed86169d60539f644',
'x86_64-unknown-linux-gnu':
'a6b9950cee5467d3a0a35473b3e848377912616668968627ba2254e5da4a1d1e',
}
/**
* Get the download URL for python-build-standalone based on platform and architecture.
*
* Constructs a GitHub release URL for downloading a portable Python distribution
* from the astral-sh/python-build-standalone project.
*
* Supported platforms:
* - macOS (darwin): x86_64, arm64
* - Linux: x86_64, arm64
* - Windows: x86_64
*
* @param version - Python version (e.g., "3.10.18")
* @param tag - python-build-standalone release tag (e.g., "20250918")
* @returns GitHub release URL for the platform-specific Python tarball
* @throws {InputError} If platform is unsupported
*
* @example
* getPythonStandaloneUrl('3.10.18', '20250918')
* // Returns: https://github.com/astral-sh/python-build-standalone/releases/download/20250918/cpython-3.10.18%2B20250918-aarch64-apple-darwin-install_only.tar.gz
*/
function getPythonStandaloneUrl(
version: string = ENV.INLINED_SOCKET_CLI_PYTHON_VERSION || '',
tag: string = ENV.INLINED_SOCKET_CLI_PYTHON_BUILD_TAG || '',
): string {
const platform = os.platform()
const arch = os.arch()
let platformTriple: string
if (platform === 'darwin') {
platformTriple =
arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'
} else if (platform === 'linux') {
platformTriple =
arch === 'arm64'
? 'aarch64-unknown-linux-gnu'
: 'x86_64-unknown-linux-gnu'
} else if (platform === 'win32') {
platformTriple = 'x86_64-pc-windows-msvc'
} else {
throw new InputError(`Unsupported platform: ${platform}`)
}
// URL encoding for the '+' in version string
const encodedVersion = `${version}%2B${tag}`
return `https://github.com/astral-sh/python-build-standalone/releases/download/${tag}/cpython-${encodedVersion}-${platformTriple}-install_only.tar.gz`
}
/**
* Get the path to the cached Python installation directory.
*/
function getPythonCachePath(): string {
const version = ENV.INLINED_SOCKET_CLI_PYTHON_VERSION
const tag = ENV.INLINED_SOCKET_CLI_PYTHON_BUILD_TAG
const platform = os.platform()
const arch = os.arch()
return path.join(
getDlxCachePath(),
'python',
`${version}-${tag}-${platform}-${arch}`,
)
}
/**
* Get the path to the Python executable within the installation.
*/
function getPythonBinPath(pythonDir: string): string {
if (WIN32) {
// Windows: python/python.exe
return path.join(pythonDir, 'python', 'python.exe')
}
// POSIX: python/bin/python3
return path.join(pythonDir, 'python', 'bin', 'python3')
}
/**
* Check if system Python meets minimum version requirement.
* Returns the path to Python executable if it meets requirements, null otherwise.
*/
export async function checkSystemPython(): Promise<string | null> {
try {
// Try python3 first, then python
let pythonPath: string | string[] | null | undefined = await whichBin(
'python3',
{ nothrow: true },
)
if (!pythonPath) {
pythonPath = await whichBin('python', { nothrow: true })
}
if (!pythonPath) {
return null
}
const pythonBin = Array.isArray(pythonPath) ? pythonPath[0]! : pythonPath
// Get version
const result = await spawn(pythonBin, ['--version'], {
shell: WIN32,
})
const stdout =
typeof result.stdout === 'string'
? result.stdout
: result.stdout.toString('utf8')
// Parse "Python 3.10.5" -> "3.10.5"
const version = semver.coerce(stdout)
if (!version) {
return null
}
// Check if it meets minimum version
if (semver.satisfies(version, `>=${PYTHON_MIN_VERSION}`)) {
return pythonBin
}
return null
} catch {
return null
}
}
/**
* Get the checksum for the current platform's Python build.
* Returns the SHA-256 checksum from PYTHON_CHECKSUMS map.
*/
function getPythonChecksum(): string {
const platform = os.platform()
const arch = os.arch()
let platformTriple: string
if (platform === 'darwin') {
platformTriple =
arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'
} else if (platform === 'linux') {
platformTriple =
arch === 'arm64'
? 'aarch64-unknown-linux-gnu'
: 'x86_64-unknown-linux-gnu'
} else if (platform === 'win32') {
platformTriple = 'x86_64-pc-windows-msvc'
} else {
throw new InputError(`Unsupported platform: ${platform}`)
}
const checksum = PYTHON_CHECKSUMS[platformTriple]
if (!checksum) {
throw new InputError(
`No checksum available for platform: ${platformTriple}`,
)
}
return checksum
}
/**
* Download and extract Python from python-build-standalone using downloadBinary.
* Uses downloadBinary for caching, checksum verification, and download management.
*/
async function downloadPython(pythonDir: string): Promise<void> {
const url = getPythonStandaloneUrl()
const tarballName = 'python-standalone.tar.gz'
const checksum = getPythonChecksum()
// Ensure directory exists.
await safeMkdir(pythonDir, { recursive: true })
try {
// Use downloadBinary to download the tarball with caching and checksum verification.
const result = await downloadBinary({
url,
name: tarballName,
checksum,
})
// Extract the tarball to pythonDir.
await spawn('tar', ['-xzf', result.binaryPath, '-C', pythonDir], {
shell: WIN32,
})
} catch (error) {
throw new InputError(
`Failed to download Python: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
/**
* Ensure Python is available, either from system or by downloading.
* Returns the path to the Python executable.
*/
export async function ensurePython(): Promise<string> {
// Always use portable Python to avoid externally-managed-environment errors
// on systems like macOS with Homebrew Python (PEP 668).
// System Python can be externally-managed and refuse pip installs even with --user flag.
const pythonDir = getPythonCachePath()
const pythonBin = getPythonBinPath(pythonDir)
if (!existsSync(pythonBin)) {
// Download and extract
await downloadPython(pythonDir)
// Verify it was extracted correctly
if (!existsSync(pythonBin)) {
throw new InputError(
`Python binary not found after extraction: ${pythonBin}`,
)
}
// Make executable on POSIX
if (!WIN32) {
await fs.chmod(pythonBin, 0o755)
}
}
return pythonBin
}
/**
* Check if socketcli is installed in the Python environment.
*/
async function isSocketPythonCliInstalled(pythonBin: string): Promise<boolean> {
try {
const result = await spawn(
pythonBin,
['-c', 'import socketsecurity.socketcli'],
{ shell: WIN32 },
)
return result.code === 0
} catch {
return false
}
}
/**
* Convert npm caret range (^2.2.15) to pip version specifier (>=2.2.15,<3.0.0).
*/
function convertCaretToPipRange(caretRange: string): string {
if (!caretRange) {
return ''
}
if (!caretRange.startsWith('^')) {
return `==${caretRange}`
}
const version = caretRange.slice(1) // Remove '^'
const parts = version.split('.')
const major = Number.parseInt(parts[0] || '0', 10)
const nextMajor = major + 1
return `>=${version},<${nextMajor}.0.0`
}
/**
* Install socketsecurity package into the Python environment.
*/
export async function ensureSocketPythonCli(pythonBin: string): Promise<void> {
// Check if already installed
if (await isSocketPythonCliInstalled(pythonBin)) {
return
}
// Get version constraint from inlined environment variable.
const pyCliVersion = ENV.INLINED_SOCKET_CLI_PYCLI_VERSION
const versionSpec = convertCaretToPipRange(pyCliVersion || '')
const packageSpec = versionSpec
? `socketsecurity${versionSpec}`
: 'socketsecurity'
// Install socketsecurity with version constraint.
await spawn(pythonBin, ['-m', 'pip', 'install', '--quiet', packageSpec], {
shell: WIN32,
stdio: 'inherit',
})
}
/**
* Run socketcli with arguments using managed or system Python.
* If SOCKET_CLI_PYCLI_LOCAL_PATH environment variable is set, uses the local
* Python CLI binary at that path instead of downloading Python and socketsecurity.
*/
export async function spawnSocketPython(
args: string[] | readonly string[],
options?: {
cwd?: string
env?: Record<string, string>
stdio?: 'inherit' | 'pipe'
},
): Promise<CResult<string>> {
try {
const resolution = resolvePyCli()
const finalEnv: Record<string, string | undefined> = {
...process.env,
...Object.fromEntries(
Object.entries(ENV).map(([key, value]) => [
key,
value === undefined ? undefined : String(value),
]),
),
...options?.env,
}
// Use local Python CLI if available.
if (resolution.type === 'local') {
const spawnResult = await spawnNode([resolution.path, ...args], {
cwd: options?.cwd,
env: finalEnv,
shell: WIN32,
stdio: options?.stdio || 'inherit',
})
return {
ok: true,
data: spawnResult.stdout?.toString() ?? '',
}
}
// Ensure Python is available
const pythonBin = await ensurePython()
// Ensure socketcli is installed
await ensureSocketPythonCli(pythonBin)
// Run socketcli via python -m
const spawnResult = await spawn(
pythonBin,
['-m', 'socketsecurity.socketcli', ...args],
{
cwd: options?.cwd,
env: finalEnv,
shell: WIN32,
stdio: options?.stdio || 'inherit',
},
)
return {
ok: true,
data: spawnResult.stdout?.toString() ?? '',
}
} catch (e) {
return {
ok: false,
data: e,
message: getErrorCause(e),
}
}
}