@ducking-mind/proxy-app-cli 0.1.1 → 0.2.0
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.
- package/README.md +53 -15
- package/agent-contract.mjs +15 -11
- package/agent-runtime/package-guide.mjs +4 -53
- package/agent-runtime/protocol.mjs +4 -124
- package/backend-container.mjs +199 -0
- package/local-dev-credentials.mjs +5 -5
- package/local-dev-relay.mjs +129 -522
- package/package.json +7 -5
- package/platform-settings.mjs +20 -0
- package/prepare-agent-runtime.mjs +7 -40
- package/proxy-app.mjs +383 -304
- package/runtime-sources.mjs +83 -0
- package/agent-runtime/runtime-status.mjs +0 -70
- package/release-runtime/release-config.mjs +0 -154
- package/release-runtime/release-target.mjs +0 -48
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { lstat, readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { extname, join, resolve } from 'node:path'
|
|
3
|
+
import { assertSafeArtifactFile } from './artifact-security.mjs'
|
|
4
|
+
|
|
5
|
+
const MAX_FUNCTION_SOURCE_BYTES = 4 * 1024 * 1024
|
|
6
|
+
const MAX_FUNCTION_SOURCE_FILES = 2000
|
|
7
|
+
|
|
8
|
+
function functionEntries(manifest) {
|
|
9
|
+
const functions = manifest?.functions
|
|
10
|
+
if (!functions || typeof functions !== 'object' || Array.isArray(functions)) return []
|
|
11
|
+
return ['request', 'task']
|
|
12
|
+
.map(kind => functions[kind]?.entry)
|
|
13
|
+
.filter(entry => typeof entry === 'string' && entry.length > 0)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Read the same runtime module files that proxy-app pack uploads. Development
|
|
17
|
+
// sends these sources to Admin so Gateway can execute the versioned draft; the
|
|
18
|
+
// browser continues to call Gateway and never receives function source.
|
|
19
|
+
export async function readLocalFunctionSources(appDir, manifest) {
|
|
20
|
+
const requiredEntries = functionEntries(manifest)
|
|
21
|
+
if (requiredEntries.length === 0) return []
|
|
22
|
+
|
|
23
|
+
const root = resolve(appDir)
|
|
24
|
+
const runtimeRoot = join(root, 'runtime')
|
|
25
|
+
const files = []
|
|
26
|
+
let totalBytes = 0
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const runtimeMetadata = await lstat(runtimeRoot)
|
|
30
|
+
if (runtimeMetadata.isSymbolicLink() || !runtimeMetadata.isDirectory()) {
|
|
31
|
+
throw new Error('runtime function sources must be inside a real runtime/ directory')
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error?.code !== 'ENOENT') throw error
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const visit = async (directory, prefix) => {
|
|
38
|
+
let entries
|
|
39
|
+
try {
|
|
40
|
+
entries = await readdir(directory, { withFileTypes: true })
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error?.code === 'ENOENT' && directory === runtimeRoot) return
|
|
43
|
+
throw error
|
|
44
|
+
}
|
|
45
|
+
entries.sort((left, right) => left.name.localeCompare(right.name))
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (entry.name.includes('\\') || entry.name === '.' || entry.name === '..') {
|
|
48
|
+
throw new Error(`invalid runtime source path: ${entry.name}`)
|
|
49
|
+
}
|
|
50
|
+
const relativePath = `${prefix}/${entry.name}`
|
|
51
|
+
const absolutePath = join(directory, entry.name)
|
|
52
|
+
if (entry.isSymbolicLink()) throw new Error(`runtime function sources cannot be symbolic links: ${relativePath}`)
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
await visit(absolutePath, relativePath)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (!entry.isFile() || !['.js', '.mjs'].includes(extname(entry.name).toLowerCase())) continue
|
|
58
|
+
if (Buffer.byteLength(relativePath, 'utf8') > 255) throw new Error(`runtime function path is too long: ${relativePath}`)
|
|
59
|
+
if (files.length >= MAX_FUNCTION_SOURCE_FILES) throw new Error('runtime function source contains too many files')
|
|
60
|
+
const metadata = await lstat(absolutePath)
|
|
61
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`runtime function sources cannot be symbolic links: ${relativePath}`)
|
|
62
|
+
if (metadata.size === 0) throw new Error(`runtime function source is empty: ${relativePath}`)
|
|
63
|
+
totalBytes += metadata.size
|
|
64
|
+
if (totalBytes > MAX_FUNCTION_SOURCE_BYTES) throw new Error('runtime function source exceeds 4 MiB')
|
|
65
|
+
const sourceBytes = await readFile(absolutePath)
|
|
66
|
+
let source
|
|
67
|
+
try {
|
|
68
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(sourceBytes)
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(`runtime function source is not valid UTF-8: ${relativePath}`)
|
|
71
|
+
}
|
|
72
|
+
assertSafeArtifactFile(relativePath, source)
|
|
73
|
+
files.push({ path: relativePath, source })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await visit(runtimeRoot, 'runtime')
|
|
78
|
+
const available = new Set(files.map(file => file.path))
|
|
79
|
+
for (const entry of requiredEntries) {
|
|
80
|
+
if (!available.has(entry)) throw new Error(`manifest function entry is missing from runtime/: ${entry}`)
|
|
81
|
+
}
|
|
82
|
+
return files
|
|
83
|
+
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/** Descriptive renderer state only; neither status nor recovery hints grant access. */
|
|
2
|
-
const states = new Set(['ready', 'auth_required', 'loading', 'no_document', 'busy', 'error'])
|
|
3
|
-
const actions = new Set(['wait', 'user_login', 'open_document', 'retry', 'inspect_state'])
|
|
4
|
-
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
5
|
-
const check = (condition, message) => { if (!condition) throw new TypeError(message) }
|
|
6
|
-
const keys = (value, allowed, field) => check(Object.keys(value).every(key => allowed.includes(key)), `Unexpected ${field} field`)
|
|
7
|
-
|
|
8
|
-
export function normalizeNextAction(value) {
|
|
9
|
-
check(record(value), 'nextAction must be an object')
|
|
10
|
-
keys(value, ['type', 'capability'], 'nextAction')
|
|
11
|
-
check(actions.has(value.type), 'Invalid nextAction type')
|
|
12
|
-
if (value.capability !== undefined) check(typeof value.capability === 'string' && /^[a-z][a-z0-9_-]{0,127}$/.test(value.capability), 'Invalid nextAction capability')
|
|
13
|
-
return { type: value.type, ...(value.capability === undefined ? {} : { capability: value.capability }) }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function normalizeRuntimeStatus(value) {
|
|
17
|
-
check(record(value), 'Runtime status must be an object')
|
|
18
|
-
keys(value, ['state', 'reason', 'retryable', 'nextAction', 'context'], 'runtime status')
|
|
19
|
-
check(states.has(value.state), 'Invalid runtime state')
|
|
20
|
-
const result = { state: value.state }
|
|
21
|
-
if (value.reason !== undefined) {
|
|
22
|
-
check(typeof value.reason === 'string' && value.reason.length <= 512, 'Invalid runtime reason')
|
|
23
|
-
result.reason = value.reason
|
|
24
|
-
}
|
|
25
|
-
if (value.retryable !== undefined) {
|
|
26
|
-
check(typeof value.retryable === 'boolean', 'Invalid runtime retryable value')
|
|
27
|
-
result.retryable = value.retryable
|
|
28
|
-
}
|
|
29
|
-
if (value.nextAction !== undefined) result.nextAction = normalizeNextAction(value.nextAction)
|
|
30
|
-
if (value.context !== undefined) {
|
|
31
|
-
check(record(value.context), 'Runtime context must be an object')
|
|
32
|
-
keys(value.context, ['activeDocumentId'], 'runtime context')
|
|
33
|
-
if (value.context.activeDocumentId !== undefined) check(typeof value.context.activeDocumentId === 'string' && value.context.activeDocumentId.length <= 128, 'Invalid activeDocumentId')
|
|
34
|
-
result.context = value.context.activeDocumentId === undefined ? {} : { activeDocumentId: value.context.activeDocumentId }
|
|
35
|
-
}
|
|
36
|
-
return result
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Keep recovery metadata across process boundaries without forwarding arbitrary fields. */
|
|
40
|
-
export function serializeAgentError(error) {
|
|
41
|
-
const result = {
|
|
42
|
-
code: typeof error?.code === 'string' ? error.code.slice(0, 128) : 'AGENT_HANDLER_ERROR',
|
|
43
|
-
message: typeof error?.message === 'string' ? error.message.slice(0, 2048) : 'Application handler failed',
|
|
44
|
-
}
|
|
45
|
-
// Preserve public platform diagnostics through the renderer/child/MCP
|
|
46
|
-
// boundaries, without forwarding credentials or arbitrary error fields.
|
|
47
|
-
if (Number.isInteger(error?.status) && error.status >= 100 && error.status <= 599) result.status = error.status
|
|
48
|
-
if (typeof error?.requestId === 'string') result.requestId = error.requestId.slice(0, 256)
|
|
49
|
-
if (typeof error?.authReason === 'string') result.authReason = error.authReason.slice(0, 128)
|
|
50
|
-
if (typeof error?.reason === 'string') result.reason = error.reason.slice(0, 512)
|
|
51
|
-
if (typeof error?.retryable === 'boolean') result.retryable = error.retryable
|
|
52
|
-
if (typeof error?.executionStarted === 'boolean') result.executionStarted = error.executionStarted
|
|
53
|
-
if (error?.nextAction !== undefined) {
|
|
54
|
-
try { result.nextAction = normalizeNextAction(error.nextAction) } catch { /* Invalid hints cannot obscure the actual error. */ }
|
|
55
|
-
}
|
|
56
|
-
const recovery = {
|
|
57
|
-
AGENT_OUTCOME_UNKNOWN: { reason: 'execution_outcome_unknown', retryable: false, nextAction: { type: 'inspect_state' } },
|
|
58
|
-
AGENT_WRITE_BUSY: { reason: 'write_pending_or_unknown', retryable: false, nextAction: { type: 'inspect_state' } },
|
|
59
|
-
AGENT_IDEMPOTENCY_CONFLICT: { reason: 'idempotency_conflict', retryable: false, nextAction: { type: 'inspect_state' } },
|
|
60
|
-
AGENT_APP_UNAVAILABLE: { reason: 'app_unavailable', retryable: true, nextAction: { type: 'wait' } },
|
|
61
|
-
AGENT_APP_NOT_READY: { reason: 'app_not_ready', retryable: true, nextAction: { type: 'wait' } },
|
|
62
|
-
}[result.code]
|
|
63
|
-
if (recovery) for (const [key, value] of Object.entries(recovery)) if (result[key] === undefined) result[key] = value
|
|
64
|
-
// An unknown execution outcome must never acquire an automatic retry hint.
|
|
65
|
-
if (result.code === 'AGENT_OUTCOME_UNKNOWN') {
|
|
66
|
-
result.retryable = false
|
|
67
|
-
result.nextAction = { type: 'inspect_state' }
|
|
68
|
-
}
|
|
69
|
-
return result
|
|
70
|
-
}
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
import { access, readFile } from 'node:fs/promises'
|
|
2
|
-
import { dirname, join, resolve } from 'node:path'
|
|
3
|
-
import { fileURLToPath } from 'node:url'
|
|
4
|
-
|
|
5
|
-
// This file deliberately contains no credentials. A developer may copy
|
|
6
|
-
// release.config.example.json to release.config.json (which is gitignored)
|
|
7
|
-
// and put the publish key in that local file.
|
|
8
|
-
export const DEFAULT_RELEASE_CONFIG = Object.freeze({
|
|
9
|
-
product: 'duckingmind',
|
|
10
|
-
updateURL: '',
|
|
11
|
-
updateChannel: 'latest',
|
|
12
|
-
apiURL: '',
|
|
13
|
-
apiKey: '',
|
|
14
|
-
releaseNotes: '',
|
|
15
|
-
artifactBaseURL: '',
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
export function parseConfigOption(args) {
|
|
19
|
-
const rest = []
|
|
20
|
-
let configPath
|
|
21
|
-
let envOverrides = false
|
|
22
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
23
|
-
const value = args[index]
|
|
24
|
-
if (value === '--env-overrides') { envOverrides = true; continue }
|
|
25
|
-
if (value === '--config') {
|
|
26
|
-
const next = args[++index]
|
|
27
|
-
if (!next || next.startsWith('--')) throw new Error('Missing value for --config')
|
|
28
|
-
configPath = next
|
|
29
|
-
continue
|
|
30
|
-
}
|
|
31
|
-
if (value.startsWith('--config=')) {
|
|
32
|
-
configPath = value.slice('--config='.length)
|
|
33
|
-
if (!configPath) throw new Error('Missing value for --config')
|
|
34
|
-
continue
|
|
35
|
-
}
|
|
36
|
-
rest.push(value)
|
|
37
|
-
}
|
|
38
|
-
return { args: rest, configPath, envOverrides }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function stringValue(value, fallback = '') {
|
|
42
|
-
return value == null ? fallback : String(value).trim()
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function flattenConfig(value) {
|
|
46
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('发布配置必须是 JSON 对象')
|
|
47
|
-
const update = value.update && typeof value.update === 'object' ? value.update : {}
|
|
48
|
-
const release = value.release && typeof value.release === 'object' ? value.release : {}
|
|
49
|
-
const build = value.build && typeof value.build === 'object' ? value.build : {}
|
|
50
|
-
return {
|
|
51
|
-
product: stringValue(value.product ?? release.product, 'duckingmind'),
|
|
52
|
-
updateURL: stringValue(value.updateURL ?? value.updateUrl ?? value.update_url ?? update.url ?? update.updateURL ?? update.update_url),
|
|
53
|
-
updateChannel: stringValue(value.updateChannel ?? value.channel ?? update.channel, 'latest'),
|
|
54
|
-
apiURL: stringValue(value.apiURL ?? value.apiUrl ?? value.api_url ?? release.apiURL ?? release.apiUrl ?? release.api_url),
|
|
55
|
-
apiKey: stringValue(value.apiKey ?? value.api_key ?? release.apiKey ?? release.api_key),
|
|
56
|
-
releaseNotes: stringValue(value.releaseNotes ?? value.release_notes ?? value.notes ?? release.releaseNotes ?? release.release_notes ?? release.notes),
|
|
57
|
-
artifactBaseURL: stringValue(value.artifactBaseURL ?? value.artifactBaseUrl ?? value.artifact_base_url ?? release.artifactBaseURL ?? release.artifactBaseUrl ?? release.artifact_base_url),
|
|
58
|
-
buildTarget: { platform: stringValue(build.platform), arch: stringValue(build.arch) },
|
|
59
|
-
releaseDirs: release.directories ?? ['release'],
|
|
60
|
-
recordPaths: release.records ?? [],
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function envOverrides(env = process.env) {
|
|
65
|
-
const keys = {
|
|
66
|
-
product: 'PROXY_DESKTOP_PRODUCT',
|
|
67
|
-
updateURL: 'PROXY_DESKTOP_UPDATE_URL',
|
|
68
|
-
updateChannel: 'PROXY_DESKTOP_UPDATE_CHANNEL',
|
|
69
|
-
apiURL: 'PROXY_DESKTOP_RELEASE_API_URL',
|
|
70
|
-
apiKey: 'PROXY_DESKTOP_RELEASE_API_KEY',
|
|
71
|
-
releaseNotes: 'PROXY_DESKTOP_RELEASE_NOTES',
|
|
72
|
-
artifactBaseURL: 'PROXY_DESKTOP_ARTIFACT_BASE_URL',
|
|
73
|
-
}
|
|
74
|
-
return Object.fromEntries(Object.entries(keys).filter(([, key]) => env[key] != null && String(env[key]).trim() !== '').map(([name, key]) => [name, String(env[key]).trim()]))
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function firstExisting(paths) {
|
|
78
|
-
for (const path of paths) {
|
|
79
|
-
try {
|
|
80
|
-
await access(path)
|
|
81
|
-
return path
|
|
82
|
-
} catch {}
|
|
83
|
-
}
|
|
84
|
-
return undefined
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function defaultConfigPaths({ root, cwd = process.cwd() }) {
|
|
88
|
-
const candidates = [join(root, 'release.config.json'), join(root, 'release.config.local.json')]
|
|
89
|
-
if (cwd && resolve(cwd) !== resolve(root)) {
|
|
90
|
-
candidates.push(join(cwd, 'desktop', 'release.config.json'), join(cwd, 'desktop', 'release.config.local.json'))
|
|
91
|
-
}
|
|
92
|
-
return [...new Set(candidates)]
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function isAllowedReleaseURL(value) {
|
|
96
|
-
let parsed
|
|
97
|
-
try { parsed = new URL(String(value || '').trim()) } catch { return false }
|
|
98
|
-
const loopbackHTTP = parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
|
|
99
|
-
return (parsed.protocol === 'https:' || loopbackHTTP) && !parsed.username && !parsed.password && !parsed.search && !parsed.hash
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export async function loadReleaseConfig({ root = releaseConfigRoot(), cwd = process.env.INIT_CWD || process.cwd(), configPath, env = process.env, envOverrides: allowEnv = false } = {}) {
|
|
103
|
-
const explicit = configPath || (allowEnv ? env.PROXY_DESKTOP_RELEASE_CONFIG : '')
|
|
104
|
-
const selected = explicit
|
|
105
|
-
? resolve(cwd, explicit)
|
|
106
|
-
: await firstExisting(defaultConfigPaths({ root, cwd }))
|
|
107
|
-
let fileConfig = {}
|
|
108
|
-
if (selected) {
|
|
109
|
-
try {
|
|
110
|
-
fileConfig = JSON.parse(await readFile(selected, 'utf8'))
|
|
111
|
-
} catch (error) {
|
|
112
|
-
// JSON parser errors may contain excerpts from the key. Never echo them.
|
|
113
|
-
throw new Error(`无法读取发布配置 ${selected}: ${error.code || 'invalid JSON'}`)
|
|
114
|
-
}
|
|
115
|
-
} else if (explicit) {
|
|
116
|
-
throw new Error(`发布配置不存在: ${selected}`)
|
|
117
|
-
}
|
|
118
|
-
const values = { ...DEFAULT_RELEASE_CONFIG, ...flattenConfig(fileConfig), ...(allowEnv ? envOverrides(env) : {}) }
|
|
119
|
-
const configDirectory = selected ? dirname(selected) : root
|
|
120
|
-
for (const field of ['releaseDirs', 'recordPaths']) {
|
|
121
|
-
if (!Array.isArray(values[field]) || values[field].some(path => typeof path !== 'string' || !path.trim())) throw new Error(`发布配置 release.${field === 'releaseDirs' ? 'directories' : 'records'} 必须是路径数组`)
|
|
122
|
-
values[field] = values[field].map(path => resolve(configDirectory, path))
|
|
123
|
-
}
|
|
124
|
-
if (allowEnv) {
|
|
125
|
-
if (env.PROXY_DESKTOP_PLATFORM) values.buildTarget.platform = env.PROXY_DESKTOP_PLATFORM
|
|
126
|
-
if (env.PROXY_DESKTOP_ARCH) values.buildTarget.arch = env.PROXY_DESKTOP_ARCH
|
|
127
|
-
}
|
|
128
|
-
return { ...values, path: selected }
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function validateReleaseConfig(config, { requireUpdateURL = false, requirePublish = false } = {}) {
|
|
132
|
-
const updateURL = stringValue(config?.updateURL)
|
|
133
|
-
const channel = stringValue(config?.updateChannel || 'latest')
|
|
134
|
-
if (requireUpdateURL && !updateURL) throw new Error('发布配置缺少 update.url')
|
|
135
|
-
if (updateURL) {
|
|
136
|
-
let parsed
|
|
137
|
-
const resolved = updateURL.replaceAll('{platform}', 'darwin').replaceAll('{arch}', 'arm64')
|
|
138
|
-
try { parsed = new URL(resolved) } catch { throw new Error('发布配置 update.url 必须是 HTTPS 或本机回环 HTTP 地址') }
|
|
139
|
-
if (!isAllowedReleaseURL(resolved)) throw new Error('发布配置 update.url 必须是无凭据、无查询参数的 HTTPS 或本机回环 HTTP 地址')
|
|
140
|
-
}
|
|
141
|
-
if (!['latest', 'beta'].includes(channel)) throw new Error('发布配置 update.channel 必须是 latest 或 beta')
|
|
142
|
-
if (requirePublish) {
|
|
143
|
-
let parsed
|
|
144
|
-
const apiURL = stringValue(config?.apiURL)
|
|
145
|
-
try { parsed = new URL(apiURL) } catch { throw new Error('发布配置 release.apiUrl 必须是 HTTPS 或本机回环 HTTP 地址') }
|
|
146
|
-
if (!isAllowedReleaseURL(apiURL)) throw new Error('发布配置 release.apiUrl 必须是无凭据、无查询参数的 HTTPS 或本机回环 HTTP 地址')
|
|
147
|
-
if (!stringValue(config?.apiKey)) throw new Error('发布配置 release.apiKey 不能为空')
|
|
148
|
-
}
|
|
149
|
-
return { ...config, updateURL, updateChannel: channel }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function releaseConfigRoot() {
|
|
153
|
-
return join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
154
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { isAllowedReleaseURL } from './release-config.mjs'
|
|
2
|
-
|
|
3
|
-
const platforms = new Map([
|
|
4
|
-
['--mac', 'darwin'], ['--macos', 'darwin'], ['-m', 'darwin'],
|
|
5
|
-
['--win', 'win32'], ['--windows', 'win32'], ['-w', 'win32'],
|
|
6
|
-
['--linux', 'linux'], ['-l', 'linux'],
|
|
7
|
-
])
|
|
8
|
-
const architectures = new Set(['x64', 'arm64', 'ia32'])
|
|
9
|
-
|
|
10
|
-
// An update feed is specific to one OS/CPU. Build each target separately so
|
|
11
|
-
// the packaged feed and the generated release record always describe its bytes.
|
|
12
|
-
export function releaseTarget(args, host = process) {
|
|
13
|
-
const requestedPlatforms = new Set()
|
|
14
|
-
const requestedArchitectures = new Set()
|
|
15
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
16
|
-
const value = args[i]
|
|
17
|
-
const platformFlag = value.split('=')[0]
|
|
18
|
-
if (platforms.has(platformFlag)) requestedPlatforms.add(platforms.get(platformFlag))
|
|
19
|
-
if (value === '--armv7l') throw new Error('Unsupported release architecture: armv7l')
|
|
20
|
-
if (value === '--universal') throw new Error('Release builds require a separate arm64 or x64 package; --universal is unsupported')
|
|
21
|
-
const arch = value === '--arch' ? args[++i] : value.replace(/^--(?:arch=)?/, '')
|
|
22
|
-
if (architectures.has(arch)) requestedArchitectures.add(arch)
|
|
23
|
-
else if (value === '--arch' || value.startsWith('--arch=')) throw new Error(`Unsupported release architecture: ${arch}`)
|
|
24
|
-
}
|
|
25
|
-
if (requestedPlatforms.size > 1 || requestedArchitectures.size > 1) {
|
|
26
|
-
throw new Error('Build one platform and architecture at a time; publish their release records together')
|
|
27
|
-
}
|
|
28
|
-
const platform = [...requestedPlatforms][0] || host.platform
|
|
29
|
-
const arch = [...requestedArchitectures][0] || host.arch
|
|
30
|
-
if (!['darwin', 'win32', 'linux'].includes(platform) || !architectures.has(arch) || (platform === 'darwin' && arch === 'ia32')) {
|
|
31
|
-
throw new Error(`Unsupported release target: ${platform}/${arch}`)
|
|
32
|
-
}
|
|
33
|
-
return { platform, arch }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function releaseFeedURL(value, target) {
|
|
37
|
-
const resolved = String(value || '').trim().replaceAll('{platform}', target.platform).replaceAll('{arch}', target.arch).replace(/\/+$/, '')
|
|
38
|
-
let url
|
|
39
|
-
try { url = new URL(resolved) } catch { throw new Error('发布配置 update.url 必须是 HTTPS 或本机回环 HTTP 更新目录地址') }
|
|
40
|
-
if (!isAllowedReleaseURL(resolved)) {
|
|
41
|
-
throw new Error('发布配置 update.url 必须是无凭据、无查询参数或片段的 HTTPS 或本机回环 HTTP 更新目录地址')
|
|
42
|
-
}
|
|
43
|
-
const routeTarget = /\/(darwin|win32|linux)\/(arm64|x64|ia32)$/.exec(url.pathname)
|
|
44
|
-
if (routeTarget && (routeTarget[1] !== target.platform || routeTarget[2] !== target.arch)) {
|
|
45
|
-
throw new Error(`Update URL target does not match ${target.platform}/${target.arch}; use {platform}/{arch} placeholders`)
|
|
46
|
-
}
|
|
47
|
-
return resolved
|
|
48
|
-
}
|