@ducking-mind/proxy-app-cli 0.1.1 → 0.1.2
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 +3 -2
- package/agent-contract.mjs +2 -5
- package/agent-runtime/package-guide.mjs +4 -53
- package/agent-runtime/protocol.mjs +4 -124
- package/local-dev-relay.mjs +5 -4
- package/package.json +4 -4
- package/platform-settings.mjs +19 -0
- package/prepare-agent-runtime.mjs +7 -40
- package/proxy-app.mjs +9 -257
- package/agent-runtime/runtime-status.mjs +0 -70
- package/release-runtime/release-config.mjs +0 -154
- package/release-runtime/release-target.mjs +0 -48
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ proxy-app publish --version 0.1.0 # 切换到指定已上传版本,也可回
|
|
|
36
36
|
`publish --version <version>` 可选择历史已发布版本回滚,也可发布已上传草稿;不修改本地 manifest,
|
|
37
37
|
不要求本地版本号与目标一致,不构建或上传。目标尚未上传时直接报错,目标已是当前线上版本时不重复切换。
|
|
38
38
|
|
|
39
|
+
桌面宿主不属于本 CLI 的开源交付范围。CLI 只生成公开应用包;桌面安装包、更新元数据和签名材料由内部私有发布流水线生成。
|
|
40
|
+
|
|
39
41
|
首次创建应用:
|
|
40
42
|
|
|
41
43
|
```bash
|
|
@@ -159,8 +161,7 @@ CLI 从已导出的变量读取配置,不会自动加载 `.env` 文件。请
|
|
|
159
161
|
|
|
160
162
|
创建应用时,`--category` 支持 `ecommerce`(电商)、`comic`(漫剧)和 `other`(其他,默认)。分类用于应用市场展示,创建后可在“编辑应用资料”中调整。
|
|
161
163
|
|
|
162
|
-
|
|
163
|
-
只设置 `PROXY_PLATFORM_URL=https://platform.example` 即可;CLI 自动使用该站点的 `/api` 与登录页。若平台把 API 部署在独立域名,再额外设置 `PROXY_API_BASE_URL` 或 `PROXY_PORTAL_URL` 覆盖对应地址。单地址配置时,请清除已导出的旧覆盖变量。
|
|
164
|
+
默认使用线上平台 `https://www.duckingmind.com`,API 地址为 `https://www.duckingmind.com/api`,登录页也使用该域名。其他环境若 Portal 与 API 使用同一域名,只设置 `PROXY_PLATFORM_URL=https://platform.example` 即可;若两者使用不同地址,再额外设置 `PROXY_API_BASE_URL` 或 `PROXY_PORTAL_URL` 覆盖对应地址。连接本地平台时,可分别设置 `PROXY_API_BASE_URL=http://localhost:9003/api` 与 `PROXY_PORTAL_URL=http://localhost:9004`。
|
|
164
165
|
管理命令(创建、发布等)仍需 `PROXY_USER_ACCESS_TOKEN`;本地开发使用浏览器授权,不需要这个变量。
|
|
165
166
|
若 shell 已全局导出 `PROXY_USER_ACCESS_TOKEN` 或旧别名 `PROXY_ACCESS_TOKEN`,开发命令会优先使用该令牌;想在浏览器中选择创建者账户时先运行 `unset PROXY_USER_ACCESS_TOKEN PROXY_ACCESS_TOKEN`。
|
|
166
167
|
|
package/agent-contract.mjs
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import { lstat, mkdir, readFile,
|
|
4
|
+
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
5
5
|
import { resolve, sep } from 'node:path'
|
|
6
6
|
import { pathToFileURL } from 'node:url'
|
|
7
7
|
|
|
8
|
-
const runtimeDirectory =
|
|
9
|
-
if (error.code !== 'ENOENT') throw error
|
|
10
|
-
return './agent-runtime/'
|
|
11
|
-
})
|
|
8
|
+
const runtimeDirectory = './agent-runtime/'
|
|
12
9
|
const { normalizeCapabilityDeclaration, normalizeManifestAgent } = await import(new URL(`${runtimeDirectory}protocol.mjs`, import.meta.url))
|
|
13
10
|
const { copyAgentPackage } = await import(new URL(`${runtimeDirectory}package-guide.mjs`, import.meta.url))
|
|
14
11
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
/** Public Agent guide and example package validation helpers. The private host
|
|
2
|
+
* owns lifecycle, transport, authorization, and release behavior. */
|
|
3
|
+
|
|
1
4
|
import { createHash } from 'node:crypto'
|
|
2
5
|
import { constants } from 'node:fs'
|
|
3
6
|
import { lstat, mkdir, open, realpath } from 'node:fs/promises'
|
|
4
7
|
import { join, resolve } from 'node:path'
|
|
5
|
-
import { normalizeManifestAgent,
|
|
8
|
+
import { normalizeManifestAgent, validateValue } from './protocol.mjs'
|
|
6
9
|
|
|
7
10
|
const MAX_TEXT_BYTES = 64 * 1024
|
|
8
11
|
const MAX_PACKAGE_BYTES = 256 * 1024
|
|
@@ -112,47 +115,6 @@ async function readAgentPackage(root, manifest) {
|
|
|
112
115
|
return readAgentPackageWithReader(manifest, entry => readPackageFile(packageRoot, entry))
|
|
113
116
|
}
|
|
114
117
|
|
|
115
|
-
/** Load the same bounded, schema-validated package resources for Web apps.
|
|
116
|
-
* The platform-provided versioned entry anchors every URL; no account bearer
|
|
117
|
-
* credential is sent to application content, even when hosted on a CDN.
|
|
118
|
-
*/
|
|
119
|
-
export async function loadWebAgentPackage(manifest, { entryURL, platformOrigin, fetchImpl = fetch, signal } = {}) {
|
|
120
|
-
signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000)
|
|
121
|
-
const entry = new URL(entryURL, platformOrigin)
|
|
122
|
-
check(['https:', 'http:'].includes(entry.protocol) && !entry.username && !entry.password, 'invalid Web entry URL')
|
|
123
|
-
check(entry.protocol === 'https:' || ['localhost', '127.0.0.1', '[::1]'].includes(entry.hostname), 'insecure Web entry URL')
|
|
124
|
-
const runtimeEntry = 'index.html'
|
|
125
|
-
const suffix = runtimeEntry.split('/').map(encodeURIComponent).join('/')
|
|
126
|
-
check(entry.pathname.endsWith(`/${suffix}`), 'Web entry does not match the declared application entry')
|
|
127
|
-
const prefix = entry.pathname.slice(0, -suffix.length)
|
|
128
|
-
const { files, ...data } = await readAgentPackageWithReader(manifest, async path => {
|
|
129
|
-
const url = new URL(entry)
|
|
130
|
-
url.pathname = prefix + path.split('/').map(encodeURIComponent).join('/')
|
|
131
|
-
url.hash = ''
|
|
132
|
-
const response = await fetchImpl(url, { signal, redirect: 'error', credentials: 'omit' })
|
|
133
|
-
check(response.ok, `Web package entry unavailable: ${path} (${response.status})`)
|
|
134
|
-
check(!response.headers.get('content-length') || Number(response.headers.get('content-length')) <= MAX_TEXT_BYTES, `entry exceeds 64 KiB: ${path}`)
|
|
135
|
-
check(response.body, `empty Web package response: ${path}`)
|
|
136
|
-
const reader = response.body.getReader()
|
|
137
|
-
const chunks = []
|
|
138
|
-
let length = 0
|
|
139
|
-
try {
|
|
140
|
-
while (true) {
|
|
141
|
-
const { value, done } = await reader.read()
|
|
142
|
-
if (done) break
|
|
143
|
-
length += value.length
|
|
144
|
-
check(length <= MAX_TEXT_BYTES, `entry exceeds 64 KiB: ${path}`)
|
|
145
|
-
chunks.push(value)
|
|
146
|
-
}
|
|
147
|
-
} finally { await reader.cancel().catch(() => {}) }
|
|
148
|
-
const bytes = Buffer.concat(chunks)
|
|
149
|
-
let content
|
|
150
|
-
try { content = new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw new Error(`Invalid UTF-8 agent package entry: ${path}`) }
|
|
151
|
-
return { bytes, content }
|
|
152
|
-
})
|
|
153
|
-
return data
|
|
154
|
-
}
|
|
155
|
-
|
|
156
118
|
/** Read only declared, bounded files from the selected application version. */
|
|
157
119
|
export async function loadAgentPackage(root, manifest) {
|
|
158
120
|
const { files, ...data } = await readAgentPackage(root, manifest)
|
|
@@ -180,14 +142,3 @@ export async function copyAgentPackage(source, destination, manifest) {
|
|
|
180
142
|
}
|
|
181
143
|
return data
|
|
182
144
|
}
|
|
183
|
-
|
|
184
|
-
/** The API topic is generated from the same declaration tools/list publishes. */
|
|
185
|
-
export function renderAgentReference(manifest) {
|
|
186
|
-
const agent = normalizeManifestAgent(manifest)
|
|
187
|
-
if (!agent) return ''
|
|
188
|
-
const lines = [`# ${manifest.name || manifest.id} API`, '', `Application: ${manifest.id}`, `Application version: ${manifest.version}`, `Contract version: ${manifest.agent.contract_version || manifest.version}`, `Application targets: ${(agent.targets || ['desktop']).join(', ')}`, '', 'Generated from the active package manifest. Read the overview and workflow topics for business rules.', '']
|
|
189
|
-
for (const capability of agent.capabilities) {
|
|
190
|
-
lines.push(`## ${toolNameFor(agent.namespace, capability.name)}`, '', capability.description, '', `Targets: ${capability.targets.join(', ')}; access: ${capability.access}; idempotent: ${capability.idempotent}.`, '', 'Input schema:', '```json', JSON.stringify(capability.input_schema, null, 2), '```', '', 'Output schema:', '```json', JSON.stringify(capability.output_schema, null, 2), '```', '')
|
|
191
|
-
}
|
|
192
|
-
return lines.join('\n')
|
|
193
|
-
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Public compatibility primitives for an app Agent contract (for example,
|
|
3
|
+
* the `proxy.desktop.agent/v1` data format). This module validates application
|
|
4
|
+
* declarations and schemas; runtime registration, transport, authorization,
|
|
5
|
+
* and host lifecycle remain in the private desktop implementation.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { serializeAgentError } from './runtime-status.mjs'
|
|
8
|
-
import { createHash } from 'node:crypto'
|
|
9
|
-
|
|
10
8
|
export const AGENT_PROTOCOL = 'proxy.desktop.agent/v1'
|
|
11
9
|
export const AGENT_PROTOCOL_VERSION = 1
|
|
12
10
|
|
|
@@ -38,17 +36,6 @@ export function isAppManifestID(value) {
|
|
|
38
36
|
return isPublicAppSlug(value) || (typeof value === 'string' && value === value.trim() && PLATFORM_APP_ID_RE.test(value))
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
// Only metadata from the authenticated platform (or the trusted account
|
|
42
|
-
// broker) may approve a historical manifest identity. Never infer an alias by
|
|
43
|
-
// adding/removing a prefix, and never alias one stable platform ID to another.
|
|
44
|
-
export function appManifestIdentityMatches(value, metadata) {
|
|
45
|
-
if (!isAppManifestID(value) || !metadata) return false
|
|
46
|
-
const id = metadata.app_id || metadata.id
|
|
47
|
-
if (PLATFORM_APP_ID_RE.test(value)) return value === id
|
|
48
|
-
if (value === (metadata.app_slug || metadata.slug)) return true
|
|
49
|
-
return Array.isArray(metadata.app_aliases) && metadata.app_aliases.some(alias => isPublicAppSlug(alias) && alias === value)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
39
|
export function assertAppId(value) {
|
|
53
40
|
assert(isAppManifestID(value) || (typeof value === 'string' && value === value.trim() && LEGACY_APP_ID_RE.test(value)), `invalid app id: ${value}`)
|
|
54
41
|
return value
|
|
@@ -164,86 +151,6 @@ export function normalizeManifestAgent(manifest) {
|
|
|
164
151
|
return Object.freeze({ enabled: true, namespace, targets, contractVersion, summary, keywords, guide: agent.guide, examples: agent.examples, capabilities })
|
|
165
152
|
}
|
|
166
153
|
|
|
167
|
-
const canonical = value => Array.isArray(value) ? value.map(canonical) : isRecord(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
|
|
168
|
-
/** Same contract identity for market metadata and installed packages, independent of transport/runtime state. */
|
|
169
|
-
export function describeAgentContract(manifest) {
|
|
170
|
-
const agent = normalizeManifestAgent(manifest)
|
|
171
|
-
if (!agent) return null
|
|
172
|
-
const contract = { appId: manifest.id, namespace: agent.namespace, contractVersion: agent.contractVersion, targets: agent.targets,
|
|
173
|
-
capabilities: [...agent.capabilities].sort((a, b) => a.name.localeCompare(b.name)) }
|
|
174
|
-
return { enabled: true, namespace: agent.namespace, targets: agent.targets, contractVersion: agent.contractVersion,
|
|
175
|
-
contractHash: createHash('sha256').update(JSON.stringify(canonical(contract))).digest('hex'),
|
|
176
|
-
capabilityCount: agent.capabilities.length,
|
|
177
|
-
desktopCapabilityCount: agent.capabilities.filter(capability => capability.targets.includes('desktop')).length,
|
|
178
|
-
webCapabilityCount: agent.capabilities.filter(capability => capability.targets.includes('web')).length,
|
|
179
|
-
capabilities: agent.capabilities.map(({ name, title, description, access, targets, billable, completion }) => ({ name, title, description, access, targets, billable, completion })) }
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function normalizeRuntimeCapability(input) {
|
|
183
|
-
assert(isRecord(input), 'runtime capability must be an object')
|
|
184
|
-
const name = assertCapabilityName(input.name)
|
|
185
|
-
if (input.handlerVersion !== undefined) {
|
|
186
|
-
assert(Number.isInteger(input.handlerVersion) && input.handlerVersion > 0, `invalid handlerVersion for ${name}`)
|
|
187
|
-
}
|
|
188
|
-
if (input.available !== undefined) assert(typeof input.available === 'boolean', `invalid available for ${name}`)
|
|
189
|
-
return Object.freeze({ name, handlerVersion: input.handlerVersion || 1, available: input.available !== false })
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function validateRuntimeRegistration(manifest, registration, { target = 'desktop' } = {}) {
|
|
193
|
-
assert(AGENT_TARGETS.includes(target), 'invalid runtime target')
|
|
194
|
-
assert(isRecord(registration), 'runtime registration must be an object')
|
|
195
|
-
const agent = normalizeManifestAgent(manifest)
|
|
196
|
-
assert(agent, 'agent capabilities are not enabled in manifest')
|
|
197
|
-
assertAppId(registration.appId)
|
|
198
|
-
assertVersion(registration.version)
|
|
199
|
-
assert(registration.appId === manifest.id, 'runtime app id does not match manifest')
|
|
200
|
-
assert(registration.version === manifest.version, 'runtime app version does not match manifest')
|
|
201
|
-
assert(Array.isArray(registration.capabilities) && registration.capabilities.length <= 64, 'runtime capabilities must be an array with at most 64 entries')
|
|
202
|
-
const codeFirst = manifest.agent.contract_source === 'code'
|
|
203
|
-
const dynamic = registration.dynamic === true && !codeFirst
|
|
204
|
-
const declarations = new Map(agent.capabilities.map((capability) => [capability.name, capability]))
|
|
205
|
-
const runtime = []
|
|
206
|
-
const registeredDeclarations = []
|
|
207
|
-
const seen = new Set()
|
|
208
|
-
for (const raw of registration.capabilities) {
|
|
209
|
-
const capability = normalizeRuntimeCapability(raw)
|
|
210
|
-
assert(!seen.has(capability.name), `duplicate runtime capability: ${capability.name}`)
|
|
211
|
-
seen.add(capability.name)
|
|
212
|
-
let declared = declarations.get(capability.name)
|
|
213
|
-
const hasContract = ['title', 'description', 'access', 'input_schema', 'inputSchema', 'output_schema', 'outputSchema', 'targets', 'completion', 'idempotent', 'destructive', 'open_world', 'billable'].some(field => Object.hasOwn(raw, field))
|
|
214
|
-
let supplied
|
|
215
|
-
if (codeFirst || dynamic || hasContract) {
|
|
216
|
-
if (codeFirst || dynamic) assert(raw.access !== undefined && (raw.input_schema ?? raw.inputSchema) !== undefined && (raw.output_schema ?? raw.outputSchema) !== undefined, `runtime capability requires a complete code declaration: ${capability.name}`)
|
|
217
|
-
supplied = normalizeCapabilityDeclaration(raw, { defaultTargets: agent.targets })
|
|
218
|
-
assert(supplied.targets.every(item => agent.targets.includes(item)), `${capability.name}.targets must be a subset of application targets`)
|
|
219
|
-
assert(supplied.targets.includes(target), `runtime capability does not support ${target}: ${capability.name}`)
|
|
220
|
-
}
|
|
221
|
-
// Code-first applications provide the complete contract at runtime. The
|
|
222
|
-
// names-only form remains a compatibility path for already packaged
|
|
223
|
-
// applications whose generated manifest contains the contract.
|
|
224
|
-
if (declared) {
|
|
225
|
-
assert(declared.targets.includes(target), `runtime capability does not support ${target}: ${capability.name}`)
|
|
226
|
-
if (codeFirst) {
|
|
227
|
-
assert(JSON.stringify(canonical(supplied)) === JSON.stringify(canonical(declared)), `runtime capability contract differs from packaged code contract: ${capability.name}`)
|
|
228
|
-
}
|
|
229
|
-
} else if (!dynamic) {
|
|
230
|
-
assert(false, `runtime capability is not declared: ${capability.name}`)
|
|
231
|
-
}
|
|
232
|
-
if (dynamic) {
|
|
233
|
-
declared = supplied
|
|
234
|
-
declarations.set(capability.name, declared)
|
|
235
|
-
}
|
|
236
|
-
registeredDeclarations.push(declared)
|
|
237
|
-
if (capability.available) runtime.push({ ...capability, declaration: declared })
|
|
238
|
-
}
|
|
239
|
-
return Object.freeze({
|
|
240
|
-
appId: registration.appId,
|
|
241
|
-
version: registration.version,
|
|
242
|
-
capabilities: runtime,
|
|
243
|
-
declarations: registeredDeclarations,
|
|
244
|
-
})
|
|
245
|
-
}
|
|
246
|
-
|
|
247
154
|
export function toolNameFor(namespace, capability) {
|
|
248
155
|
assertNamespace(namespace)
|
|
249
156
|
assertCapabilityName(capability)
|
|
@@ -252,33 +159,6 @@ export function toolNameFor(namespace, capability) {
|
|
|
252
159
|
return name
|
|
253
160
|
}
|
|
254
161
|
|
|
255
|
-
export function toMcpTool({ namespace, appId, version, contractVersion = version, contractHash, capability, runtime }) {
|
|
256
|
-
const declaration = normalizeCapabilityDeclaration({ ...capability, name: capability.name })
|
|
257
|
-
const tool = {
|
|
258
|
-
name: toolNameFor(namespace, declaration.name),
|
|
259
|
-
title: declaration.title,
|
|
260
|
-
description: [declaration.description || `${appId} ${declaration.name}`, declaration.billable ? 'May incur model or service charges.' : '', declaration.completion === 'accepted' ? 'Returns acceptance only; use the application task query to check completion.' : ''].filter(Boolean).join(' '),
|
|
261
|
-
inputSchema: declaration.input_schema,
|
|
262
|
-
_meta: { proxy: { appId, version, contractVersion, ...(contractHash ? { contractHash } : {}), capability: declaration.name, billable: declaration.billable, completion: declaration.completion, targets: declaration.targets } },
|
|
263
|
-
}
|
|
264
|
-
if (declaration.output_schema) tool.outputSchema = declaration.output_schema
|
|
265
|
-
tool.annotations = { readOnlyHint: declaration.access === ACCESS.READ, destructiveHint: declaration.destructive, idempotentHint: declaration.idempotent, openWorldHint: declaration.open_world }
|
|
266
|
-
if (runtime) tool._meta.proxy.runtime = runtime
|
|
267
|
-
return Object.freeze(tool)
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export function createInvocation({ requestId, appId, appVersion, capability, args, deadlineAt, cancellationToken }) {
|
|
271
|
-
assert(typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 128, 'invalid request id')
|
|
272
|
-
assertAppId(appId); assertVersion(appVersion); assertCapabilityName(capability)
|
|
273
|
-
assert(args === undefined || isRecord(args), 'agent arguments must be an object')
|
|
274
|
-
return Object.freeze({ requestId, appId, appVersion, capability, args: args || {}, deadlineAt, cancellationToken })
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export function serializeError(error) {
|
|
278
|
-
return serializeAgentError(error)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
282
162
|
// Deliberately bounded JSON Schema subset. Reject unsupported keywords instead
|
|
283
163
|
// of advertising validation that the host cannot actually enforce.
|
|
284
164
|
const keywords = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'description', 'title', 'default'])
|
package/local-dev-relay.mjs
CHANGED
|
@@ -314,10 +314,11 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
|
|
|
314
314
|
let requiresFreshLogin = forceLogin
|
|
315
315
|
let developerToken = explicitToken
|
|
316
316
|
if (!developerToken && !forceLogin) developerToken = await wait(Promise.resolve(developerCredentialStore?.load()))
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
317
|
+
// A missing developer cache does not mean that the Portal session is
|
|
318
|
+
// invalid. Let the Portal reuse its current signed-in account; the
|
|
319
|
+
// registration endpoint still enforces that it is the app creator.
|
|
320
|
+
// Force account selection only when replacing a rejected credential.
|
|
321
|
+
if (!developerToken && !explicitToken) requiresFreshLogin = false
|
|
321
322
|
checkActive()
|
|
322
323
|
if (developerToken) {
|
|
323
324
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ducking-mind/proxy-app-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "CLI for creating, building and publishing Proxy platform apps",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,12 +25,13 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"proxy-app.mjs",
|
|
28
|
+
"platform-settings.mjs",
|
|
28
29
|
"local-dev-relay.mjs",
|
|
29
30
|
"local-dev-credentials.mjs",
|
|
30
31
|
"artifact-security.mjs",
|
|
31
32
|
"agent-contract.mjs",
|
|
32
|
-
"agent-runtime
|
|
33
|
-
"
|
|
33
|
+
"agent-runtime/protocol.mjs",
|
|
34
|
+
"agent-runtime/package-guide.mjs",
|
|
34
35
|
"prepare-agent-runtime.mjs"
|
|
35
36
|
],
|
|
36
37
|
"scripts": {
|
|
@@ -43,7 +44,6 @@
|
|
|
43
44
|
"access": "public"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"@ducking-mind/proxy-app-desktop-runtime": "^0.1.0",
|
|
47
47
|
"fflate": "^0.8.2"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const DEFAULT_PLATFORM_URL = 'https://www.duckingmind.com'
|
|
2
|
+
|
|
3
|
+
export function platformAPIBaseURL(env = process.env) {
|
|
4
|
+
const explicit = env.PROXY_API_BASE_URL?.trim()
|
|
5
|
+
if (explicit) return explicit.replace(/\/$/, '')
|
|
6
|
+
const platform = env.PROXY_PLATFORM_URL?.trim() || DEFAULT_PLATFORM_URL
|
|
7
|
+
let url
|
|
8
|
+
try { url = new URL(platform) } catch { throw new Error('PROXY_PLATFORM_URL must be a valid platform origin, such as https://platform.example') }
|
|
9
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
|
10
|
+
throw new Error('PROXY_PLATFORM_URL must be a platform origin without a path, credentials, query, or hash')
|
|
11
|
+
}
|
|
12
|
+
return `${url.origin}/api`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function platformPortalURL(env = process.env) {
|
|
16
|
+
const explicit = env.PROXY_PORTAL_URL?.trim() || env.PROXY_PUBLIC_URL?.trim()
|
|
17
|
+
if (explicit) return explicit
|
|
18
|
+
return env.PROXY_PLATFORM_URL?.trim() || DEFAULT_PLATFORM_URL
|
|
19
|
+
}
|
|
@@ -1,43 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { access } from 'node:fs/promises'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
modules.add(name)
|
|
10
|
-
const content = await readFile(new URL(name, source), 'utf8')
|
|
11
|
-
for (const match of content.matchAll(/(?:from\s*|import\s*)['"]\.\/([^'"]+)['"]/g)) await collect(match[1])
|
|
12
|
-
await writeFile(new URL(name, destination), content)
|
|
3
|
+
// These modules are deliberately maintained as public, dependency-free copies.
|
|
4
|
+
// They contain the application capability contract and package validation only;
|
|
5
|
+
// the proprietary desktop host and its build scripts are never copied into an
|
|
6
|
+
// npm package.
|
|
7
|
+
for (const file of ['./agent-runtime/protocol.mjs', './agent-runtime/package-guide.mjs']) {
|
|
8
|
+
await access(new URL(file, import.meta.url))
|
|
13
9
|
}
|
|
14
|
-
|
|
15
|
-
// Include the transitive local imports, never the Electron entry points.
|
|
16
|
-
// Repacking an installed npm package reuses its already bundled runtime.
|
|
17
|
-
const hasSource = await stat(source).then(() => true).catch(error => { if (error.code !== 'ENOENT') throw error; return false })
|
|
18
|
-
if (hasSource) {
|
|
19
|
-
await mkdir(destination, { recursive: true })
|
|
20
|
-
await collect('protocol.mjs')
|
|
21
|
-
await collect('package-guide.mjs')
|
|
22
|
-
for (const entry of await readdir(destination)) if (!modules.has(entry)) await rm(new URL(entry, destination), { recursive: true, force: true })
|
|
23
|
-
} else {
|
|
24
|
-
await import(new URL('package-guide.mjs', destination))
|
|
25
|
-
}
|
|
26
|
-
// The package contract resolver uses this same bundled validation, including
|
|
27
|
-
// when it is run as a project's direct build helper.
|
|
28
10
|
await import(new URL('./agent-contract.mjs', import.meta.url))
|
|
29
|
-
|
|
30
|
-
// The standalone CLI also exposes Electron release helpers. Keep a small copy
|
|
31
|
-
// in the npm package so an installed CLI does not depend on the monorepo's
|
|
32
|
-
// app/desktop directory. Source checkouts still use the live modules below.
|
|
33
|
-
const releaseSource = new URL('../desktop/scripts/', import.meta.url)
|
|
34
|
-
const releaseDestination = new URL('./release-runtime/', import.meta.url)
|
|
35
|
-
const releaseModules = ['release-config.mjs', 'release-target.mjs']
|
|
36
|
-
const hasReleaseSource = await stat(new URL(releaseModules[0], releaseSource)).then(() => true).catch(error => {
|
|
37
|
-
if (error.code !== 'ENOENT') throw error
|
|
38
|
-
return false
|
|
39
|
-
})
|
|
40
|
-
if (hasReleaseSource) {
|
|
41
|
-
await mkdir(releaseDestination, { recursive: true })
|
|
42
|
-
for (const name of releaseModules) await copyFile(new URL(name, releaseSource), new URL(name, releaseDestination))
|
|
43
|
-
}
|
package/proxy-app.mjs
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import {
|
|
5
|
-
import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
6
5
|
import { createHash, randomUUID } from 'node:crypto'
|
|
7
|
-
import {
|
|
8
|
-
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
|
|
9
|
-
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
10
7
|
import { createServer as createTCPServer } from 'node:net'
|
|
11
8
|
import { zipSync } from 'fflate'
|
|
12
9
|
import { createLocalDevelopmentRelay } from './local-dev-relay.mjs'
|
|
13
10
|
import { createLocalCredentialStore } from './local-dev-credentials.mjs'
|
|
14
11
|
import { assertSafeArtifactFile, assertSafeArtifactDirectory } from './artifact-security.mjs'
|
|
15
12
|
import { resolveAgentContract } from './agent-contract.mjs'
|
|
13
|
+
import { platformAPIBaseURL, platformPortalURL } from './platform-settings.mjs'
|
|
16
14
|
|
|
17
15
|
const MAX_BUNDLE_BYTES = 50 * 1024 * 1024
|
|
18
16
|
const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'platform.capabilities.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
|
|
@@ -37,24 +35,8 @@ const appBinding = app => ({
|
|
|
37
35
|
...(publicAppAliases(app).length ? { app_aliases: publicAppAliases(app) } : {}),
|
|
38
36
|
})
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const agentRuntimeDirectory = await stat(new URL('../desktop/agent/protocol.mjs', import.meta.url)).then(() => '../desktop/agent/').catch(error => {
|
|
43
|
-
if (error.code !== 'ENOENT') throw error
|
|
44
|
-
return './agent-runtime/'
|
|
45
|
-
})
|
|
46
|
-
const { normalizeManifestAgent } = await import(new URL(`${agentRuntimeDirectory}protocol.mjs`, import.meta.url))
|
|
47
|
-
const { loadAgentPackage, copyAgentPackage } = await import(new URL(`${agentRuntimeDirectory}package-guide.mjs`, import.meta.url))
|
|
48
|
-
|
|
49
|
-
// Source checkouts use the live desktop release helpers. npm packages carry a
|
|
50
|
-
// small prepackaged copy under release-runtime/ so `proxy-app` works after
|
|
51
|
-
// installation without the monorepo's app/desktop directory.
|
|
52
|
-
const releaseRuntimeDirectory = await stat(new URL('../desktop/scripts/release-config.mjs', import.meta.url)).then(() => '../desktop/scripts/').catch(error => {
|
|
53
|
-
if (error.code !== 'ENOENT') throw error
|
|
54
|
-
return './release-runtime/'
|
|
55
|
-
})
|
|
56
|
-
const { isAllowedReleaseURL, loadReleaseConfig, parseConfigOption, releaseConfigRoot, validateReleaseConfig } = await import(new URL(`${releaseRuntimeDirectory}release-config.mjs`, import.meta.url))
|
|
57
|
-
const { releaseFeedURL, releaseTarget } = await import(new URL(`${releaseRuntimeDirectory}release-target.mjs`, import.meta.url))
|
|
38
|
+
const { normalizeManifestAgent } = await import(new URL('./agent-runtime/protocol.mjs', import.meta.url))
|
|
39
|
+
const { loadAgentPackage, copyAgentPackage } = await import(new URL('./agent-runtime/package-guide.mjs', import.meta.url))
|
|
58
40
|
|
|
59
41
|
function usage() {
|
|
60
42
|
console.log(`在应用目录内,常用命令:
|
|
@@ -79,22 +61,18 @@ pack 无需提前执行 build 或 validate;已上传的版本由 publish 直
|
|
|
79
61
|
proxy-app pack [app-dir] [-o <output.zip>]
|
|
80
62
|
proxy-app upload [app-dir] [-o <output.zip>]
|
|
81
63
|
proxy-app publish [app-dir] [-o <output.zip> | --version <uploaded-version>]
|
|
82
|
-
proxy-app desktop-build [app-dir]
|
|
83
|
-
proxy-app desktop-pack [app-dir] [-o <output-directory>]
|
|
84
64
|
|
|
85
65
|
upload 只上传;publish 显式切换线上版本。首次使用 publish 仍需申请应用开放。
|
|
86
66
|
--version 仅选择已上传版本,不构建、不上传、不修改本地 manifest。
|
|
87
67
|
-o 与 --output 等价,也兼容原来的 <app-dir> <output.zip> 写法。
|
|
88
68
|
--source 仅供 pack/upload 调试源码,跳过构建,不用于正式发布。
|
|
89
|
-
desktop-pack 会在输出目录生成 desktop-release.json 和 electron-updater metadata;发布配置
|
|
90
|
-
使用 desktop/release.config.json,API Key 只用于发布请求,不会写入安装包。
|
|
91
69
|
应用分类:ecommerce(电商)、comic(漫剧)、other(其他,默认)。
|
|
92
70
|
|
|
93
71
|
Publish environment:
|
|
94
72
|
PROXY_PLATFORM_URL Optional single-origin platform URL; derives API (/api) and login Portal
|
|
95
|
-
PROXY_API_BASE_URL default:
|
|
73
|
+
PROXY_API_BASE_URL default: https://www.duckingmind.com/api
|
|
96
74
|
Optional override when the platform uses a separate API host
|
|
97
|
-
PROXY_PORTAL_URL Optional login Portal override; default:
|
|
75
|
+
PROXY_PORTAL_URL Optional login Portal override; default: https://www.duckingmind.com
|
|
98
76
|
PROXY_USER_ACCESS_TOKEN Current platform user session for app management/CI only; never passed to the app
|
|
99
77
|
PROXY_ACCESS_TOKEN Legacy alias for PROXY_USER_ACCESS_TOKEN
|
|
100
78
|
|
|
@@ -165,8 +143,6 @@ function commandArguments(command, args) {
|
|
|
165
143
|
pack: { values: ['--output'], flags: ['--source'], max: 2 },
|
|
166
144
|
upload: { values: ['--output'], flags: ['--source'], max: 2 },
|
|
167
145
|
publish: { values: ['--output', '--version'], max: 2 },
|
|
168
|
-
'desktop-build': { values: ['--config'], flags: ['--mac', '--macos', '-m', '--win', '--windows', '-w', '--linux', '-l', '--x64', '--arm64', '--ia32', '--env-overrides'], max: 1 },
|
|
169
|
-
'desktop-pack': { values: ['--output', '--config'], flags: ['--mac', '--macos', '-m', '--win', '--windows', '-w', '--linux', '-l', '--x64', '--arm64', '--ia32', '--env-overrides'], max: 2 },
|
|
170
146
|
}
|
|
171
147
|
const config = configurations[command]
|
|
172
148
|
if (!config) throw new Error(`unknown command: ${command}`)
|
|
@@ -214,27 +190,6 @@ function apiSettings() {
|
|
|
214
190
|
return { token, baseURL }
|
|
215
191
|
}
|
|
216
192
|
|
|
217
|
-
function platformAPIBaseURL() {
|
|
218
|
-
const explicit = process.env.PROXY_API_BASE_URL?.trim()
|
|
219
|
-
if (explicit) return explicit.replace(/\/$/, '')
|
|
220
|
-
const platform = process.env.PROXY_PLATFORM_URL?.trim()
|
|
221
|
-
if (!platform) return 'http://localhost:9003/api'
|
|
222
|
-
let url
|
|
223
|
-
try { url = new URL(platform) } catch { throw new Error('PROXY_PLATFORM_URL must be a valid platform origin, such as https://platform.example') }
|
|
224
|
-
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
|
225
|
-
throw new Error('PROXY_PLATFORM_URL must be a platform origin without a path, credentials, query, or hash')
|
|
226
|
-
}
|
|
227
|
-
return `${url.origin}/api`
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function platformPortalURL() {
|
|
231
|
-
const explicit = process.env.PROXY_PORTAL_URL?.trim() || process.env.PROXY_PUBLIC_URL?.trim()
|
|
232
|
-
if (explicit) return explicit
|
|
233
|
-
const platform = process.env.PROXY_PLATFORM_URL?.trim()
|
|
234
|
-
if (platform) return platform
|
|
235
|
-
return 'http://localhost:9004'
|
|
236
|
-
}
|
|
237
|
-
|
|
238
193
|
function responseError(body, fallback) {
|
|
239
194
|
const message = typeof body?.error === 'string' ? body.error : body?.error?.message || body?.message
|
|
240
195
|
const code = body?.code || body?.error?.code
|
|
@@ -652,207 +607,6 @@ async function packApp(appDir, output, { allowSource = false, allowSourceMaps =
|
|
|
652
607
|
return { ...info, outputPath }
|
|
653
608
|
}
|
|
654
609
|
|
|
655
|
-
async function loadDesktopReleaseConfig(options = []) {
|
|
656
|
-
const parsed = parseConfigOption(options)
|
|
657
|
-
const config = await loadReleaseConfig({
|
|
658
|
-
root: releaseConfigRoot(),
|
|
659
|
-
cwd: process.env.INIT_CWD || process.cwd(),
|
|
660
|
-
configPath: parsed.configPath,
|
|
661
|
-
envOverrides: parsed.envOverrides,
|
|
662
|
-
})
|
|
663
|
-
// desktop-build/desktop-pack are also used for local staging. A release
|
|
664
|
-
// config is optional there; the main app's build:release command
|
|
665
|
-
// requires update.url before producing a publishable package.
|
|
666
|
-
return validateReleaseConfig(config)
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
async function resolveDesktopSource() {
|
|
670
|
-
const cliRoot = dirname(fileURLToPath(import.meta.url))
|
|
671
|
-
const checkout = join(cliRoot, '..', 'desktop')
|
|
672
|
-
if (await exists(join(checkout, 'host'))) return checkout
|
|
673
|
-
let packageManifest
|
|
674
|
-
try {
|
|
675
|
-
packageManifest = createRequire(join(cliRoot, 'package.json')).resolve('@ducking-mind/proxy-app-desktop-runtime/package.json')
|
|
676
|
-
} catch {
|
|
677
|
-
throw new Error('Electron runtime package is not installed. Install @ducking-mind/proxy-app-desktop-runtime before building a desktop app.')
|
|
678
|
-
}
|
|
679
|
-
const runtime = join(dirname(packageManifest), 'runtime')
|
|
680
|
-
if (!(await exists(join(runtime, 'host')))) throw new Error('Electron runtime package is incomplete; install a published runtime package with its runtime assets.')
|
|
681
|
-
return runtime
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function findPackageRoot(name, fromDirectory) {
|
|
685
|
-
const require = createRequire(join(fromDirectory, 'package.json'))
|
|
686
|
-
let current = dirname(require.resolve(name))
|
|
687
|
-
while (true) {
|
|
688
|
-
const manifest = join(current, 'package.json')
|
|
689
|
-
if (existsSync(manifest)) {
|
|
690
|
-
try {
|
|
691
|
-
if (JSON.parse(readFileSync(manifest, 'utf8')).name === name) return current
|
|
692
|
-
} catch { /* Continue looking above the resolved entry. */ }
|
|
693
|
-
}
|
|
694
|
-
const parent = dirname(current)
|
|
695
|
-
if (parent === current) throw new Error(`cannot resolve installed package: ${name}`)
|
|
696
|
-
current = parent
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
async function desktopBuildApp(appDir, options = []) {
|
|
701
|
-
const releaseConfig = await loadDesktopReleaseConfig(options)
|
|
702
|
-
const info = await buildApp(appDir)
|
|
703
|
-
await assertSafeArtifactDirectory(info.artifactRoot)
|
|
704
|
-
const staging = join(info.root, 'dist-electron')
|
|
705
|
-
await rm(staging, { recursive: true, force: true })
|
|
706
|
-
await mkdir(join(staging, 'app'), { recursive: true })
|
|
707
|
-
await cp(info.artifactRoot, join(staging, 'app'), { recursive: true, dereference: true })
|
|
708
|
-
const desktopSource = await resolveDesktopSource()
|
|
709
|
-
await mkdir(join(staging, 'single-app'), { recursive: true })
|
|
710
|
-
await cp(join(desktopSource, 'single-app', 'main.mjs'), join(staging, 'single-app', 'main.mjs'))
|
|
711
|
-
await cp(join(desktopSource, 'single-app', 'preload.cjs'), join(staging, 'single-app', 'preload.cjs'))
|
|
712
|
-
await cp(join(desktopSource, 'unified', 'renderer', 'assets', 'duck-mark-v2.png'), join(staging, 'single-app', 'status-tray-icon.png'))
|
|
713
|
-
await cp(join(desktopSource, 'host'), join(staging, 'host'), { recursive: true })
|
|
714
|
-
await mkdir(join(staging, 'agent'), { recursive: true })
|
|
715
|
-
for (const name of ['protocol.mjs', 'runtime-status.mjs']) await cp(join(desktopSource, 'agent', name), join(staging, 'agent', name))
|
|
716
|
-
const desktopPackage = JSON.parse(await readFile(join(desktopSource, 'package.json'), 'utf8'))
|
|
717
|
-
const installedElectron = await readFile(join(info.root, 'node_modules', 'electron', 'package.json'), 'utf8').then(JSON.parse).catch(() =>
|
|
718
|
-
readFile(join(desktopSource, 'node_modules', 'electron', 'package.json'), 'utf8').then(JSON.parse).catch(() => null))
|
|
719
|
-
const electronVersion = installedElectron?.version || String(desktopPackage.devDependencies?.electron || '44.9.0').replace(/^[^0-9]*/, '')
|
|
720
|
-
// Standalone Electron apps use the same updater as the unified shell. Copy
|
|
721
|
-
// only its runtime dependency tree into staging; never ship the desktop
|
|
722
|
-
// project's full devDependencies or its Electron binary.
|
|
723
|
-
const runtimeDependencies = new Set()
|
|
724
|
-
async function copyRuntimeDependency(name, fromDir = desktopSource) {
|
|
725
|
-
if (runtimeDependencies.has(name)) return
|
|
726
|
-
let source
|
|
727
|
-
try { source = findPackageRoot(name, fromDir) }
|
|
728
|
-
catch { throw new Error(`桌面运行依赖缺失:${name}`) }
|
|
729
|
-
runtimeDependencies.add(name)
|
|
730
|
-
await cp(source, join(staging, 'node_modules', name), { recursive: true, dereference: true })
|
|
731
|
-
const packageJSON = JSON.parse(await readFile(join(source, 'package.json'), 'utf8'))
|
|
732
|
-
for (const dependency of Object.keys(packageJSON.dependencies || {})) await copyRuntimeDependency(dependency, source)
|
|
733
|
-
}
|
|
734
|
-
await copyRuntimeDependency('electron-updater')
|
|
735
|
-
const targetPlatform = desktopTargetPlatform(releaseConfig.buildTarget?.platform || process.platform)
|
|
736
|
-
const targetArch = desktopTargetArch(releaseConfig.buildTarget?.arch || process.arch)
|
|
737
|
-
if (targetPlatform === 'darwin' && targetArch === 'ia32') throw new Error('macOS 不支持 ia32 发布架构')
|
|
738
|
-
const updateURL = resolveDesktopFeedURL(releaseConfig.updateURL, targetPlatform, targetArch)
|
|
739
|
-
const updateChannel = releaseConfig.updateChannel
|
|
740
|
-
await writeFile(join(staging, 'update-config.json'), `${JSON.stringify({ url: updateURL, channel: updateChannel }, null, 2)}\n`)
|
|
741
|
-
const builder = { appId: `com.duckingmind.proxy.app.${info.manifest.id.replaceAll('.', '-')}`, productName: info.manifest.name, electronVersion, artifactName: '${productName}-${version}-${arch}.${ext}', directories: { output: 'release' }, files: ['single-app/**', 'host/**', 'agent/**', 'app/**', 'node_modules/**', 'package.json'], extraResources: [{ from: 'update-config.json', to: 'update-config.json' }], asar: true, mac: { target: ['dmg', 'zip'] }, win: { target: ['nsis'] }, linux: { target: ['AppImage'] } }
|
|
742
|
-
// setFeedURL alone is insufficient: electron-updater reads app-update.yml
|
|
743
|
-
// for cache/signing configuration when downloading. Generic publication
|
|
744
|
-
// makes electron-builder embed it while --publish never keeps uploads local.
|
|
745
|
-
builder.publish = updateURL ? [{ provider: 'generic', url: updateURL, channel: updateChannel }] : null
|
|
746
|
-
await writeFile(join(staging, 'electron-builder.json'), JSON.stringify(builder, null, 2) + '\n')
|
|
747
|
-
await writeFile(join(staging, 'package.json'), JSON.stringify({ name: info.manifest.id.replaceAll('.', '-'), version: info.manifest.version, description: info.manifest.description || info.manifest.name, author: 'Proxy', private: true, type: 'module', main: 'single-app/main.mjs', dependencies: { 'electron-updater': desktopPackage.dependencies?.['electron-updater'] || '^6.6.2' } }, null, 2) + '\n')
|
|
748
|
-
await writeFile(join(staging, 'desktop-manifest.json'), JSON.stringify({ app_id: info.manifest.id, version: info.manifest.version, name: info.manifest.name }, null, 2) + '\n')
|
|
749
|
-
console.log(JSON.stringify({ staging, app_id: info.manifest.id, version: info.manifest.version }, null, 2))
|
|
750
|
-
return { ...info, staging, releaseConfig, targetPlatform, targetArch }
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
async function desktopPackApp(appDir, output, options = []) {
|
|
754
|
-
const info = await desktopBuildApp(appDir, options)
|
|
755
|
-
const outputPath = resolve(output || join(info.root, 'dist-electron', 'release'))
|
|
756
|
-
await mkdir(outputPath, { recursive: true })
|
|
757
|
-
const configPath = join(info.staging, 'electron-builder.json')
|
|
758
|
-
const config = JSON.parse(await readFile(configPath, 'utf8'))
|
|
759
|
-
config.directories = { ...(config.directories || {}), output: outputPath }
|
|
760
|
-
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
761
|
-
const desktopRoot = await resolveDesktopSource()
|
|
762
|
-
const builderName = process.platform === 'win32' ? 'electron-builder.cmd' : 'electron-builder'
|
|
763
|
-
const commands = [join(info.root, 'node_modules', '.bin', builderName), join(desktopRoot, 'node_modules', '.bin', builderName)]
|
|
764
|
-
let builder = null
|
|
765
|
-
for (const candidate of commands) if (await exists(candidate)) { builder = candidate; break }
|
|
766
|
-
if (!builder) throw new Error('Electron 打包需要在当前应用依赖中安装 electron-builder。')
|
|
767
|
-
const targetPlatform = info.targetPlatform
|
|
768
|
-
const targetArch = info.targetArch
|
|
769
|
-
if (targetPlatform === 'darwin' && targetArch === 'ia32') throw new Error('macOS 不支持 ia32 发布架构')
|
|
770
|
-
await run(builder, ['--config', join(info.staging, 'electron-builder.json'), '--projectDir', info.staging, { darwin: '--mac', win32: '--win', linux: '--linux' }[targetPlatform], `--${targetArch}`, '--publish', 'never'], info.staging, process.env).catch((error) => {
|
|
771
|
-
throw new Error(`Electron 打包失败:${error.message}`)
|
|
772
|
-
})
|
|
773
|
-
const release = await writeDesktopReleaseRecord(info, outputPath, info.releaseConfig, targetPlatform, targetArch)
|
|
774
|
-
console.log(JSON.stringify({ output: outputPath, app_id: info.manifest.id, version: info.manifest.version, release }, null, 2))
|
|
775
|
-
return { ...info, outputPath, release }
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function desktopTargetPlatform(value = process.platform) {
|
|
779
|
-
if (value === 'darwin') return 'darwin'
|
|
780
|
-
if (value === 'win32') return 'win32'
|
|
781
|
-
if (value === 'linux') return 'linux'
|
|
782
|
-
throw new Error(`不支持的桌面发布平台:${value}`)
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function desktopTargetArch(value = process.arch) {
|
|
786
|
-
if (value === 'x64' || value === 'arm64') return value
|
|
787
|
-
if (value === 'ia32') return value
|
|
788
|
-
throw new Error(`不支持的桌面发布架构:${value}`)
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function resolveDesktopFeedURL(value, platform, arch) {
|
|
792
|
-
const raw = String(value || '').trim()
|
|
793
|
-
if (!raw) return ''
|
|
794
|
-
const resolved = raw.replaceAll('{platform}', platform).replaceAll('{arch}', arch).replace(/\/+$/, '')
|
|
795
|
-
let url
|
|
796
|
-
try { url = new URL(resolved) } catch { throw new Error('发布配置 update.url 必须是 HTTPS 或本机回环 HTTP 更新目录地址') }
|
|
797
|
-
if (!isAllowedReleaseURL(resolved)) throw new Error('发布配置 update.url 必须是无凭据、无查询参数的 HTTPS 或本机回环 HTTP 更新目录地址')
|
|
798
|
-
const explicitTarget = /\/(darwin|win32|linux)\/(arm64|x64|ia32)$/.exec(url.pathname)
|
|
799
|
-
if (explicitTarget && (explicitTarget[1] !== platform || explicitTarget[2] !== arch)) throw new Error(`更新地址目标与 ${platform}/${arch} 不一致,请使用 {platform}/{arch}`)
|
|
800
|
-
return resolved
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
/**
|
|
804
|
-
* When a standalone Electron app is built for distribution, emit the same
|
|
805
|
-
* importable record and generic-provider feed as the unified shell. Local
|
|
806
|
-
* builds use an upload placeholder that publish-release.mjs replaces.
|
|
807
|
-
*/
|
|
808
|
-
async function writeDesktopReleaseRecord(info, outputPath, releaseConfig, configuredPlatform, configuredArch) {
|
|
809
|
-
const artifactBase = String(releaseConfig.artifactBaseURL || '').trim().replace(/\/$/, '')
|
|
810
|
-
if (artifactBase && !artifactBase.startsWith('https://')) throw new Error('发布配置 release.artifactBaseUrl 必须使用 HTTPS')
|
|
811
|
-
const product = String(releaseConfig.product || info.manifest.id).trim()
|
|
812
|
-
const channel = releaseConfig.updateChannel
|
|
813
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(product)) throw new Error('发布配置 product 无效')
|
|
814
|
-
if (!['latest', 'beta'].includes(channel)) throw new Error('发布配置 update.channel 必须是 latest 或 beta')
|
|
815
|
-
const platform = desktopTargetPlatform(configuredPlatform)
|
|
816
|
-
const arch = desktopTargetArch(configuredArch)
|
|
817
|
-
if (platform === 'darwin' && arch === 'ia32') throw new Error('macOS 不支持 ia32 发布架构')
|
|
818
|
-
const targetExtension = platform === 'darwin' ? '.zip' : platform === 'win32' ? '.exe' : '.appimage'
|
|
819
|
-
const candidates = []
|
|
820
|
-
for (const entry of await readdir(outputPath, { withFileTypes: true })) {
|
|
821
|
-
if (!entry.isFile() || extname(entry.name).toLowerCase() !== targetExtension || !entry.name.includes(info.manifest.version)) continue
|
|
822
|
-
const archInName = /(?:^|[-_])(x64|arm64|ia32)(?:[-_.]|$)/i.exec(entry.name)?.[1]?.toLowerCase()
|
|
823
|
-
if (archInName && archInName !== arch) continue
|
|
824
|
-
const file = join(outputPath, entry.name)
|
|
825
|
-
const details = await stat(file)
|
|
826
|
-
if (details.size > 0) candidates.push({ file, name: entry.name, mtimeMs: details.mtimeMs })
|
|
827
|
-
}
|
|
828
|
-
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
829
|
-
const artifact = candidates[0]
|
|
830
|
-
if (!artifact) throw new Error(`未找到 ${platform}/${arch} 的 ${info.manifest.version} ${targetExtension} 发布产物`)
|
|
831
|
-
const data = await readFile(artifact.file)
|
|
832
|
-
const sha512 = createHash('sha512').update(data).digest('base64')
|
|
833
|
-
const artifactURL = artifactBase
|
|
834
|
-
? `${artifactBase}/${encodeURIComponent(basename(artifact.file))}`
|
|
835
|
-
: `https://upload.invalid/${encodeURIComponent(basename(artifact.file))}`
|
|
836
|
-
const release = {
|
|
837
|
-
product,
|
|
838
|
-
version: info.manifest.version,
|
|
839
|
-
channel,
|
|
840
|
-
platform,
|
|
841
|
-
arch,
|
|
842
|
-
artifact_url: artifactURL,
|
|
843
|
-
artifact_name: basename(artifact.file),
|
|
844
|
-
sha512,
|
|
845
|
-
size: data.length,
|
|
846
|
-
release_notes: String(releaseConfig.releaseNotes || '').trim(),
|
|
847
|
-
}
|
|
848
|
-
const metadataName = platform === 'darwin' ? `${channel}-mac.yml` : platform === 'linux' ? `${channel}-linux${arch === 'x64' ? '' : `-${arch}`}.yml` : `${channel}.yml`
|
|
849
|
-
const metadata = `version: ${release.version}\nfiles:\n - url: ${JSON.stringify(artifactURL)}\n sha512: ${sha512}\n size: ${data.length}\npath: ${JSON.stringify(release.artifact_name)}\nsha512: ${sha512}\nreleaseDate: ${new Date().toISOString()}\nreleaseNotes: ${JSON.stringify(release.release_notes)}\n`
|
|
850
|
-
await writeFile(join(outputPath, metadataName), metadata)
|
|
851
|
-
const recordPath = join(outputPath, 'desktop-release.json')
|
|
852
|
-
await writeFile(recordPath, `${JSON.stringify(release, null, 2)}\n`)
|
|
853
|
-
return { ...release, metadata: metadataName, record: recordPath }
|
|
854
|
-
}
|
|
855
|
-
|
|
856
610
|
function defaultAgentNamespace(identifier) {
|
|
857
611
|
return identifier.replace(/^app\./, '').replace(/[^a-z0-9]+/g, '_').slice(0, 48) || 'app'
|
|
858
612
|
}
|
|
@@ -919,9 +673,9 @@ async function writeProject(root, app) {
|
|
|
919
673
|
await writeFile(join(root, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', resolveJsonModule: true, strict: true, skipLibCheck: true, lib: ['ES2022', 'DOM', 'DOM.Iterable'], types: ['vite/client', 'node'], noEmit: true }, include: ['src', 'vite.config.ts'] }, null, 2) + '\n')
|
|
920
674
|
await writeFile(join(root, 'package.json'), JSON.stringify({ name: app.slug.replaceAll('.', '-'), private: true, type: 'module', scripts: { dev: 'proxy-app dev', 'dev:server': 'vite', typecheck: 'tsc --noEmit', build: 'npm run typecheck && vite build' }, dependencies: { '@ducking-mind/proxy-app-sdk': '^0.1.0' }, devDependencies: { [cliPackage.name]: `^${cliPackage.version}`, '@types/node': '^22.18.0', typescript: '^5.2.2', vite: '^8.2.2' }, engines: { node: '>=22.18' } }, null, 2) + '\n')
|
|
921
675
|
await writeFile(join(root, 'agent', 'guide.md'), `# ${app.name} 能力说明\n\n这是由 “proxy-app init” 生成的能力指南。外部 Codex 通过主应用 MCP 调用能力,应用本身不实现 Agent。\n\n当前提供 “proxy_${namespace}_read_metadata”,用于确认应用 ID、版本和名称。能力定义在 “src/agent-tools.mjs” 中维护,应用使用同一份定义注册处理器;构建命令自动生成发布契约,无需在 manifest 中重复声明能力。\n`)
|
|
922
|
-
await writeFile(join(root, 'agent', 'workflow.md'), `# 调用流程\n\n1. 先读取应用状态和本指南。\n2. 调用 “proxy_${namespace}_read_metadata” 确认当前版本。\n3. 新增写能力时,先读取业务 revision,并为每次新操作生成唯一 “idempotency_key”。\n4. Web
|
|
676
|
+
await writeFile(join(root, 'agent', 'workflow.md'), `# 调用流程\n\n1. 先读取应用状态和本指南。\n2. 调用 “proxy_${namespace}_read_metadata” 确认当前版本。\n3. 新增写能力时,先读取业务 revision,并为每次新操作生成唯一 “idempotency_key”。\n4. Web 和私有桌面宿主共用同一套 manifest;仅在能力确实支持时缩小 “targets”。桌面宿主源码和构建流程不属于公开 CLI。\n`)
|
|
923
677
|
await writeFile(join(root, 'agent', 'examples.json'), JSON.stringify([{ capability: 'read_metadata', input: {}, output: { appId: app.slug, version: '0.1.0', name: app.name } }], null, 2) + '\n')
|
|
924
|
-
await writeFile(join(root, 'README.md'), `# ${app.name}\n\n## 开发\n\n~~~bash\nnpm install\nnpx proxy-app create\nnpm run dev\n~~~\n\n已有平台应用时跳过 create,将 manifest.id 设为平台应用的公开 slug 后直接运行 npm run dev,无需 link 或 repair。启动时会先完成开发者授权与应用检查,再运行开发服务器,不要求手工配置用户 token。底层服务器命令配置在 package.json 的 dev:server 中。\n\n## 能力接入\n\n
|
|
678
|
+
await writeFile(join(root, 'README.md'), `# ${app.name}\n\n## 开发\n\n~~~bash\nnpm install\nnpx proxy-app create\nnpm run dev\n~~~\n\n已有平台应用时跳过 create,将 manifest.id 设为平台应用的公开 slug 后直接运行 npm run dev,无需 link 或 repair。启动时会先完成开发者授权与应用检查,再运行开发服务器,不要求手工配置用户 token。底层服务器命令配置在 package.json 的 dev:server 中。\n\n## 能力接入\n\n本项目生成 Web 目标,并保留供私有桌面宿主识别的 “desktop” 兼容目标;公开 CLI 只构建和发布 Web 应用。能力的名称、Schema 和读写属性只在 “src/agent-tools.mjs” 中维护,处理器注册使用同一份定义。源码 manifest 不再声明 Agent 配置;构建会从代码自动生成发布能力目录,请勿手工重复维护。新增能力后同步业务指南和示例,再运行:\n\n~~~bash\nnpx proxy-app validate\nnpx proxy-app pack\n~~~\n\n完整约定见仓库的 [应用开发指南](https://github.com/duckingmind/app/blob/main/docs/APP_DEVELOPMENT.md)。\n`)
|
|
925
679
|
}
|
|
926
680
|
|
|
927
681
|
async function replaceIdentifier(root, previous, next) {
|
|
@@ -1166,8 +920,6 @@ try {
|
|
|
1166
920
|
else if (command === 'pack') await packApp(appDir, output, { allowSource: options.includes('--source') })
|
|
1167
921
|
else if (command === 'upload') await uploadApp(appDir, output, { allowSource: options.includes('--source') })
|
|
1168
922
|
else if (command === 'publish') await publishApp(appDir, output, option(options, '--version'))
|
|
1169
|
-
else if (command === 'desktop-build') await desktopBuildApp(appDir, options)
|
|
1170
|
-
else if (command === 'desktop-pack') await desktopPackApp(appDir, output, options)
|
|
1171
923
|
}
|
|
1172
924
|
} catch (error) {
|
|
1173
925
|
console.error(`proxy app: ${error instanceof Error ? error.message : String(error)}`)
|
|
@@ -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
|
-
}
|