mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 13:47:59 +08:00
### What this PR does
Before this PR:
- On **Windows**, `getLoginShellEnvironment()` runs `cmd.exe /c set`
which just inherits the parent (Electron) process's env — it does NOT
re-read the Windows registry. If Node.js is installed via MSI after the
app launches, the captured PATH is stale. This causes npm preinstall
scripts to fail: npm itself runs (found via `commonPaths` filesystem
fallback), but `cmd.exe /d /s /c node ./engine-requirements.js` can't
find `node` in the stale PATH. On Unix this isn't an issue because `zsh
-ilc env` sources profile files and picks up nvm/mise/fnm PATH changes.
- On **Unix**, OpenClaw fails to start/install when Node.js is installed
via nvm, mise, or fnm after Cherry Studio has launched, because the
cached shell environment is stale.
- `findExecutableInEnv` has a hidden side effect of refreshing the shell
env cache, making it unpredictable.
- `startGateway` uses stale env because `findOpenClawBinary` refreshes
the cache but the gateway spawn uses a different (stale) env.
- Install process mutates the shared cached env object, polluting all
future callers.
- Inconsistent spawn strategy: install/uninstall use `spawn()` + `shell:
true` while gateway operations use `spawnWithEnv()`.
After this PR:
- **Windows**: skip the useless `cmd.exe /c set` entirely. Instead, copy
`process.env` as the base (same result, but faster), then read the
**current** system + user PATH from the Windows registry via `reg
query`, expand `%VAR%` references, and replace the stale PATH. This
ensures newly installed tools (e.g. Node.js MSI) are found immediately.
- **Unix**: unchanged — `zsh -ilc env` still sources profile files
correctly.
- Shell env cache follows CQS (Command-Query Separation):
`getShellEnv()` is a pure query, `refreshShellEnv()` is an explicit
command.
- `findExecutableInEnv` no longer refreshes the cache — callers
explicitly call `refreshShellEnv()` when they need fresh env.
- `startGateway` refreshes env first, then passes it to both
`findOpenClawBinary` and `crossPlatformSpawn`.
- Install process clones the cached env before modifying PATH (`{
...await getShellEnv() }`).
- All spawn calls unified to `crossPlatformSpawn` (handles Windows
`.cmd` files via `cmd.exe /c`).
- OpenClaw UI now checks Node.js version (≥18) and git availability
before install, with download URL hints and automatic polling for newly
installed tools.
- Unit tests added for the new Windows registry PATH resolution logic
(10 test cases).
<img width="1019" height="765" alt="image"
src="https://github.com/user-attachments/assets/5f6a4d27-6d3e-4033-8309-0c98bf8cba4c"
/>
### Why we need it and why it was done in this way
**Why registry reads instead of `cmd.exe /c set`?**
On Windows, `cmd.exe /c set` inherits the parent process env unchanged.
Unlike Unix shells that source `~/.bashrc`/`.zshrc` on launch, `cmd.exe`
does not re-read the registry. When a user installs Node.js (via MSI,
Scoop, etc.) after Cherry Studio is already running, the new PATH
entries only exist in the registry — not in the Electron process's
inherited env. Reading `HKLM\...\Environment` (system PATH) and
`HKCU\Environment` (user PATH) directly gives us the ground-truth PATH
at the time of the call.
**Why `execFileSync` instead of `crossPlatformSpawn`/`executeCommand`?**
1. **Circular dependency**: `executeCommand` internally calls
`getShellEnv()` to obtain env. Since `queryRegValue` is called *by*
`getShellEnv` → `getLoginShellEnvironment` → `getWindowsEnvironment`,
using `executeCommand` would create an infinite recursion.
2. **Synchronous is appropriate**: `reg query` completes in
milliseconds. Keeping it synchronous allows `getWindowsEnvironment()` to
return directly via `Promise.resolve()`, simplifying the control flow.
3. **No `.cmd` shim handling needed**: `reg.exe` is a native executable
— it doesn't need the `cmd.exe /c` wrapping that `crossPlatformSpawn`
provides.
4. **Security**: `execFileSync` executes the binary directly without
shell interpolation, avoiding command injection risk.
**Why expand `%VAR%` manually?**
Windows registry stores PATH as `REG_EXPAND_SZ` with embedded references
like `%SystemRoot%\system32`. The `reg query` output returns the raw
string without expansion. We expand these references against the current
`process.env` using case-insensitive lookup to match Windows behavior.
The following tradeoffs were made:
- CQS over convenience: callers must now explicitly call
`refreshShellEnv()` before `findExecutableInEnv()` when they need fresh
env. This adds a line of code at call sites but makes the caching
behavior predictable and eliminates hidden side effects.
- Clone-on-modify over freeze: we spread-clone the env object in
`install()` rather than `Object.freeze()` the cache, because freeze
would break callers that legitimately need to add env vars (e.g.,
`OPENCLAW_CONFIG_PATH`).
The following alternatives were considered:
- Making `getShellEnv()` always return a frozen copy — rejected because
it would require all callers to spread, even those that only read.
- Extracting a shared function for install/uninstall — rejected (Rule of
Three: only 2 instances, with semantic differences in error handling and
sudo retry).
- Using PowerShell `[Environment]::GetEnvironmentVariable` instead of
`reg query` — rejected because it has a much higher startup cost (~200ms
vs ~5ms) and requires detecting PowerShell availability.
Links to places where the discussion took place: N/A
### Breaking changes
None. All changes are internal to the main process. No Redux/IndexedDB
schema changes.
### Special notes for your reviewer
- **New file**: `src/main/utils/__tests__/shell-env.test.ts` — 10 test
cases covering registry PATH resolution (stale replacement, system+user
combination, `%VAR%` expansion, REG_SZ vs REG_EXPAND_SZ, fallback
behavior, cherry bin append, no cmd.exe spawn).
- **New helpers in `shell-env.ts`**: `queryRegValue()`,
`expandWindowsEnvVars()`, `readWindowsRegistryPath()`,
`getWindowsEnvironment()` — all private, tested through the public
`refreshShellEnv()` API.
- Function renames: `spawnWithEnv` → `crossPlatformSpawn`,
`executeInEnv` → `executeCommand` — names now reflect actual
responsibility (Windows `.cmd` adaptation, not "env injection").
- `checkNodeVersion` returns a discriminated union `{ status:
'not_found' } | { status: 'version_low'; version; path } | { status:
'ok'; version; path }` instead of the previous `checkNpmAvailable`
boolean.
- i18n keys renamed from `openclaw.node_required.*` →
`openclaw.node_missing.*` / `openclaw.node_version_low.*` with
translations for all supported locales.
### Checklist
- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780096809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. You want a
user-guide update if it's a user facing feature.
### Release note
```release-note
fix(shell-env): on Windows, read PATH from registry instead of inheriting stale Electron process env; fix Node.js detection for nvm/mise/fnm-managed installations; add version check (≥18) and git availability check with download hints in OpenClaw setup UI
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: dev <dev@cherry-ai.com>
Co-authored-by: kangfenmao <kangfenmao@qq.com>
533 lines
15 KiB
TypeScript
533 lines
15 KiB
TypeScript
import fs from 'fs/promises'
|
|
import path from 'path'
|
|
import semver from 'semver'
|
|
|
|
type UpgradeChannel = 'latest' | 'rc' | 'beta'
|
|
type UpdateMirror = 'github' | 'gitcode'
|
|
|
|
const CHANNELS: UpgradeChannel[] = ['latest', 'rc', 'beta']
|
|
const MIRRORS: UpdateMirror[] = ['github', 'gitcode']
|
|
const GITHUB_REPO = 'CherryHQ/cherry-studio'
|
|
const GITCODE_REPO = 'CherryHQ/cherry-studio'
|
|
const DEFAULT_FEED_TEMPLATES: Record<UpdateMirror, string> = {
|
|
github: `https://github.com/${GITHUB_REPO}/releases/download/{{tag}}`,
|
|
gitcode: `https://gitcode.com/${GITCODE_REPO}/releases/download/{{tag}}`
|
|
}
|
|
const GITCODE_LATEST_FALLBACK = 'https://releases.cherry-ai.com'
|
|
|
|
interface CliOptions {
|
|
tag?: string
|
|
configPath?: string
|
|
segmentsPath?: string
|
|
dryRun?: boolean
|
|
skipReleaseChecks?: boolean
|
|
isPrerelease?: boolean
|
|
}
|
|
|
|
interface ChannelTemplateConfig {
|
|
feedTemplates?: Partial<Record<UpdateMirror, string>>
|
|
}
|
|
|
|
interface SegmentMatchRule {
|
|
range?: string
|
|
exact?: string[]
|
|
excludeExact?: string[]
|
|
}
|
|
|
|
interface SegmentDefinition {
|
|
id: string
|
|
type: 'legacy' | 'breaking' | 'latest'
|
|
match: SegmentMatchRule
|
|
lockedVersion?: string
|
|
minCompatibleVersion: string
|
|
description: string
|
|
channelTemplates?: Partial<Record<UpgradeChannel, ChannelTemplateConfig>>
|
|
}
|
|
|
|
interface SegmentMetadataFile {
|
|
segments: SegmentDefinition[]
|
|
}
|
|
|
|
interface ChannelConfig {
|
|
version: string
|
|
feedUrls: Record<UpdateMirror, string>
|
|
}
|
|
|
|
interface VersionMetadata {
|
|
segmentId: string
|
|
segmentType?: string
|
|
}
|
|
|
|
interface VersionEntry {
|
|
metadata?: VersionMetadata
|
|
minCompatibleVersion: string
|
|
description: string
|
|
channels: Record<UpgradeChannel, ChannelConfig | null>
|
|
}
|
|
|
|
interface UpgradeConfigFile {
|
|
lastUpdated: string
|
|
versions: Record<string, VersionEntry>
|
|
}
|
|
|
|
interface ReleaseInfo {
|
|
tag: string
|
|
version: string
|
|
channel: UpgradeChannel
|
|
}
|
|
|
|
interface UpdateVersionsResult {
|
|
versions: Record<string, VersionEntry>
|
|
updated: boolean
|
|
}
|
|
|
|
const ROOT_DIR = path.resolve(__dirname, '..')
|
|
const DEFAULT_CONFIG_PATH = path.join(ROOT_DIR, 'app-upgrade-config.json')
|
|
const DEFAULT_SEGMENTS_PATH = path.join(ROOT_DIR, 'config/app-upgrade-segments.json')
|
|
|
|
async function main() {
|
|
const options = parseArgs()
|
|
const releaseTag = resolveTag(options)
|
|
const normalizedVersion = normalizeVersion(releaseTag)
|
|
const releaseChannel = detectChannel(normalizedVersion)
|
|
if (!releaseChannel) {
|
|
console.warn(`[update-app-upgrade-config] Tag ${normalizedVersion} does not map to beta/rc/latest. Skipping.`)
|
|
return
|
|
}
|
|
|
|
// Validate version format matches prerelease status
|
|
if (options.isPrerelease !== undefined) {
|
|
const hasPrereleaseSuffix = releaseChannel === 'beta' || releaseChannel === 'rc'
|
|
|
|
if (options.isPrerelease && !hasPrereleaseSuffix) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] ⚠️ Release marked as prerelease but version ${normalizedVersion} has no beta/rc suffix. Skipping.`
|
|
)
|
|
return
|
|
}
|
|
|
|
if (!options.isPrerelease && hasPrereleaseSuffix) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] ⚠️ Release marked as latest but version ${normalizedVersion} has prerelease suffix (${releaseChannel}). Skipping.`
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
const [config, segmentFile] = await Promise.all([
|
|
readJson<UpgradeConfigFile>(options.configPath ?? DEFAULT_CONFIG_PATH),
|
|
readJson<SegmentMetadataFile>(options.segmentsPath ?? DEFAULT_SEGMENTS_PATH)
|
|
])
|
|
|
|
const segment = pickSegment(segmentFile.segments, normalizedVersion)
|
|
if (!segment) {
|
|
throw new Error(`Unable to find upgrade segment for version ${normalizedVersion}`)
|
|
}
|
|
|
|
if (segment.lockedVersion && segment.lockedVersion !== normalizedVersion) {
|
|
throw new Error(`Segment ${segment.id} is locked to ${segment.lockedVersion}, but received ${normalizedVersion}`)
|
|
}
|
|
|
|
const releaseInfo: ReleaseInfo = {
|
|
tag: formatTag(releaseTag),
|
|
version: normalizedVersion,
|
|
channel: releaseChannel
|
|
}
|
|
|
|
const { versions: updatedVersions, updated } = await updateVersions(
|
|
config.versions,
|
|
segment,
|
|
releaseInfo,
|
|
Boolean(options.skipReleaseChecks)
|
|
)
|
|
|
|
if (!updated) {
|
|
throw new Error(
|
|
`[update-app-upgrade-config] Feed URLs are not ready for ${releaseInfo.version} (${releaseInfo.channel}). Try again after the release mirrors finish syncing.`
|
|
)
|
|
}
|
|
|
|
const updatedConfig: UpgradeConfigFile = {
|
|
...config,
|
|
lastUpdated: new Date().toISOString(),
|
|
versions: updatedVersions
|
|
}
|
|
|
|
const output = JSON.stringify(updatedConfig, null, 2) + '\n'
|
|
|
|
if (options.dryRun) {
|
|
console.log('Dry run enabled. Generated configuration:\n')
|
|
console.log(output)
|
|
return
|
|
}
|
|
|
|
await fs.writeFile(options.configPath ?? DEFAULT_CONFIG_PATH, output, 'utf-8')
|
|
console.log(
|
|
`✅ Updated ${path.relative(process.cwd(), options.configPath ?? DEFAULT_CONFIG_PATH)} for ${segment.id} (${releaseInfo.channel}) -> ${releaseInfo.version}`
|
|
)
|
|
}
|
|
|
|
function parseArgs(): CliOptions {
|
|
const args = process.argv.slice(2)
|
|
const options: CliOptions = {}
|
|
|
|
for (let i = 0; i < args.length; i += 1) {
|
|
const arg = args[i]
|
|
if (arg === '--tag') {
|
|
options.tag = args[i + 1]
|
|
i += 1
|
|
} else if (arg === '--config') {
|
|
options.configPath = args[i + 1]
|
|
i += 1
|
|
} else if (arg === '--segments') {
|
|
options.segmentsPath = args[i + 1]
|
|
i += 1
|
|
} else if (arg === '--dry-run') {
|
|
options.dryRun = true
|
|
} else if (arg === '--skip-release-checks') {
|
|
options.skipReleaseChecks = true
|
|
} else if (arg === '--is-prerelease') {
|
|
options.isPrerelease = args[i + 1] === 'true'
|
|
i += 1
|
|
} else if (arg === '--help') {
|
|
printHelp()
|
|
process.exit(0)
|
|
} else {
|
|
console.warn(`Ignoring unknown argument "${arg}"`)
|
|
}
|
|
}
|
|
|
|
if (options.skipReleaseChecks && !options.dryRun) {
|
|
throw new Error('--skip-release-checks can only be used together with --dry-run')
|
|
}
|
|
|
|
return options
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: tsx scripts/update-app-upgrade-config.ts [options]
|
|
|
|
Options:
|
|
--tag <tag> Release tag (e.g. v2.1.6). Falls back to GITHUB_REF_NAME/RELEASE_TAG.
|
|
--config <path> Path to app-upgrade-config.json.
|
|
--segments <path> Path to app-upgrade-segments.json.
|
|
--is-prerelease <true|false> Whether this is a prerelease (validates version format).
|
|
--dry-run Print the result without writing to disk.
|
|
--skip-release-checks Skip release page availability checks (only valid with --dry-run).
|
|
--help Show this help message.`)
|
|
}
|
|
|
|
function resolveTag(options: CliOptions): string {
|
|
const envTag = process.env.RELEASE_TAG ?? process.env.GITHUB_REF_NAME ?? process.env.TAG_NAME
|
|
const tag = options.tag ?? envTag
|
|
|
|
if (!tag) {
|
|
throw new Error('A release tag is required. Pass --tag or set RELEASE_TAG/GITHUB_REF_NAME.')
|
|
}
|
|
|
|
return tag
|
|
}
|
|
|
|
function normalizeVersion(tag: string): string {
|
|
const cleaned = semver.clean(tag, { loose: true })
|
|
if (!cleaned) {
|
|
throw new Error(`Tag "${tag}" is not a valid semantic version`)
|
|
}
|
|
|
|
const valid = semver.valid(cleaned, { loose: true })
|
|
if (!valid) {
|
|
throw new Error(`Unable to normalize tag "${tag}" to a valid semantic version`)
|
|
}
|
|
|
|
return valid
|
|
}
|
|
|
|
function detectChannel(version: string): UpgradeChannel | null {
|
|
const parsed = semver.parse(version, { loose: true })
|
|
if (!parsed) {
|
|
return null
|
|
}
|
|
|
|
if (parsed.prerelease.length === 0) {
|
|
return 'latest'
|
|
}
|
|
|
|
const label = String(parsed.prerelease[0]).toLowerCase()
|
|
if (label === 'beta') {
|
|
return 'beta'
|
|
}
|
|
if (label === 'rc') {
|
|
return 'rc'
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function readJson<T>(filePath: string): Promise<T> {
|
|
const absolute = path.isAbsolute(filePath) ? filePath : path.resolve(filePath)
|
|
const data = await fs.readFile(absolute, 'utf-8')
|
|
return JSON.parse(data) as T
|
|
}
|
|
|
|
function pickSegment(segments: SegmentDefinition[], version: string): SegmentDefinition | null {
|
|
for (const segment of segments) {
|
|
if (matchesSegment(segment.match, version)) {
|
|
return segment
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function matchesSegment(matchRule: SegmentMatchRule, version: string): boolean {
|
|
if (matchRule.exact && matchRule.exact.includes(version)) {
|
|
return true
|
|
}
|
|
|
|
if (matchRule.excludeExact && matchRule.excludeExact.includes(version)) {
|
|
return false
|
|
}
|
|
|
|
if (matchRule.range && !semver.satisfies(version, matchRule.range, { includePrerelease: true })) {
|
|
return false
|
|
}
|
|
|
|
if (matchRule.exact) {
|
|
return matchRule.exact.includes(version)
|
|
}
|
|
|
|
return Boolean(matchRule.range)
|
|
}
|
|
|
|
function formatTag(tag: string): string {
|
|
if (tag.startsWith('refs/tags/')) {
|
|
return tag.replace('refs/tags/', '')
|
|
}
|
|
return tag
|
|
}
|
|
|
|
async function updateVersions(
|
|
versions: Record<string, VersionEntry>,
|
|
segment: SegmentDefinition,
|
|
releaseInfo: ReleaseInfo,
|
|
skipReleaseValidation: boolean
|
|
): Promise<UpdateVersionsResult> {
|
|
const versionsCopy: Record<string, VersionEntry> = { ...versions }
|
|
const existingKey = findVersionKeyBySegment(versionsCopy, segment.id)
|
|
const targetKey = resolveVersionKey(existingKey, segment, releaseInfo)
|
|
const shouldRename = existingKey && existingKey !== targetKey
|
|
|
|
let entry: VersionEntry
|
|
if (existingKey) {
|
|
entry = { ...versionsCopy[existingKey], channels: { ...versionsCopy[existingKey].channels } }
|
|
} else {
|
|
entry = createEmptyVersionEntry()
|
|
}
|
|
|
|
entry.channels = ensureChannelSlots(entry.channels)
|
|
|
|
const channelUpdated = await applyChannelUpdate(entry, segment, releaseInfo, skipReleaseValidation)
|
|
if (!channelUpdated) {
|
|
return { versions, updated: false }
|
|
}
|
|
|
|
if (shouldRename && existingKey) {
|
|
delete versionsCopy[existingKey]
|
|
}
|
|
|
|
entry.metadata = {
|
|
segmentId: segment.id,
|
|
segmentType: segment.type
|
|
}
|
|
entry.minCompatibleVersion = segment.minCompatibleVersion
|
|
entry.description = segment.description
|
|
|
|
versionsCopy[targetKey] = entry
|
|
return {
|
|
versions: sortVersionMap(versionsCopy),
|
|
updated: true
|
|
}
|
|
}
|
|
|
|
function findVersionKeyBySegment(versions: Record<string, VersionEntry>, segmentId: string): string | null {
|
|
for (const [key, value] of Object.entries(versions)) {
|
|
if (value.metadata?.segmentId === segmentId) {
|
|
return key
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function resolveVersionKey(existingKey: string | null, segment: SegmentDefinition, releaseInfo: ReleaseInfo): string {
|
|
if (segment.lockedVersion) {
|
|
return segment.lockedVersion
|
|
}
|
|
|
|
if (releaseInfo.channel === 'latest') {
|
|
return releaseInfo.version
|
|
}
|
|
|
|
if (existingKey) {
|
|
return existingKey
|
|
}
|
|
|
|
const baseVersion = getBaseVersion(releaseInfo.version)
|
|
return baseVersion ?? releaseInfo.version
|
|
}
|
|
|
|
function getBaseVersion(version: string): string | null {
|
|
const parsed = semver.parse(version, { loose: true })
|
|
if (!parsed) {
|
|
return null
|
|
}
|
|
return `${parsed.major}.${parsed.minor}.${parsed.patch}`
|
|
}
|
|
|
|
function createEmptyVersionEntry(): VersionEntry {
|
|
return {
|
|
minCompatibleVersion: '',
|
|
description: '',
|
|
channels: {
|
|
latest: null,
|
|
rc: null,
|
|
beta: null
|
|
}
|
|
}
|
|
}
|
|
|
|
function ensureChannelSlots(
|
|
channels: Record<UpgradeChannel, ChannelConfig | null>
|
|
): Record<UpgradeChannel, ChannelConfig | null> {
|
|
return CHANNELS.reduce(
|
|
(acc, channel) => {
|
|
acc[channel] = channels[channel] ?? null
|
|
return acc
|
|
},
|
|
{} as Record<UpgradeChannel, ChannelConfig | null>
|
|
)
|
|
}
|
|
|
|
async function applyChannelUpdate(
|
|
entry: VersionEntry,
|
|
segment: SegmentDefinition,
|
|
releaseInfo: ReleaseInfo,
|
|
skipReleaseValidation: boolean
|
|
): Promise<boolean> {
|
|
if (!CHANNELS.includes(releaseInfo.channel)) {
|
|
throw new Error(`Unsupported channel "${releaseInfo.channel}"`)
|
|
}
|
|
|
|
const feedUrls = buildFeedUrls(segment, releaseInfo)
|
|
|
|
if (skipReleaseValidation) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] Skipping release availability validation for ${releaseInfo.version} (${releaseInfo.channel}).`
|
|
)
|
|
} else {
|
|
const availability = await ensureReleaseAvailability(releaseInfo)
|
|
if (!availability.github) {
|
|
return false
|
|
}
|
|
if (releaseInfo.channel === 'latest' && !availability.gitcode) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] gitcode release page not ready for ${releaseInfo.tag}. Falling back to ${GITCODE_LATEST_FALLBACK}.`
|
|
)
|
|
feedUrls.gitcode = GITCODE_LATEST_FALLBACK
|
|
}
|
|
}
|
|
|
|
entry.channels[releaseInfo.channel] = {
|
|
version: releaseInfo.version,
|
|
feedUrls
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
function buildFeedUrls(segment: SegmentDefinition, releaseInfo: ReleaseInfo): Record<UpdateMirror, string> {
|
|
return MIRRORS.reduce(
|
|
(acc, mirror) => {
|
|
const template = resolveFeedTemplate(segment, releaseInfo, mirror)
|
|
acc[mirror] = applyTemplate(template, releaseInfo)
|
|
return acc
|
|
},
|
|
{} as Record<UpdateMirror, string>
|
|
)
|
|
}
|
|
|
|
function resolveFeedTemplate(segment: SegmentDefinition, releaseInfo: ReleaseInfo, mirror: UpdateMirror): string {
|
|
if (mirror === 'gitcode' && releaseInfo.channel !== 'latest') {
|
|
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.github ?? DEFAULT_FEED_TEMPLATES.github
|
|
}
|
|
|
|
return segment.channelTemplates?.[releaseInfo.channel]?.feedTemplates?.[mirror] ?? DEFAULT_FEED_TEMPLATES[mirror]
|
|
}
|
|
|
|
function applyTemplate(template: string, releaseInfo: ReleaseInfo): string {
|
|
return template.replace(/{{\s*tag\s*}}/gi, releaseInfo.tag).replace(/{{\s*version\s*}}/gi, releaseInfo.version)
|
|
}
|
|
|
|
function sortVersionMap(versions: Record<string, VersionEntry>): Record<string, VersionEntry> {
|
|
const sorted = Object.entries(versions).sort(([a], [b]) => semver.rcompare(a, b))
|
|
return sorted.reduce(
|
|
(acc, [version, entry]) => {
|
|
acc[version] = entry
|
|
return acc
|
|
},
|
|
{} as Record<string, VersionEntry>
|
|
)
|
|
}
|
|
|
|
interface ReleaseAvailability {
|
|
github: boolean
|
|
gitcode: boolean
|
|
}
|
|
|
|
async function ensureReleaseAvailability(releaseInfo: ReleaseInfo): Promise<ReleaseAvailability> {
|
|
const mirrorsToCheck: UpdateMirror[] = releaseInfo.channel === 'latest' ? MIRRORS : ['github']
|
|
const availability: ReleaseAvailability = {
|
|
github: false,
|
|
gitcode: releaseInfo.channel === 'latest' ? false : true
|
|
}
|
|
|
|
for (const mirror of mirrorsToCheck) {
|
|
const url = getReleasePageUrl(mirror, releaseInfo.tag)
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: mirror === 'github' ? 'HEAD' : 'GET',
|
|
redirect: 'follow'
|
|
})
|
|
|
|
if (response.ok) {
|
|
availability[mirror] = true
|
|
} else {
|
|
console.warn(
|
|
`[update-app-upgrade-config] ${mirror} release not available for ${releaseInfo.tag} (status ${response.status}, ${url}).`
|
|
)
|
|
availability[mirror] = false
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[update-app-upgrade-config] Failed to verify ${mirror} release page for ${releaseInfo.tag} (${url}). Continuing.`,
|
|
error
|
|
)
|
|
availability[mirror] = false
|
|
}
|
|
}
|
|
|
|
return availability
|
|
}
|
|
|
|
function getReleasePageUrl(mirror: UpdateMirror, tag: string): string {
|
|
if (mirror === 'github') {
|
|
return `https://github.com/${GITHUB_REPO}/releases/tag/${encodeURIComponent(tag)}`
|
|
}
|
|
// Use latest.yml download URL for GitCode to check if release exists
|
|
// Note: GitCode returns 401 for HEAD requests, so we use GET in ensureReleaseAvailability
|
|
return `https://gitcode.com/${GITCODE_REPO}/releases/download/${encodeURIComponent(tag)}/latest.yml`
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('❌ Failed to update app-upgrade-config:', error)
|
|
process.exit(1)
|
|
})
|