@ducking-mind/proxy-app-cli 0.1.0 → 0.1.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.
- package/README.md +127 -62
- package/agent-contract.mjs +128 -0
- package/agent-runtime/package-guide.mjs +193 -0
- package/agent-runtime/protocol.mjs +325 -0
- package/agent-runtime/runtime-status.mjs +70 -0
- package/artifact-security.mjs +45 -0
- package/local-dev-credentials.mjs +187 -0
- package/local-dev-relay.mjs +367 -82
- package/package.json +23 -6
- package/prepare-agent-runtime.mjs +43 -0
- package/proxy-app.mjs +871 -190
- package/release-runtime/release-config.mjs +154 -0
- package/release-runtime/release-target.mjs +48 -0
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
}
|