Add desktop (Electron) packaging and release distribution (#1147)
* Add desktop packaging workflow * Add desktop package homepage * Fix desktop default credential prompt * Suppress default credential prompt on desktop * Publish desktop artifacts on release; reduce CI to PR smoke test Add desktop-release.yml triggered on release publish (mirroring docker-publish.yml) to build all platforms and upload .dmg/.exe/ .AppImage/.deb to the GitHub Release. Trim build.yml desktop job to a PR-only Linux x64 smoke test, since release artifacts are now produced by desktop-release.yml. This drops per-push and macOS/Windows packaging from regular CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix desktop Hermes data home on Windows --------- Co-authored-by: xingzhi <chuzihao.czh@alibaba-inc.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
// Apply locally-curated patches to hermes-agent inside the bundled venv.
|
||||
// Each patch is idempotent: a marker string is searched for first, and the
|
||||
// edit is skipped if the patch is already in place.
|
||||
//
|
||||
// Run after `install-hermes.mjs`. Designed to be safe to re-run.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
// Allow the CI sanity-check path to point at a temp install dir without
|
||||
// the full bundled-Python layout (e.g. `pip install --target /tmp/foo`).
|
||||
const sitePkgs = process.env.HERMES_AGENT_SITE_PACKAGES ?? (
|
||||
TARGET_OS === 'win32'
|
||||
? join(PY_DIR, 'Lib', 'site-packages')
|
||||
: (() => {
|
||||
const libDir = join(PY_DIR, 'lib')
|
||||
if (!existsSync(libDir)) throw new Error(`No lib dir at ${libDir}`)
|
||||
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
|
||||
if (!py) throw new Error(`Could not locate pythonX.Y under ${libDir}`)
|
||||
return join(libDir, py, 'site-packages')
|
||||
})()
|
||||
)
|
||||
|
||||
const dtPath = join(sitePkgs, 'gateway', 'platforms', 'dingtalk.py')
|
||||
if (!existsSync(dtPath)) {
|
||||
console.error(`dingtalk.py not found at ${dtPath} — is hermes-agent installed?`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let src = readFileSync(dtPath, 'utf-8')
|
||||
const before = src
|
||||
let applied = 0
|
||||
let skipped = 0
|
||||
|
||||
function patch(id, marker, find, replace) {
|
||||
if (src.includes(marker)) {
|
||||
console.log(` · ${id} (already applied)`)
|
||||
skipped++
|
||||
return
|
||||
}
|
||||
if (!src.includes(find)) {
|
||||
console.log(` ✗ ${id} (anchor not found — upstream changed?)`)
|
||||
return
|
||||
}
|
||||
src = src.replace(find, replace)
|
||||
console.log(` ✓ ${id}`)
|
||||
applied++
|
||||
}
|
||||
|
||||
console.log(`Patching ${dtPath}`)
|
||||
|
||||
// NOTE: the former `dt-pre-start` patch was retired — hermes-agent now ships
|
||||
// `_IncomingHandler.pre_start()` natively (present in 0.15.x and on main), so
|
||||
// re-adding it just injected a duplicate method.
|
||||
|
||||
// ── dt-card-tpl-env ─────────────────────────────────────────────
|
||||
// Fall back to DINGTALK_CARD_TEMPLATE_ID env var.
|
||||
patch(
|
||||
'dt-card-tpl-env',
|
||||
'# patch:dt-card-tpl-env',
|
||||
` self._card_template_id: Optional[str] = extra.get("card_template_id")`,
|
||||
` # patch:dt-card-tpl-env — env var fallback
|
||||
self._card_template_id: Optional[str] = (
|
||||
extra.get("card_template_id") or os.getenv("DINGTALK_CARD_TEMPLATE_ID")
|
||||
)`,
|
||||
)
|
||||
|
||||
// ── dt-card-before-webhook ──────────────────────────────────────
|
||||
// Try AI Card *before* validating session_webhook — Card SDK does not need
|
||||
// a webhook URL. Move the lookup of `current_message` and the AI Card block
|
||||
// up before the webhook gate.
|
||||
patch(
|
||||
'dt-card-before-webhook',
|
||||
'# patch:dt-card-before-webhook',
|
||||
` # Check metadata first (for direct webhook sends)
|
||||
session_webhook = metadata.get("session_webhook")
|
||||
if not session_webhook:
|
||||
webhook_info = self._get_valid_webhook(chat_id)
|
||||
if not webhook_info:
|
||||
logger.warning(
|
||||
"[%s] No valid session_webhook for chat_id=%s",
|
||||
self.name, chat_id,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="No valid session_webhook available. Reply must follow an incoming message.",
|
||||
)
|
||||
session_webhook, _ = webhook_info
|
||||
|
||||
if not self._http_client:
|
||||
return SendResult(success=False, error="HTTP client not initialized")
|
||||
|
||||
# Look up the inbound message for this chat (for AI Card routing)
|
||||
current_message = self._message_contexts.get(chat_id)`,
|
||||
` # patch:dt-card-before-webhook — try AI Card first; webhook gate moved below.
|
||||
if not self._http_client:
|
||||
return SendResult(success=False, error="HTTP client not initialized")
|
||||
|
||||
# Look up the inbound message for this chat (for AI Card routing)
|
||||
current_message = self._message_contexts.get(chat_id)
|
||||
session_webhook = metadata.get("session_webhook")`,
|
||||
)
|
||||
|
||||
// The above leaves the existing AI Card block intact; we still need to add
|
||||
// the deferred webhook gate AFTER the AI Card attempt. The original code
|
||||
// had `logger.debug("[%s] Sending via webhook", self.name)` immediately
|
||||
// after the AI Card fallback log. Insert the gate right before that.
|
||||
patch(
|
||||
'dt-card-before-webhook-gate',
|
||||
'# patch:dt-card-before-webhook-gate',
|
||||
` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)
|
||||
|
||||
logger.debug("[%s] Sending via webhook", self.name)`,
|
||||
` logger.warning("[%s] AI Card send failed, falling back to webhook", self.name)
|
||||
|
||||
# patch:dt-card-before-webhook-gate — webhook required only for fallback path
|
||||
if not session_webhook:
|
||||
webhook_info = self._get_valid_webhook(chat_id)
|
||||
if not webhook_info:
|
||||
logger.warning(
|
||||
"[%s] No valid session_webhook for chat_id=%s",
|
||||
self.name, chat_id,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error="No valid session_webhook available. Reply must follow an incoming message.",
|
||||
)
|
||||
session_webhook, _ = webhook_info
|
||||
|
||||
logger.debug("[%s] Sending via webhook", self.name)`,
|
||||
)
|
||||
|
||||
// ── dt-dm-robot-code ────────────────────────────────────────────
|
||||
patch(
|
||||
'dt-dm-robot-code',
|
||||
'# patch:dt-dm-robot-code',
|
||||
` im_robot_open_deliver_model=(
|
||||
dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(
|
||||
space_type="IM_ROBOT",
|
||||
)
|
||||
),`,
|
||||
` im_robot_open_deliver_model=(
|
||||
dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(
|
||||
space_type="IM_ROBOT",
|
||||
robot_code=self._robot_code, # patch:dt-dm-robot-code
|
||||
)
|
||||
),`,
|
||||
)
|
||||
|
||||
// ── dt-card-autolayout ──────────────────────────────────────────
|
||||
patch(
|
||||
'dt-card-autolayout',
|
||||
'# patch:dt-card-autolayout',
|
||||
` card_data=dingtalk_card_models.CreateCardRequestCardData(
|
||||
card_param_map={"content": ""},
|
||||
),`,
|
||||
` card_data=dingtalk_card_models.CreateCardRequestCardData(
|
||||
# patch:dt-card-autolayout — wide-screen via sys_full_json_obj
|
||||
card_param_map={
|
||||
"content": "",
|
||||
"sys_full_json_obj": json.dumps({"config": {"autoLayout": True}}),
|
||||
},
|
||||
),`,
|
||||
)
|
||||
|
||||
if (src !== before) {
|
||||
writeFileSync(dtPath, src)
|
||||
}
|
||||
console.log(`Done. Applied ${applied}, skipped ${skipped}.`)
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// Download python-build-standalone for the current (or target) platform/arch
|
||||
// and extract into resources/python/<os>-<arch>/
|
||||
import { mkdirSync, existsSync, createWriteStream, rmSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir, platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
// Pin a known-good python-build-standalone release. Bump intentionally.
|
||||
const PBS_TAG = process.env.PBS_TAG || '20260510'
|
||||
const PYTHON_VERSION = process.env.PBS_PY || '3.12.13'
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform() // darwin | win32 | linux
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch() // arm64 | x64
|
||||
|
||||
const TRIPLE_MAP = {
|
||||
'darwin-arm64': 'aarch64-apple-darwin',
|
||||
'darwin-x64': 'x86_64-apple-darwin',
|
||||
'win32-x64': 'x86_64-pc-windows-msvc',
|
||||
'linux-x64': 'x86_64-unknown-linux-gnu',
|
||||
'linux-arm64': 'aarch64-unknown-linux-gnu',
|
||||
}
|
||||
|
||||
const key = `${TARGET_OS}-${TARGET_ARCH}`
|
||||
const triple = TRIPLE_MAP[key]
|
||||
if (!triple) {
|
||||
console.error(`Unsupported target: ${key}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// electron-builder uses `mac`/`win`/`linux` for `${os}` — match that
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const OUT_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
const FLAVOR = 'install_only_stripped'
|
||||
const FILE = `cpython-${PYTHON_VERSION}+${PBS_TAG}-${triple}-${FLAVOR}.tar.gz`
|
||||
const PBS_BASE_URL = (process.env.PBS_BASE_URL || 'https://github.com/astral-sh/python-build-standalone/releases/download').replace(/\/$/, '')
|
||||
const URL = `${PBS_BASE_URL}/${PBS_TAG}/${FILE}`
|
||||
|
||||
if (existsSync(resolve(OUT_DIR, 'python')) || existsSync(resolve(OUT_DIR, 'bin', 'python3'))) {
|
||||
console.log(`✓ Python already present at ${OUT_DIR}, skipping`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
const tarPath = resolve(tmpdir(), FILE)
|
||||
|
||||
console.log(`→ Fetching ${URL}`)
|
||||
const curl = spawnSync('curl', ['-fL', '--retry', '3', '-o', tarPath, URL], { stdio: 'inherit' })
|
||||
if (curl.status !== 0) {
|
||||
console.error('curl failed')
|
||||
process.exit(curl.status ?? 1)
|
||||
}
|
||||
|
||||
console.log(`→ Extracting into ${OUT_DIR}`)
|
||||
// PBS tarballs unpack to a top-level "python/" directory; --strip-components=1 flattens it
|
||||
const tar = spawnSync('tar', ['-xzf', tarPath, '-C', OUT_DIR, '--strip-components=1'], { stdio: 'inherit' })
|
||||
if (tar.status !== 0) {
|
||||
console.error('tar failed')
|
||||
process.exit(tar.status ?? 1)
|
||||
}
|
||||
|
||||
rmSync(tarPath, { force: true })
|
||||
console.log(`✓ Python ready at ${OUT_DIR}`)
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
// Install hermes-agent into the bundled Python at resources/python/<os>-<arch>/.
|
||||
// Prefers `uv` (10-100x faster, more deterministic) and falls back to pip.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const HERMES_VERSION = process.env.HERMES_VERSION || '0.15.2'
|
||||
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
const pyBin = TARGET_OS === 'win32'
|
||||
? resolve(PY_DIR, 'python.exe')
|
||||
: resolve(PY_DIR, 'bin', 'python3')
|
||||
|
||||
if (!existsSync(pyBin)) {
|
||||
console.error(`Python not found at ${pyBin}. Run: npm run fetch:python`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function hasUv() {
|
||||
const r = spawnSync('uv', ['--version'], { stdio: 'ignore' })
|
||||
return r.status === 0
|
||||
}
|
||||
|
||||
let r
|
||||
if (hasUv()) {
|
||||
console.log(`→ Installing hermes-agent==${HERMES_VERSION} via uv`)
|
||||
r = spawnSync('uv', [
|
||||
'pip', 'install',
|
||||
'--python', pyBin,
|
||||
`hermes-agent==${HERMES_VERSION}`,
|
||||
], { stdio: 'inherit' })
|
||||
} else {
|
||||
console.log(`→ Installing hermes-agent==${HERMES_VERSION} via pip`)
|
||||
r = spawnSync(pyBin, [
|
||||
'-m', 'pip', 'install',
|
||||
`hermes-agent==${HERMES_VERSION}`,
|
||||
'--no-warn-script-location',
|
||||
'--disable-pip-version-check',
|
||||
], { stdio: 'inherit' })
|
||||
}
|
||||
if (r.status !== 0) process.exit(r.status ?? 1)
|
||||
|
||||
const hermesBin = TARGET_OS === 'win32'
|
||||
? resolve(PY_DIR, 'Scripts', 'hermes.exe')
|
||||
: resolve(PY_DIR, 'bin', 'hermes')
|
||||
|
||||
if (!existsSync(hermesBin)) {
|
||||
console.error(`hermes binary not found at ${hermesBin} after install`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// hermes-web-ui's agent-bridge searches for `run_agent.py` at <python_root>/run_agent.py
|
||||
// (and a few neighbouring dirs). pip places it at site-packages/run_agent.py — surface
|
||||
// it at the venv root with a *relative* symlink so the venv stays portable when copied
|
||||
// into the packaged .app/.exe (an absolute symlink would break the moment the bundle
|
||||
// is moved to /Applications/...).
|
||||
const { readdirSync, symlinkSync, copyFileSync, unlinkSync, lstatSync } = await import('node:fs')
|
||||
function siteRunAgentRelative() {
|
||||
if (TARGET_OS === 'win32') {
|
||||
return ['Lib', 'site-packages', 'run_agent.py'].join('\\')
|
||||
}
|
||||
const libDir = resolve(PY_DIR, 'lib')
|
||||
const py = readdirSync(libDir).find(n => /^python\d+\.\d+$/.test(n))
|
||||
return ['lib', py, 'site-packages', 'run_agent.py'].join('/')
|
||||
}
|
||||
{
|
||||
const relSrc = siteRunAgentRelative()
|
||||
const absSrc = resolve(PY_DIR, relSrc)
|
||||
const dst = resolve(PY_DIR, 'run_agent.py')
|
||||
if (existsSync(absSrc)) {
|
||||
try { lstatSync(dst); unlinkSync(dst) } catch {}
|
||||
if (TARGET_OS === 'win32') copyFileSync(absSrc, dst)
|
||||
else symlinkSync(relSrc, dst)
|
||||
console.log(`✓ run_agent.py linked at venv root (relative → ${relSrc})`)
|
||||
} else {
|
||||
console.warn(`! run_agent.py not found at ${absSrc} — agent-bridge may fail`)
|
||||
}
|
||||
}
|
||||
|
||||
// Relocate: replace the pip-generated launcher (which embeds an absolute
|
||||
// shebang to the build-time Python path) with a relative wrapper so the
|
||||
// bundled venv works after being moved into the .app/.exe payload.
|
||||
const { writeFileSync, chmodSync } = await import('node:fs')
|
||||
if (TARGET_OS === 'win32') {
|
||||
// Windows: pip generates a .exe launcher that embeds a relative shebang
|
||||
// already. Add a .cmd wrapper that prefers the colocated python.exe.
|
||||
const cmdPath = resolve(PY_DIR, 'Scripts', 'hermes.cmd')
|
||||
writeFileSync(
|
||||
cmdPath,
|
||||
[
|
||||
'@echo off',
|
||||
'set "PY=%~dp0..\\python.exe"',
|
||||
'"%PY%" -m hermes_cli.main %*',
|
||||
].join('\r\n'),
|
||||
)
|
||||
} else {
|
||||
const launcher = [
|
||||
'#!/bin/sh',
|
||||
'DIR="$(cd "$(dirname "$0")" && pwd)"',
|
||||
'exec "$DIR/python3" -m hermes_cli.main "$@"',
|
||||
'',
|
||||
].join('\n')
|
||||
writeFileSync(hermesBin, launcher, { mode: 0o755 })
|
||||
chmodSync(hermesBin, 0o755)
|
||||
// Same for hermes-agent / hermes-acp (they all just dispatch into modules)
|
||||
for (const [name, mod] of [
|
||||
['hermes-agent', 'run_agent'],
|
||||
['hermes-acp', 'acp_adapter.entry'],
|
||||
]) {
|
||||
const p = resolve(PY_DIR, 'bin', name)
|
||||
if (existsSync(p)) {
|
||||
writeFileSync(p, launcher.replace('hermes_cli.main', mod), { mode: 0o755 })
|
||||
chmodSync(p, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ hermes installed at ${hermesBin} (relocatable launcher)`)
|
||||
|
||||
r = spawnSync(hermesBin, ['--version'], { stdio: 'inherit' })
|
||||
if (r.status !== 0) {
|
||||
console.error('hermes --version failed')
|
||||
process.exit(r.status ?? 1)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
// Merge two per-arch `latest-mac.yml` manifests (arm64 + x64) into a single
|
||||
// manifest whose `files:` array lists BOTH dmgs, so electron-updater can pick
|
||||
// the right architecture.
|
||||
//
|
||||
// Why this exists: our Release workflow builds macOS arm64 and x64 in separate
|
||||
// matrix jobs, each emitting its own `latest-mac.yml`. When the publish job
|
||||
// flattens the artifacts they collide and only one arch survives — leaving the
|
||||
// other arch's users served a mismatched dmg (runs under Rosetta / fails the
|
||||
// updater signature check). Merging the `files` lists fixes that.
|
||||
//
|
||||
// Usage: node merge-mac-latest-yml.mjs <a.yml> <b.yml> > latest-mac.yml
|
||||
//
|
||||
// The manifest shape electron-builder emits is small and regular, so we parse
|
||||
// it with a focused extractor rather than pulling in a YAML dependency.
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
function parse(path) {
|
||||
const text = readFileSync(path, 'utf-8')
|
||||
const version = (text.match(/^version:\s*(.+)$/m) || [])[1]?.trim()
|
||||
const releaseDate = (text.match(/^releaseDate:\s*(.+)$/m) || [])[1]?.trim()
|
||||
// Each entry under `files:` is `- url: ...` then indented sha512/size lines.
|
||||
const files = []
|
||||
const re = /- url:\s*(\S+)\s*\n\s*sha512:\s*(\S+)\s*\n\s*size:\s*(\d+)/g
|
||||
let m
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
files.push({ url: m[1], sha512: m[2], size: Number(m[3]) })
|
||||
}
|
||||
if (!version || files.length === 0) {
|
||||
throw new Error(`Could not parse manifest at ${path} (version=${version}, files=${files.length})`)
|
||||
}
|
||||
return { version, releaseDate, files }
|
||||
}
|
||||
|
||||
const [, , aPath, bPath] = process.argv
|
||||
if (!aPath || !bPath) {
|
||||
console.error('Usage: merge-mac-latest-yml.mjs <a.yml> <b.yml>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const a = parse(aPath)
|
||||
const b = parse(bPath)
|
||||
|
||||
if (a.version !== b.version) {
|
||||
console.error(`Version mismatch: ${aPath}=${a.version} vs ${bPath}=${b.version}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Dedupe by url, preserving order (a first, then b).
|
||||
const seen = new Set()
|
||||
const files = []
|
||||
for (const f of [...a.files, ...b.files]) {
|
||||
if (seen.has(f.url)) continue
|
||||
seen.add(f.url)
|
||||
files.push(f)
|
||||
}
|
||||
|
||||
// Top-level path/sha512/size are the legacy single-file fields; point them at
|
||||
// the first entry (arm64 when arm64 is passed first). electron-updater >=6
|
||||
// selects from `files` by arch; these remain as a fallback for old clients.
|
||||
const head = files[0]
|
||||
const releaseDate = a.releaseDate || b.releaseDate
|
||||
|
||||
const lines = [`version: ${a.version}`, 'files:']
|
||||
for (const f of files) {
|
||||
lines.push(` - url: ${f.url}`)
|
||||
lines.push(` sha512: ${f.sha512}`)
|
||||
lines.push(` size: ${f.size}`)
|
||||
}
|
||||
lines.push(`path: ${head.url}`)
|
||||
lines.push(`sha512: ${head.sha512}`)
|
||||
if (releaseDate) lines.push(`releaseDate: ${releaseDate}`)
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
// Strip __pycache__, *.pyc, tests, idle, tkinter from bundled Python to shrink the installer.
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readdirSync, statSync, rmSync, existsSync } from 'node:fs'
|
||||
import { platform as osPlatform, arch as osArch } from 'node:os'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, '..')
|
||||
|
||||
const TARGET_OS = process.env.TARGET_OS || osPlatform()
|
||||
const TARGET_ARCH = process.env.TARGET_ARCH || osArch()
|
||||
const OS_LABEL = TARGET_OS === 'win32' ? 'win' : TARGET_OS === 'darwin' ? 'mac' : TARGET_OS
|
||||
const PY_DIR = resolve(ROOT, 'resources', 'python', `${OS_LABEL}-${TARGET_ARCH}`)
|
||||
|
||||
if (!existsSync(PY_DIR)) {
|
||||
console.error(`No bundled python at ${PY_DIR}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const PRUNE_DIR_NAMES = new Set(['__pycache__', 'test', 'tests', 'idle_test', 'idlelib', 'turtledemo', 'tkinter', 'ensurepip'])
|
||||
const PRUNE_FILE_SUFFIXES = ['.pyc', '.pyo']
|
||||
|
||||
let bytesFreed = 0
|
||||
function walk(dir) {
|
||||
let entries
|
||||
try { entries = readdirSync(dir) } catch { return }
|
||||
for (const name of entries) {
|
||||
const p = join(dir, name)
|
||||
let st
|
||||
try { st = statSync(p) } catch { continue }
|
||||
if (st.isDirectory()) {
|
||||
if (PRUNE_DIR_NAMES.has(name)) {
|
||||
bytesFreed += dirSize(p)
|
||||
rmSync(p, { recursive: true, force: true })
|
||||
} else {
|
||||
walk(p)
|
||||
}
|
||||
} else if (PRUNE_FILE_SUFFIXES.some(s => name.endsWith(s))) {
|
||||
bytesFreed += st.size
|
||||
rmSync(p, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
function dirSize(dir) {
|
||||
let total = 0
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name)
|
||||
const st = statSync(p)
|
||||
total += st.isDirectory() ? dirSize(p) : st.size
|
||||
}
|
||||
} catch {}
|
||||
return total
|
||||
}
|
||||
|
||||
walk(PY_DIR)
|
||||
console.log(`✓ Pruned ~${(bytesFreed / 1024 / 1024).toFixed(1)} MB from ${PY_DIR}`)
|
||||
Reference in New Issue
Block a user