@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
package/proxy-app.mjs
CHANGED
|
@@ -1,51 +1,119 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import {
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
5
6
|
import { createHash, randomUUID } from 'node:crypto'
|
|
6
|
-
import {
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { createServer as createTCPServer } from 'node:net'
|
|
7
11
|
import { zipSync } from 'fflate'
|
|
8
12
|
import { createLocalDevelopmentRelay } from './local-dev-relay.mjs'
|
|
13
|
+
import { createLocalCredentialStore } from './local-dev-credentials.mjs'
|
|
14
|
+
import { assertSafeArtifactFile, assertSafeArtifactDirectory } from './artifact-security.mjs'
|
|
15
|
+
import { resolveAgentContract } from './agent-contract.mjs'
|
|
9
16
|
|
|
10
17
|
const MAX_BUNDLE_BYTES = 50 * 1024 * 1024
|
|
11
|
-
const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
|
|
12
|
-
const ALLOWED_APIS = new Set(['model.responses', 'model.chat', 'model.messages', 'model.embeddings', 'model.images', 'model.videos', 'model.music'])
|
|
13
|
-
const ALLOWED_PRICING_MODES = new Set(['
|
|
14
|
-
const ALLOWED_CATEGORIES = new Set(['
|
|
15
|
-
const
|
|
18
|
+
const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'platform.capabilities.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
|
|
19
|
+
const ALLOWED_APIS = new Set(['model.responses', 'model.chat', 'model.messages', 'model.embeddings', 'model.images', 'model.videos', 'model.music', 'model.voice_design', 'model.tts'])
|
|
20
|
+
const ALLOWED_PRICING_MODES = new Set(['commission'])
|
|
21
|
+
const ALLOWED_CATEGORIES = new Set(['ecommerce', 'comic', 'other'])
|
|
22
|
+
const RESERVED_AGENT_NAMESPACES = new Set(['app', 'codex', 'desktop', 'proxy', 'system'])
|
|
23
|
+
const BUNDLE_IGNORED_FILES = new Set(['.proxy-app.json', '.proxy-app-build'])
|
|
16
24
|
const APP_WORKSPACE_PATH = '/portal/user/app-workspace'
|
|
25
|
+
// Public slugs are used verbatim, including single-segment names such as
|
|
26
|
+
// "canvas". The platform's opaque app_id remains a separate compatibility
|
|
27
|
+
// format; accepting a slug must not reinterpret it as that durable identity.
|
|
28
|
+
const PLATFORM_APP_ID_PATTERN = /^app_[a-z0-9][a-z0-9._-]{2,119}$/
|
|
29
|
+
const PUBLIC_APP_SLUG_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/
|
|
30
|
+
const validPublicAppSlug = value => typeof value === 'string' && value.length <= 120 && value === value.trim() && PUBLIC_APP_SLUG_PATTERN.test(value)
|
|
31
|
+
const validAppIdentifier = value => typeof value === 'string' && value === value.trim() && (PLATFORM_APP_ID_PATTERN.test(value) || validPublicAppSlug(value))
|
|
32
|
+
const publicAppAliases = app => Array.isArray(app?.app_aliases) ? app.app_aliases.filter(validPublicAppSlug) : []
|
|
33
|
+
const matchesAppIdentity = (identifier, appID, slug, aliases = []) => identifier === appID
|
|
34
|
+
|| validPublicAppSlug(identifier) && (identifier === slug || aliases.includes(identifier))
|
|
35
|
+
const appBinding = app => ({
|
|
36
|
+
app_id: app.id, slug: app.slug,
|
|
37
|
+
...(publicAppAliases(app).length ? { app_aliases: publicAppAliases(app) } : {}),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// npm packages carry the same pure protocol modules as the desktop host.
|
|
41
|
+
// Source checkouts prefer the live implementation so validation cannot drift.
|
|
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))
|
|
17
58
|
|
|
18
59
|
function usage() {
|
|
19
|
-
console.log(
|
|
20
|
-
proxy-app
|
|
21
|
-
proxy-app
|
|
22
|
-
proxy-app
|
|
23
|
-
proxy-app
|
|
24
|
-
proxy-app
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
console.log(`在应用目录内,常用命令:
|
|
61
|
+
proxy-app dev 开发授权与检查通过后启动应用
|
|
62
|
+
proxy-app pack 重新构建 → 校验 → 生成 ZIP
|
|
63
|
+
proxy-app upload 重新构建、打包并上传待发布版本,不切换线上版本
|
|
64
|
+
proxy-app publish 手动发布当前 manifest 版本;缺少上传产物时先构建上传
|
|
65
|
+
proxy-app publish --version 0.1.0 切换到已上传版本,也可回滚
|
|
66
|
+
|
|
67
|
+
首次使用:proxy-app init <app-dir>,安装依赖后运行 proxy-app create。
|
|
68
|
+
应用开发使用 manifest.id 中的公开 slug;管理命令仍可使用 app_id。
|
|
69
|
+
pack 无需提前执行 build 或 validate;已上传的版本由 publish 直接推进发布。
|
|
70
|
+
|
|
71
|
+
完整命令(app-dir 默认当前目录):
|
|
72
|
+
proxy-app init [app-dir] [--name <name>]
|
|
73
|
+
proxy-app create [app-dir] [--name <name>] [--category other] [--description text]
|
|
74
|
+
proxy-app status [app-dir]
|
|
75
|
+
proxy-app doctor [app-dir]
|
|
76
|
+
proxy-app dev [app-dir] [--port <port>] [--no-auth-cache]
|
|
77
|
+
proxy-app validate [app-dir]
|
|
78
|
+
proxy-app build [app-dir]
|
|
79
|
+
proxy-app pack [app-dir] [-o <output.zip>]
|
|
80
|
+
proxy-app upload [app-dir] [-o <output.zip>]
|
|
81
|
+
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
|
+
|
|
85
|
+
upload 只上传;publish 显式切换线上版本。首次使用 publish 仍需申请应用开放。
|
|
86
|
+
--version 仅选择已上传版本,不构建、不上传、不修改本地 manifest。
|
|
87
|
+
-o 与 --output 等价,也兼容原来的 <app-dir> <output.zip> 写法。
|
|
88
|
+
--source 仅供 pack/upload 调试源码,跳过构建,不用于正式发布。
|
|
89
|
+
desktop-pack 会在输出目录生成 desktop-release.json 和 electron-updater metadata;发布配置
|
|
90
|
+
使用 desktop/release.config.json,API Key 只用于发布请求,不会写入安装包。
|
|
91
|
+
应用分类:ecommerce(电商)、comic(漫剧)、other(其他,默认)。
|
|
34
92
|
|
|
35
93
|
Publish environment:
|
|
94
|
+
PROXY_PLATFORM_URL Optional single-origin platform URL; derives API (/api) and login Portal
|
|
36
95
|
PROXY_API_BASE_URL default: http://localhost:9003/api
|
|
96
|
+
Optional override when the platform uses a separate API host
|
|
97
|
+
PROXY_PORTAL_URL Optional login Portal override; default: http://localhost:9004
|
|
37
98
|
PROXY_USER_ACCESS_TOKEN Current platform user session for app management/CI only; never passed to the app
|
|
38
99
|
PROXY_ACCESS_TOKEN Legacy alias for PROXY_USER_ACCESS_TOKEN
|
|
39
100
|
|
|
40
101
|
Local development:
|
|
41
102
|
proxy-app dev <app-dir> [--port 5173]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
103
|
+
If the requested port is occupied, the next available loopback port is used.
|
|
104
|
+
Checks the app binding and developer permission before starting the app.
|
|
105
|
+
First authorize the developer using the terminal login URL; then app users
|
|
106
|
+
sign in separately for their own App Session, models, quota and billing.
|
|
107
|
+
App business traffic always uses the App Session Gateway (/v1). The local
|
|
108
|
+
relay exposes only the authentication control path and never an Admin Runtime
|
|
109
|
+
or legacy Bridge endpoint.
|
|
110
|
+
macOS stores developer and app-user authorization separately in Keychain.
|
|
111
|
+
--no-auth-cache uses memory only. If a cached developer account cannot access
|
|
112
|
+
the app, dev automatically asks you to authorize with the app creator account.
|
|
46
113
|
|
|
47
114
|
Platform verification:
|
|
48
|
-
|
|
115
|
+
Publish builds and uploads new candidates before requesting application opening.
|
|
116
|
+
Once the application is open, later versions publish without another review.
|
|
49
117
|
`)
|
|
50
118
|
}
|
|
51
119
|
|
|
@@ -54,17 +122,119 @@ function option(args, name, fallback = '') {
|
|
|
54
122
|
return index >= 0 && args[index + 1] ? args[index + 1] : fallback
|
|
55
123
|
}
|
|
56
124
|
|
|
57
|
-
function
|
|
58
|
-
return
|
|
125
|
+
function reservePort(port) {
|
|
126
|
+
return new Promise((resolvePromise, reject) => {
|
|
127
|
+
const server = createTCPServer()
|
|
128
|
+
let settled = false
|
|
129
|
+
const settle = (callback, value) => {
|
|
130
|
+
if (settled) return
|
|
131
|
+
settled = true
|
|
132
|
+
callback(value)
|
|
133
|
+
}
|
|
134
|
+
server.once('error', (error) => {
|
|
135
|
+
if (error?.code === 'EADDRINUSE') settle(resolvePromise, null)
|
|
136
|
+
else settle(reject, error)
|
|
137
|
+
})
|
|
138
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
|
|
139
|
+
settle(resolvePromise, {
|
|
140
|
+
port,
|
|
141
|
+
release: () => {
|
|
142
|
+
if (!server.listening) return Promise.resolve()
|
|
143
|
+
return new Promise((resolveClose) => server.close(() => resolveClose()))
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function reserveAvailablePort(startPort) {
|
|
151
|
+
for (let port = startPort; port <= 65535; port += 1) {
|
|
152
|
+
const reservation = await reservePort(port)
|
|
153
|
+
if (reservation) return reservation
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`从端口 ${startPort} 开始没有可用的本地端口`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function commandArguments(command, args) {
|
|
159
|
+
const configurations = {
|
|
160
|
+
init: { values: ['--name'], max: 1 },
|
|
161
|
+
create: { values: ['--name', '--category', '--description'], max: 1 },
|
|
162
|
+
repair: { max: 2 }, link: { max: 2 }, status: { max: 1 }, doctor: { max: 1 },
|
|
163
|
+
validate: { max: 1 }, build: { max: 1 },
|
|
164
|
+
dev: { values: ['--port'], flags: ['--no-auth-cache'], max: 1 },
|
|
165
|
+
pack: { values: ['--output'], flags: ['--source'], max: 2 },
|
|
166
|
+
upload: { values: ['--output'], flags: ['--source'], max: 2 },
|
|
167
|
+
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
|
+
}
|
|
171
|
+
const config = configurations[command]
|
|
172
|
+
if (!config) throw new Error(`unknown command: ${command}`)
|
|
173
|
+
const positional = []
|
|
174
|
+
const options = []
|
|
175
|
+
const seen = new Set()
|
|
176
|
+
for (let i = 0; i < args.length; i++) {
|
|
177
|
+
const arg = args[i]
|
|
178
|
+
if (arg === '--') { positional.push(...args.slice(i + 1)); break }
|
|
179
|
+
if (!arg.startsWith('-')) { positional.push(arg); continue }
|
|
180
|
+
const key = arg === '-o' ? '--output' : arg
|
|
181
|
+
if (![...(config.values || []), ...(config.flags || [])].includes(key)) {
|
|
182
|
+
throw new Error(`unsupported ${command} option: ${arg}${command === 'dev' ? '; local development always uses the real platform' : ''}`)
|
|
183
|
+
}
|
|
184
|
+
if (seen.has(key)) throw new Error(`duplicate option: ${key}`)
|
|
185
|
+
seen.add(key)
|
|
186
|
+
options.push(key)
|
|
187
|
+
if (config.values?.includes(key)) {
|
|
188
|
+
if (args[i + 1] === undefined || args[i + 1].startsWith('-') || !args[i + 1].trim()) throw new Error(`missing value for ${arg}`)
|
|
189
|
+
options.push(args[++i])
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (positional.length > config.max) throw new Error(`too many arguments for ${command}`)
|
|
193
|
+
if (command === 'repair') {
|
|
194
|
+
if (!positional.length) return { appDir: '.', appID: '', options }
|
|
195
|
+
return { appDir: positional.length === 2 ? positional[0] : '.', appID: positional.at(-1), options }
|
|
196
|
+
}
|
|
197
|
+
if (command === 'link') {
|
|
198
|
+
if (!positional.length) return { appDir: '.', appID: '', options }
|
|
199
|
+
return { appDir: positional.length === 2 ? positional[0] : '.', appID: positional.at(-1), options }
|
|
200
|
+
}
|
|
201
|
+
const output = option(options, '--output') || positional[1]
|
|
202
|
+
if (seen.has('--output') && positional[1]) throw new Error('specify output once: use --output or the positional output path')
|
|
203
|
+
if (command === 'publish' && seen.has('--version')) {
|
|
204
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(option(options, '--version'))) throw new Error('--version must be a valid version such as 0.1.0')
|
|
205
|
+
if (output) throw new Error('--version selects an uploaded version without building; do not specify an output path')
|
|
206
|
+
}
|
|
207
|
+
return { appDir: positional[0] || '.', output, options }
|
|
59
208
|
}
|
|
60
209
|
|
|
61
210
|
function apiSettings() {
|
|
62
211
|
const token = process.env.PROXY_USER_ACCESS_TOKEN || process.env.PROXY_ACCESS_TOKEN
|
|
63
|
-
const baseURL = (
|
|
212
|
+
const baseURL = platformAPIBaseURL()
|
|
64
213
|
if (!token) throw new Error('PROXY_USER_ACCESS_TOKEN is required for app management commands; it is never passed to the app runtime')
|
|
65
214
|
return { token, baseURL }
|
|
66
215
|
}
|
|
67
216
|
|
|
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
|
+
|
|
68
238
|
function responseError(body, fallback) {
|
|
69
239
|
const message = typeof body?.error === 'string' ? body.error : body?.error?.message || body?.message
|
|
70
240
|
const code = body?.code || body?.error?.code
|
|
@@ -108,18 +278,43 @@ async function readManifest(appDir) {
|
|
|
108
278
|
}
|
|
109
279
|
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('manifest must be a JSON object')
|
|
110
280
|
if (manifest.schema !== 'proxy.app/v1') throw new Error('manifest.schema must be proxy.app/v1')
|
|
111
|
-
if (
|
|
281
|
+
if (!validAppIdentifier(manifest.id)) throw new Error('manifest.id is invalid; expected a 1–120 character lowercase slug or platform app_id')
|
|
112
282
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.version || '')) throw new Error('manifest.version is invalid')
|
|
113
283
|
if (typeof manifest.name !== 'string' || !manifest.name.trim()) throw new Error('manifest.name is required')
|
|
114
|
-
if (manifest.
|
|
115
|
-
|
|
116
|
-
|
|
284
|
+
if (manifest.backend !== undefined && (!manifest.backend || typeof manifest.backend !== 'object' || Array.isArray(manifest.backend))) {
|
|
285
|
+
throw new Error('manifest.backend must be an object')
|
|
286
|
+
}
|
|
287
|
+
// App traffic always goes through the platform gateway. Treating backend as
|
|
288
|
+
// optional keeps the manifest focused on app-specific capabilities while
|
|
289
|
+
// preserving an explicit rejection for unsupported backends.
|
|
290
|
+
if ((manifest.backend?.type || 'platform') !== 'platform') throw new Error('manifest.backend.type must be platform')
|
|
291
|
+
// Permissions are optional metadata. An omitted list means the app accepts
|
|
292
|
+
// the platform's currently supported public capabilities; an explicit list
|
|
293
|
+
// remains available for legacy packages that intentionally narrow access.
|
|
294
|
+
if (manifest.permissions !== undefined && !Array.isArray(manifest.permissions)) throw new Error('manifest.permissions must be an array')
|
|
117
295
|
const permissions = new Set()
|
|
118
|
-
for (const permission of manifest.permissions) {
|
|
296
|
+
for (const permission of manifest.permissions || []) {
|
|
119
297
|
if (!ALLOWED_PERMISSIONS.has(permission)) throw new Error(`unsupported app permission: ${permission}`)
|
|
120
298
|
if (permissions.has(permission)) throw new Error(`duplicate app permission: ${permission}`)
|
|
121
299
|
permissions.add(permission)
|
|
122
300
|
}
|
|
301
|
+
const sourceManifest = structuredClone(manifest)
|
|
302
|
+
if (manifest.agent !== undefined && (!manifest.agent || typeof manifest.agent !== 'object' || Array.isArray(manifest.agent))) throw new Error('manifest.agent must be an object')
|
|
303
|
+
if (manifest.agent?.enabled !== undefined && typeof manifest.agent.enabled !== 'boolean') throw new Error('manifest.agent.enabled must be boolean')
|
|
304
|
+
manifest = await resolveAgentContract(appDir, manifest)
|
|
305
|
+
if (manifest.agent !== undefined) {
|
|
306
|
+
if (!manifest.agent || typeof manifest.agent !== 'object' || Array.isArray(manifest.agent)) throw new Error('manifest.agent must be an object')
|
|
307
|
+
if (manifest.agent.enabled !== undefined && typeof manifest.agent.enabled !== 'boolean') throw new Error('manifest.agent.enabled must be boolean')
|
|
308
|
+
try {
|
|
309
|
+
const agent = normalizeManifestAgent(manifest)
|
|
310
|
+
if (agent) {
|
|
311
|
+
if (RESERVED_AGENT_NAMESPACES.has(agent.namespace)) throw new Error(`reserved agent namespace: ${agent.namespace}`)
|
|
312
|
+
if (!Array.isArray(manifest.agent.capabilities)) throw new Error('manifest.agent.capabilities must be an array')
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw new Error(`invalid manifest.agent: ${error.message}`)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
123
318
|
if (manifest.apis !== undefined && !Array.isArray(manifest.apis)) throw new Error('manifest.apis must be an array')
|
|
124
319
|
const apis = new Set()
|
|
125
320
|
for (const api of manifest.apis || []) {
|
|
@@ -130,24 +325,34 @@ async function readManifest(appDir) {
|
|
|
130
325
|
if (manifest.pricing !== undefined && (!manifest.pricing || typeof manifest.pricing !== 'object' || Array.isArray(manifest.pricing))) {
|
|
131
326
|
throw new Error('manifest.pricing must be an object')
|
|
132
327
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
328
|
+
if (manifest.pricing !== undefined) {
|
|
329
|
+
const pricing = manifest.pricing
|
|
330
|
+
const mode = pricing.mode
|
|
331
|
+
const oneTimePrice = pricing.one_time_price || 0
|
|
332
|
+
const commissionRate = pricing.commission_rate || 0
|
|
333
|
+
if (mode === 'free') throw new Error('free app pricing is no longer supported; configure commission pricing on the platform')
|
|
334
|
+
if (mode === 'one_time') throw new Error('one-time app pricing is no longer supported; configure commission pricing on the platform')
|
|
335
|
+
if (!ALLOWED_PRICING_MODES.has(mode)) throw new Error('manifest.pricing.mode must be commission')
|
|
336
|
+
if (!/^[A-Z]{3,10}$/.test(pricing.currency || 'USD')) throw new Error('manifest.pricing.currency is invalid')
|
|
337
|
+
if (!Number.isFinite(oneTimePrice) || !Number.isFinite(commissionRate) || oneTimePrice < 0 || commissionRate < 0) throw new Error('manifest.pricing values must be non-negative numbers')
|
|
338
|
+
if (commissionRate <= 0 || commissionRate > 100 || oneTimePrice !== 0) throw new Error('commission pricing requires commission_rate between 0 and 100 and zero one_time_price')
|
|
339
|
+
}
|
|
340
|
+
// Every application uses the same top-level page. Hosting mode and desktop
|
|
341
|
+
// support are platform policy rather than app manifest configuration. Keep
|
|
342
|
+
// accepting those fields when reading older manifests; they are ignored.
|
|
343
|
+
const entry = 'index.html'
|
|
344
|
+
return { manifest, sourceManifest, entry }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function validateApp(appDir, { checkBuildManifest = true, source = false } = {}) {
|
|
148
348
|
const root = resolve(appDir)
|
|
149
349
|
const { manifest, entry } = await readManifest(root)
|
|
150
|
-
|
|
350
|
+
// The package identity is carried by manifest.id. A legacy sidecar may be
|
|
351
|
+
// present, but it is deliberately ignored here: local validation must not
|
|
352
|
+
// turn the removed link/repair workflow into an upload prerequisite. The
|
|
353
|
+
// server performs the authoritative identity check against the app selected
|
|
354
|
+
// by the management API before accepting a version.
|
|
355
|
+
const artifactRoot = !source && (await exists(join(root, 'dist'))) ? join(root, 'dist') : root
|
|
151
356
|
if (!(await exists(join(artifactRoot, entry)))) throw new Error(`runtime entry is missing: ${entry}`)
|
|
152
357
|
if (artifactRoot !== root && /(?:src|href)=["']\//i.test(await readFile(join(artifactRoot, entry), 'utf8'))) {
|
|
153
358
|
throw new Error("built entry contains root-relative assets; configure the bundler to emit relative URLs (Vite: base: './')")
|
|
@@ -155,17 +360,94 @@ async function validateApp(appDir) {
|
|
|
155
360
|
if (manifest.assets?.icon && !(await exists(join(artifactRoot, safeRelative(manifest.assets.icon))))) {
|
|
156
361
|
console.warn(`warning: manifest.assets.icon is missing: ${manifest.assets.icon}`)
|
|
157
362
|
}
|
|
363
|
+
if (checkBuildManifest && artifactRoot !== root && await exists(join(artifactRoot, 'manifest.json'))) {
|
|
364
|
+
const builtManifest = JSON.parse(await readFile(join(artifactRoot, 'manifest.json'), 'utf8'))
|
|
365
|
+
if (JSON.stringify(builtManifest) !== JSON.stringify(manifest)) {
|
|
366
|
+
throw new Error('构建产物与当前 manifest 不一致,请运行 proxy-app pack 重新构建打包。')
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
await loadAgentPackage(artifactRoot, manifest)
|
|
158
370
|
return { root, artifactRoot, manifest, entry }
|
|
159
371
|
}
|
|
160
372
|
|
|
161
|
-
function run(command, args, cwd) {
|
|
373
|
+
function run(command, args, cwd, env = process.env) {
|
|
162
374
|
return new Promise((resolvePromise, reject) => {
|
|
163
|
-
const child = spawn(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
|
|
164
|
-
|
|
165
|
-
|
|
375
|
+
const child = spawn(command, args, { cwd, env, stdio: 'inherit', detached: process.platform !== 'win32', shell: process.platform === 'win32' })
|
|
376
|
+
let canceled = false
|
|
377
|
+
const stop = (signal) => {
|
|
378
|
+
canceled = true
|
|
379
|
+
try {
|
|
380
|
+
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal)
|
|
381
|
+
else child.kill(signal)
|
|
382
|
+
} catch { /* The build may already have exited. */ }
|
|
383
|
+
}
|
|
384
|
+
const interrupt = () => stop('SIGINT')
|
|
385
|
+
const terminate = () => stop('SIGTERM')
|
|
386
|
+
const cleanup = () => {
|
|
387
|
+
process.removeListener('SIGINT', interrupt)
|
|
388
|
+
process.removeListener('SIGTERM', terminate)
|
|
389
|
+
}
|
|
390
|
+
process.once('SIGINT', interrupt)
|
|
391
|
+
process.once('SIGTERM', terminate)
|
|
392
|
+
child.on('error', (error) => { cleanup(); reject(error) })
|
|
393
|
+
child.on('close', (code, signal) => {
|
|
394
|
+
cleanup()
|
|
395
|
+
if (code === 0 && !canceled) resolvePromise()
|
|
396
|
+
else reject(new Error(`${command} exited with ${code ?? signal}${canceled ? ' (构建已取消)' : ''}`))
|
|
397
|
+
})
|
|
166
398
|
})
|
|
167
399
|
}
|
|
168
400
|
|
|
401
|
+
async function buildApp(appDir) {
|
|
402
|
+
const root = resolve(appDir)
|
|
403
|
+
if (process.env.PROXY_APP_BUILD_ACTIVE === root) throw new Error('package.json 的 build 脚本不能调用 proxy-app build/pack/upload/publish,请直接调用 Vite 等构建工具。')
|
|
404
|
+
const before = await readManifest(root)
|
|
405
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
406
|
+
if (!pkg.scripts?.build) throw new Error('package.json 缺少 build 脚本,无法重新构建应用。')
|
|
407
|
+
const staging = join(root, '.proxy-app-build')
|
|
408
|
+
const dist = join(root, 'dist')
|
|
409
|
+
try { await mkdir(staging) } catch (error) {
|
|
410
|
+
if (error.code === 'EEXIST') throw new Error('该应用正在构建(.proxy-app-build)。若上次进程异常退出,请先检查此目录内保留的旧 dist,再清理目录重试。')
|
|
411
|
+
throw error
|
|
412
|
+
}
|
|
413
|
+
let previousDist = false
|
|
414
|
+
let started = false
|
|
415
|
+
let preserveBackup = false
|
|
416
|
+
try {
|
|
417
|
+
if (await exists(dist)) {
|
|
418
|
+
await rename(dist, join(staging, 'dist'))
|
|
419
|
+
previousDist = true
|
|
420
|
+
}
|
|
421
|
+
started = true
|
|
422
|
+
console.log('正在重新构建应用…')
|
|
423
|
+
await run('npm', ['run', 'build'], root, { ...process.env, PROXY_APP_BUILD_ACTIVE: root })
|
|
424
|
+
const outputInfo = await lstat(dist).catch((error) => { if (error.code !== 'ENOENT') throw error; return null })
|
|
425
|
+
if (!outputInfo?.isDirectory() || outputInfo.isSymbolicLink()) throw new Error('build output is missing or invalid: build 脚本必须生成真实的 dist 目录。')
|
|
426
|
+
await copyAgentPackage(root, dist, before.manifest)
|
|
427
|
+
const info = await validateApp(root, { checkBuildManifest: false })
|
|
428
|
+
if (info.artifactRoot === root) throw new Error('build output is missing: build 脚本必须生成 dist 目录,不能把源码作为发布包。')
|
|
429
|
+
if (JSON.stringify(before.manifest) !== JSON.stringify(info.manifest)) {
|
|
430
|
+
throw new Error('manifest 或代码能力契约在构建期间发生变更,请重新运行 proxy-app pack,避免配置与资源版本不一致。')
|
|
431
|
+
}
|
|
432
|
+
// Stamp only newly generated output, never a successful no-op over old dist.
|
|
433
|
+
await writeFile(join(info.artifactRoot, 'manifest.json'), JSON.stringify(info.manifest, null, 2) + '\n')
|
|
434
|
+
return info
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (started) {
|
|
437
|
+
try {
|
|
438
|
+
await rm(dist, { recursive: true, force: true })
|
|
439
|
+
if (previousDist) await rename(join(staging, 'dist'), dist)
|
|
440
|
+
} catch {
|
|
441
|
+
preserveBackup = true
|
|
442
|
+
throw new Error(`${error.message}\n旧构建保留在 ${staging},请检查后恢复。`)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
throw error
|
|
446
|
+
} finally {
|
|
447
|
+
if (!preserveBackup) await rm(staging, { recursive: true, force: true })
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
169
451
|
function runChild(command, args, cwd, env) {
|
|
170
452
|
return new Promise((resolvePromise, reject) => {
|
|
171
453
|
const child = spawn(command, args, { cwd, env, stdio: 'inherit', shell: process.platform === 'win32' })
|
|
@@ -184,38 +466,76 @@ function runChild(command, args, cwd, env) {
|
|
|
184
466
|
|
|
185
467
|
async function devApp(appDir, args) {
|
|
186
468
|
const root = resolve(appDir)
|
|
469
|
+
if (process.env.PROXY_APP_DEV_ACTIVE === root) {
|
|
470
|
+
throw new Error('package.json 的 dev:server 脚本必须直接调用 Vite 等开发服务器,不能调用 proxy-app dev 或 npm run dev。')
|
|
471
|
+
}
|
|
187
472
|
const { manifest } = await readManifest(root)
|
|
188
|
-
const supportedOptions = new Set(['--port', '--
|
|
473
|
+
const supportedOptions = new Set(['--port', '--no-auth-cache'])
|
|
189
474
|
for (const arg of args) {
|
|
190
475
|
if (arg.startsWith('--') && !supportedOptions.has(arg)) {
|
|
191
476
|
throw new Error(`unsupported dev option: ${arg}; local development always uses the real platform`)
|
|
192
477
|
}
|
|
193
478
|
}
|
|
194
|
-
|
|
195
|
-
|
|
479
|
+
// Development starts with the public manifest identity. Owner-authorized
|
|
480
|
+
// registration resolves the stable ID in memory before creating sessions.
|
|
481
|
+
const binding = { app_id: manifest.id, slug: manifest.id }
|
|
482
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
|
|
483
|
+
const devScript = ['dev:server', 'dev'].find(name => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim())
|
|
484
|
+
if (!devScript) throw new Error('package.json 缺少 dev:server 脚本,请配置 Vite 等开发服务器启动命令。')
|
|
196
485
|
const requestedPort = option(args, '--port', process.env.PROXY_APP_DEV_PORT || '5173')
|
|
197
486
|
if (!/^\d+$/.test(requestedPort) || Number(requestedPort) < 1 || Number(requestedPort) > 65535) throw new Error('--port must be between 1 and 65535')
|
|
198
|
-
const
|
|
487
|
+
const configuredEntryURL = process.env.PROXY_APP_DEV_URL?.trim() || ''
|
|
488
|
+
let portReservation = configuredEntryURL ? null : await reserveAvailablePort(Number(requestedPort))
|
|
489
|
+
const selectedPort = portReservation?.port || Number(requestedPort)
|
|
490
|
+
if (!configuredEntryURL && selectedPort !== Number(requestedPort)) {
|
|
491
|
+
console.warn(`端口 ${requestedPort} 已被占用,改用 ${selectedPort}。`)
|
|
492
|
+
}
|
|
493
|
+
const entryURL = configuredEntryURL || `http://127.0.0.1:${selectedPort}/`
|
|
199
494
|
let parsedEntryURL
|
|
200
495
|
try { parsedEntryURL = new URL(entryURL) } catch { throw new Error('PROXY_APP_DEV_URL must be a valid loopback http(s) URL') }
|
|
201
496
|
if (!['http:', 'https:'].includes(parsedEntryURL.protocol) || !['localhost', '127.0.0.1', '::1'].includes(parsedEntryURL.hostname) || parsedEntryURL.username || parsedEntryURL.password) {
|
|
202
497
|
throw new Error('PROXY_APP_DEV_URL must use localhost, 127.0.0.1, or ::1 without credentials')
|
|
203
498
|
}
|
|
204
499
|
const relaySecret = randomUUID()
|
|
205
|
-
const baseURL = (
|
|
206
|
-
const portalURL =
|
|
207
|
-
const
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
relaySecret,
|
|
212
|
-
appID: binding.app_id,
|
|
213
|
-
appSlug: manifest.id,
|
|
214
|
-
version: manifest.version,
|
|
215
|
-
manifest,
|
|
216
|
-
entryURL,
|
|
217
|
-
legacyBridge,
|
|
500
|
+
const baseURL = platformAPIBaseURL()
|
|
501
|
+
const portalURL = platformPortalURL()
|
|
502
|
+
const storeSettings = { baseURL, portalURL, appID: binding.app_id, projectPath: root }
|
|
503
|
+
const credentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore(storeSettings)
|
|
504
|
+
const developerCredentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore({
|
|
505
|
+
...storeSettings, purpose: 'development',
|
|
218
506
|
})
|
|
507
|
+
const cachedOperations = (store) => {
|
|
508
|
+
if (!store) return null
|
|
509
|
+
let fallbackReported = store.kind !== 'keychain'
|
|
510
|
+
const operation = (method) => async (...values) => {
|
|
511
|
+
try { return await store[method](...values) }
|
|
512
|
+
finally {
|
|
513
|
+
if (!fallbackReported && store.kind === 'memory') {
|
|
514
|
+
fallbackReported = true
|
|
515
|
+
console.warn(store.reason)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { load: operation('load'), save: operation('save'), clear: operation('clear') }
|
|
520
|
+
}
|
|
521
|
+
let relay
|
|
522
|
+
try {
|
|
523
|
+
relay = await createLocalDevelopmentRelay({
|
|
524
|
+
baseURL,
|
|
525
|
+
portalURL,
|
|
526
|
+
relaySecret,
|
|
527
|
+
appID: binding.app_id,
|
|
528
|
+
appSlug: manifest.id,
|
|
529
|
+
version: manifest.version,
|
|
530
|
+
manifest,
|
|
531
|
+
entryURL,
|
|
532
|
+
credentialStore: cachedOperations(credentialStore),
|
|
533
|
+
developerCredentialStore: cachedOperations(developerCredentialStore),
|
|
534
|
+
})
|
|
535
|
+
} catch (error) {
|
|
536
|
+
await portReservation?.release()
|
|
537
|
+
throw error
|
|
538
|
+
}
|
|
219
539
|
const {
|
|
220
540
|
PROXY_USER_ACCESS_TOKEN: _userToken,
|
|
221
541
|
PROXY_ACCESS_TOKEN: _legacyToken,
|
|
@@ -225,35 +545,81 @@ async function devApp(appDir, args) {
|
|
|
225
545
|
PROXY_APP_DEV_URL: _entryURL,
|
|
226
546
|
PROXY_APP_DEV_PORT: _devPort,
|
|
227
547
|
PROXY_PLATFORM_GATEWAY_URL: _gatewayURL,
|
|
228
|
-
PROXY_PLATFORM_RUNTIME_URL: _runtimeURL,
|
|
229
|
-
PROXY_PLATFORM_BRIDGE_URL: _bridgeURL,
|
|
230
548
|
PROXY_PLATFORM_AUTH_URL: _authURL,
|
|
231
549
|
PROXY_PLATFORM_RELAY_SECRET: _inheritedRelaySecret,
|
|
232
550
|
...safeChildEnv
|
|
233
551
|
} = process.env
|
|
234
552
|
const childEnv = {
|
|
235
553
|
...safeChildEnv,
|
|
554
|
+
PROXY_APP_DEV_ACTIVE: root,
|
|
236
555
|
PROXY_PLATFORM_GATEWAY_URL: `${relay.relayURL}/v1`,
|
|
237
|
-
...(!legacyBridge ? { PROXY_PLATFORM_RUNTIME_URL: `${relay.relayURL}/api/app-runtime/v1` } : { PROXY_PLATFORM_BRIDGE_URL: `${relay.relayURL}/bridge` }),
|
|
238
556
|
PROXY_PLATFORM_AUTH_URL: `${relay.relayURL}/auth`,
|
|
239
557
|
PROXY_PLATFORM_RELAY_SECRET: relaySecret,
|
|
240
558
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
559
|
+
const preparation = new AbortController()
|
|
560
|
+
let canceledSignal = ''
|
|
561
|
+
const cancel = (signal) => { canceledSignal = signal; preparation.abort() }
|
|
562
|
+
const interrupt = () => cancel('SIGINT')
|
|
563
|
+
const terminate = () => cancel('SIGTERM')
|
|
564
|
+
const removePreparationListeners = () => {
|
|
565
|
+
process.removeListener('SIGINT', interrupt)
|
|
566
|
+
process.removeListener('SIGTERM', terminate)
|
|
567
|
+
}
|
|
568
|
+
process.once('SIGINT', interrupt)
|
|
569
|
+
process.once('SIGTERM', terminate)
|
|
245
570
|
try {
|
|
246
|
-
|
|
571
|
+
console.log(`正在检查开发应用:${manifest.id}@${manifest.version}`)
|
|
572
|
+
console.log(`平台 API 地址:${baseURL}`)
|
|
573
|
+
console.log(`开发授权缓存:${developerCredentialStore?.kind === 'keychain' ? 'macOS 钥匙串,与应用用户登录独立保存' : '仅当前进程内存'}`)
|
|
574
|
+
await relay.prepareDevelopment({
|
|
575
|
+
token: _userToken || _legacyToken || '',
|
|
576
|
+
signal: preparation.signal,
|
|
577
|
+
onAuthorizationRequired: ({ login_url }) => {
|
|
578
|
+
console.log('开发环境尚未授权,或已有开发授权已失效。请使用应用创建者账户打开以下链接:')
|
|
579
|
+
console.log('开发者授权入口:')
|
|
580
|
+
console.log(login_url)
|
|
581
|
+
console.log('完成开发者授权并通过应用登记检查后才会启动本地应用。按 Ctrl+C 取消。')
|
|
582
|
+
},
|
|
583
|
+
})
|
|
584
|
+
preparation.signal.throwIfAborted()
|
|
585
|
+
removePreparationListeners()
|
|
586
|
+
console.log('开发应用检查通过。应用用户将在应用内独立登录。')
|
|
587
|
+
console.log(`Proxy App platform relay ready: ${relay.relayURL}`)
|
|
588
|
+
console.log(`Local app: ${entryURL}`)
|
|
589
|
+
console.log('App transport: App Session Gateway (/v1); local relay auth is /__platform/auth.')
|
|
590
|
+
console.log('Platform mode: sign in from the app to use real models, provider routing, quota and billing.')
|
|
591
|
+
await portReservation?.release()
|
|
592
|
+
portReservation = null
|
|
593
|
+
await runChild('npm', ['run', devScript, '--', '--host', '127.0.0.1', '--port', String(selectedPort), '--strictPort'], root, childEnv)
|
|
594
|
+
} catch (error) {
|
|
595
|
+
if (!canceledSignal) {
|
|
596
|
+
const guidance = []
|
|
597
|
+
if (error?.stage === 'app_registration') {
|
|
598
|
+
if ([403, 404].includes(error.statusCode)) {
|
|
599
|
+
guidance.push('请核对 manifest.json 中的 id(应用 slug)与上述平台地址,确认应用已在该平台创建。开发不需要绑定或 repair。')
|
|
600
|
+
guidance.push('请使用应用创建者账户完成开发授权,并确认终端显示的平台地址与应用所在平台一致。')
|
|
601
|
+
guidance.push('如果设置了 PROXY_USER_ACCESS_TOKEN,请确认它属于应用创建者;旧账户令牌请清除或取消设置后重新运行普通 proxy-app dev。')
|
|
602
|
+
} else if (error.statusCode === 401) {
|
|
603
|
+
guidance.push('请确认 PROXY_USER_ACCESS_TOKEN 属于应用创建者;如果该变量是旧账户令牌,请清除或取消设置后重新运行普通 proxy-app dev。')
|
|
604
|
+
}
|
|
605
|
+
if (typeof error.requestId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(error.requestId)) {
|
|
606
|
+
guidance.push(`请求编号:${error.requestId}`)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (guidance.length) error.message += '\n' + guidance.join('\n')
|
|
610
|
+
throw error
|
|
611
|
+
}
|
|
612
|
+
console.error('已取消开发环境初始化,本地应用未启动。')
|
|
613
|
+
process.exitCode = canceledSignal === 'SIGINT' ? 130 : 143
|
|
247
614
|
} finally {
|
|
615
|
+
removePreparationListeners()
|
|
616
|
+
await portReservation?.release()
|
|
248
617
|
await relay.close()
|
|
249
618
|
}
|
|
250
619
|
}
|
|
251
620
|
|
|
252
|
-
async function packApp(appDir, output, { allowSource = false } = {}) {
|
|
253
|
-
const info = await validateApp(appDir)
|
|
254
|
-
if (info.artifactRoot === info.root && !allowSource) {
|
|
255
|
-
throw new Error('build output is missing; run proxy-app build before packing (use --source only for local debugging)')
|
|
256
|
-
}
|
|
621
|
+
async function packApp(appDir, output, { allowSource = false, allowSourceMaps = allowSource } = {}) {
|
|
622
|
+
const info = allowSource ? await validateApp(appDir, { source: true }) : await buildApp(appDir)
|
|
257
623
|
const outputPath = resolve(output || join(info.root, `${info.manifest.id}-${info.manifest.version}.zip`))
|
|
258
624
|
const files = {}
|
|
259
625
|
const collect = async (directory, prefix = '') => {
|
|
@@ -271,7 +637,13 @@ async function packApp(appDir, output, { allowSource = false } = {}) {
|
|
|
271
637
|
}
|
|
272
638
|
if (info.artifactRoot === info.root) await collect(info.root)
|
|
273
639
|
else await collect(info.artifactRoot)
|
|
274
|
-
|
|
640
|
+
if (JSON.stringify((await readManifest(info.root)).manifest) !== JSON.stringify(info.manifest)) {
|
|
641
|
+
throw new Error('manifest 或代码能力契约在打包期间发生变更,请重新运行 proxy-app pack。')
|
|
642
|
+
}
|
|
643
|
+
files['manifest.json'] = new TextEncoder().encode(JSON.stringify(info.manifest, null, 2) + '\n')
|
|
644
|
+
for (const [filePath, data] of Object.entries(files)) {
|
|
645
|
+
assertSafeArtifactFile(filePath, data, { allowSourceMaps })
|
|
646
|
+
}
|
|
275
647
|
const bundle = zipSync(files, { level: 9 })
|
|
276
648
|
if (bundle.length > MAX_BUNDLE_BYTES) throw new Error(`bundle is larger than ${MAX_BUNDLE_BYTES} bytes`)
|
|
277
649
|
await mkdir(dirname(outputPath), { recursive: true })
|
|
@@ -280,14 +652,276 @@ async function packApp(appDir, output, { allowSource = false } = {}) {
|
|
|
280
652
|
return { ...info, outputPath }
|
|
281
653
|
}
|
|
282
654
|
|
|
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
|
+
function defaultAgentNamespace(identifier) {
|
|
857
|
+
return identifier.replace(/^app\./, '').replace(/[^a-z0-9]+/g, '_').slice(0, 48) || 'app'
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function replaceGeneratedNamespace(root, previous, next) {
|
|
861
|
+
if (!previous || previous === next) return
|
|
862
|
+
for (const relative of ['agent/guide.md', 'agent/workflow.md', 'README.md', 'src/agent-tools.mjs']) {
|
|
863
|
+
const target = join(root, relative)
|
|
864
|
+
const source = await readFile(target, 'utf8').catch(() => null)
|
|
865
|
+
if (source !== null) {
|
|
866
|
+
const replaced = source.replaceAll(`proxy_${previous}_`, `proxy_${next}_`)
|
|
867
|
+
.replaceAll(`"namespace": "${previous}"`, `"namespace": "${next}"`)
|
|
868
|
+
.replaceAll(`namespace: ${JSON.stringify(previous)}`, `namespace: ${JSON.stringify(next)}`)
|
|
869
|
+
.replaceAll(`'namespace': '${previous}'`, `'namespace': '${next}'`)
|
|
870
|
+
.replaceAll(`namespace: '${previous}'`, `namespace: '${next}'`)
|
|
871
|
+
if (replaced !== source) await writeFile(target, replaced)
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function replaceGeneratedIdentifier(root, previous, next) {
|
|
877
|
+
if (!previous || previous === next) return
|
|
878
|
+
const target = join(root, 'agent', 'examples.json')
|
|
879
|
+
const source = await readFile(target, 'utf8').catch(() => null)
|
|
880
|
+
if (source !== null) await writeFile(target, source.replaceAll(previous, next))
|
|
881
|
+
}
|
|
882
|
+
|
|
283
883
|
async function writeProject(root, app) {
|
|
884
|
+
const cliPackage = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'))
|
|
284
885
|
await mkdir(join(root, 'src'), { recursive: true })
|
|
285
|
-
await
|
|
886
|
+
await mkdir(join(root, 'agent'), { recursive: true })
|
|
887
|
+
const namespace = defaultAgentNamespace(app.slug)
|
|
888
|
+
const manifest = {
|
|
889
|
+
schema: 'proxy.app/v1', id: app.slug, version: '0.1.0', name: app.name, description: app.description || '',
|
|
890
|
+
}
|
|
891
|
+
await writeFile(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
|
|
286
892
|
await writeFile(join(root, 'index.html'), '<!doctype html>\n<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Proxy App</title></head><body><main id="app"></main><script type="module" src="/src/main.ts"></script></body></html>\n')
|
|
287
|
-
await writeFile(join(root, 'src', '
|
|
893
|
+
await writeFile(join(root, 'src', 'agent-tools.mjs'), [
|
|
894
|
+
'// This module is shared by the application and the package builder.',
|
|
895
|
+
'// Keep it independent of browser state and expose only JSON-serializable metadata.',
|
|
896
|
+
'export const agentMetadata = Object.freeze({',
|
|
897
|
+
' namespace: ' + JSON.stringify(namespace) + ", targets: ['web', 'desktop'], contract_version: '1.0.0',",
|
|
898
|
+
' summary: ' + JSON.stringify(app.name + ' 的应用能力') + ', keywords: [' + JSON.stringify(app.name) + '],',
|
|
899
|
+
" guide: { entry: 'agent/guide.md', topics: [{ id: 'workflow', title: '调用流程', entry: 'agent/workflow.md' }] },",
|
|
900
|
+
" examples: 'agent/examples.json',",
|
|
901
|
+
'})',
|
|
902
|
+
'',
|
|
903
|
+
'export const capabilities = [{',
|
|
904
|
+
" name: 'read_metadata', title: '读取应用元数据', description: '读取应用的标识、名称和版本。',",
|
|
905
|
+
" access: 'read', idempotent: true, targets: ['web', 'desktop'],",
|
|
906
|
+
" input_schema: { type: 'object', properties: {}, required: [], additionalProperties: false },",
|
|
907
|
+
" output_schema: { type: 'object', properties: { appId: { type: 'string' }, version: { type: 'string' }, name: { type: 'string' } }, required: ['appId', 'version', 'name'], additionalProperties: false },",
|
|
908
|
+
'}]',
|
|
909
|
+
'',
|
|
910
|
+
'export function capability(name) {',
|
|
911
|
+
' const declaration = capabilities.find(item => item.name === name)',
|
|
912
|
+
" if (!declaration) throw new Error('Unknown application capability: ' + name)",
|
|
913
|
+
' return declaration',
|
|
914
|
+
'}',
|
|
915
|
+
].join('\n') + '\n')
|
|
916
|
+
await writeFile(join(root, 'src', 'agent-tools.d.mts'), `import type { AgentCapabilityDeclaration, AgentContractMetadata } from '@ducking-mind/proxy-app-sdk'\nexport const agentMetadata: AgentContractMetadata\nexport const capabilities: AgentCapabilityDeclaration[]\nexport function capability(name: string): AgentCapabilityDeclaration\n`)
|
|
917
|
+
await writeFile(join(root, 'src', 'main.ts'), `import { agent, createAppClient } from '@ducking-mind/proxy-app-sdk'\nimport manifest from '../manifest.json'\nimport { capability } from './agent-tools.mjs'\n\nconst client = createAppClient(manifest.id, { runtime: import.meta.env.DEV ? 'local' : undefined, localDevelopment: import.meta.env.DEV, localAppVersion: manifest.version })\nconst root = document.querySelector<HTMLElement>('#app')\nif (root) root.textContent = manifest.name\n\n// Register the same code-owned declaration used to generate the package contract.\nconst unregisterMetadata = agent.register(capability('read_metadata'), () => ({\n appId: manifest.id,\n version: manifest.version,\n name: manifest.name,\n}))\nagent.setStatus({ state: 'ready' }, { scope: 'app', priority: 1 })\nvoid agent.ready()\nvoid client.platform.capabilities().then(console.log)\n\n// Call unregisterMetadata when the feature or application is unmounted.\nvoid unregisterMetadata\n`)
|
|
288
918
|
await writeFile(join(root, 'vite.config.ts'), "import { defineConfig } from 'vite'\nimport { createPlatformDevProxy } from '@ducking-mind/proxy-app-sdk/vite'\n\nexport default defineConfig({\n base: './',\n server: {\n host: '127.0.0.1',\n proxy: createPlatformDevProxy(),\n },\n})\n")
|
|
289
|
-
await writeFile(join(root, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: '
|
|
290
|
-
await writeFile(join(root, 'package.json'), JSON.stringify({ name: app.slug.replaceAll('.', '-'), private: true, type: 'module', scripts: { dev: 'vite', typecheck: 'tsc --noEmit', build: 'npm run typecheck && vite build' }, dependencies: { '@ducking-mind/proxy-app-sdk': '^0.1.0' }, devDependencies: { typescript: '^5.2.2', vite: '^8.2.2' } }, null, 2) + '\n')
|
|
919
|
+
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
|
+
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
|
+
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 和本地 App 共用同一套 manifest;仅在能力确实支持时缩小 “targets”。\n`)
|
|
923
|
+
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本项目已生成 Web 与本地 App 两个运行目标,以及一个 “read_metadata” 示例。能力的名称、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`)
|
|
291
925
|
}
|
|
292
926
|
|
|
293
927
|
async function replaceIdentifier(root, previous, next) {
|
|
@@ -314,26 +948,30 @@ async function initApp(appDir, args) {
|
|
|
314
948
|
const name = option(args, '--name', root.split(sep).pop() || 'Proxy App')
|
|
315
949
|
const app = { id: '', slug: `app.proxy.local.${randomUUID().replaceAll('-', '').slice(0, 16)}`, name, description: '' }
|
|
316
950
|
await writeProject(root, app)
|
|
317
|
-
console.log(JSON.stringify({ created: root,
|
|
951
|
+
console.log(JSON.stringify({ created: root, identifier: app.slug, next: [`cd ${root}`, 'npm install', 'npx proxy-app create', 'npm run dev', 'npx proxy-app pack'] }, null, 2))
|
|
318
952
|
}
|
|
319
953
|
|
|
320
954
|
async function createApp(appDir, args) {
|
|
321
955
|
const root = resolve(appDir)
|
|
322
956
|
if (await exists(join(root, '.proxy-app.json'))) throw new Error('app is already linked; run proxy-app status to inspect it')
|
|
323
|
-
const { manifest } = await readManifest(root)
|
|
957
|
+
const { sourceManifest: manifest } = await readManifest(root)
|
|
324
958
|
const localIdentifier = manifest.id
|
|
325
959
|
const name = option(args, '--name', manifest.name || root.split(sep).pop() || 'Proxy App')
|
|
326
|
-
const category = option(args, '--category', '
|
|
960
|
+
const category = option(args, '--category', 'other')
|
|
327
961
|
if (!ALLOWED_CATEGORIES.has(category)) throw new Error(`unsupported app category: ${category}`)
|
|
328
962
|
const app = await apiJSON(APP_WORKSPACE_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description: option(args, '--description', manifest.description || ''), category }) })
|
|
329
|
-
if (!app || typeof app.id !== 'string' ||
|
|
330
|
-
throw new Error('platform returned an invalid app binding; expected
|
|
963
|
+
if (!app || typeof app.id !== 'string' || !app.id || !validPublicAppSlug(app.slug)) {
|
|
964
|
+
throw new Error('platform returned an invalid app binding; expected app_id and a 1–120 character lowercase slug')
|
|
331
965
|
}
|
|
332
|
-
|
|
966
|
+
const previousNamespace = defaultAgentNamespace(localIdentifier)
|
|
967
|
+
const nextNamespace = defaultAgentNamespace(app.id)
|
|
968
|
+
await replaceGeneratedNamespace(root, previousNamespace, nextNamespace)
|
|
969
|
+
manifest.id = app.id
|
|
970
|
+
await replaceGeneratedIdentifier(root, localIdentifier, app.id)
|
|
333
971
|
await writeFile(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
|
|
334
|
-
await replaceIdentifier(root, localIdentifier, app.
|
|
335
|
-
await writeFile(join(root, '.proxy-app.json'), JSON.stringify(
|
|
336
|
-
console.log(JSON.stringify({ created: root,
|
|
972
|
+
await replaceIdentifier(root, localIdentifier, app.id)
|
|
973
|
+
await writeFile(join(root, '.proxy-app.json'), JSON.stringify(appBinding(app), null, 2) + '\n')
|
|
974
|
+
console.log(JSON.stringify({ created: root, identifier: app.slug, app_id: app.id, next: [`proxy-app upload ${root}`] }, null, 2))
|
|
337
975
|
}
|
|
338
976
|
|
|
339
977
|
async function linkApp(appDir, appID) {
|
|
@@ -342,101 +980,161 @@ async function linkApp(appDir, appID) {
|
|
|
342
980
|
const app = (result.items || []).find((item) => item.id === appID)
|
|
343
981
|
if (!app) throw new Error(`application not found: ${appID}`)
|
|
344
982
|
const manifestPath = join(root, 'manifest.json')
|
|
345
|
-
const { manifest } = await readManifest(root)
|
|
346
|
-
if (typeof app.
|
|
983
|
+
const { sourceManifest: manifest } = await readManifest(root)
|
|
984
|
+
if (!validPublicAppSlug(app.slug) || typeof app.id !== 'string' || !app.id) {
|
|
347
985
|
throw new Error(`application has an invalid identifier: ${app.slug || '(missing)'}`)
|
|
348
986
|
}
|
|
349
987
|
const previous = manifest.id
|
|
350
|
-
|
|
988
|
+
const previousNamespace = defaultAgentNamespace(previous)
|
|
989
|
+
const nextNamespace = defaultAgentNamespace(app.id)
|
|
990
|
+
await replaceGeneratedNamespace(root, previousNamespace, nextNamespace)
|
|
991
|
+
manifest.id = app.id
|
|
992
|
+
await replaceGeneratedIdentifier(root, previous, app.id)
|
|
351
993
|
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
352
|
-
await replaceIdentifier(root, previous, app.
|
|
353
|
-
await writeFile(join(root, '.proxy-app.json'), JSON.stringify(
|
|
354
|
-
console.log(JSON.stringify({ linked: root,
|
|
994
|
+
await replaceIdentifier(root, previous, app.id)
|
|
995
|
+
await writeFile(join(root, '.proxy-app.json'), JSON.stringify(appBinding(app), null, 2) + '\n')
|
|
996
|
+
console.log(JSON.stringify({ linked: root, identifier: app.slug, app_id: app.id }, null, 2))
|
|
355
997
|
}
|
|
356
998
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
999
|
+
// Repair is the user-facing binding command. It keeps the legacy slug in the
|
|
1000
|
+
// local file for compatibility, but users only need to provide app_id.
|
|
1001
|
+
async function repairApp(appDir, appID) {
|
|
1002
|
+
const root = resolve(appDir)
|
|
1003
|
+
const existing = await readFile(join(root, '.proxy-app.json'), 'utf8').then(JSON.parse).catch(() => ({}))
|
|
1004
|
+
const target = appID || existing.app_id
|
|
1005
|
+
if (!target) throw new Error('缺少 app_id,请使用 proxy-app repair <app-id>')
|
|
1006
|
+
await linkApp(root, target)
|
|
363
1007
|
}
|
|
364
1008
|
|
|
365
|
-
|
|
366
|
-
|
|
1009
|
+
// Management commands use the platform app identity carried by manifest.id.
|
|
1010
|
+
// A sidecar binding may remain in an older checkout, but it is intentionally
|
|
1011
|
+
// ignored: existing apps can be uploaded/published directly after setting
|
|
1012
|
+
// manifest.id to the app's public slug (or stable app_id). This keeps the
|
|
1013
|
+
// development and release flows consistent and avoids making `link` a hidden
|
|
1014
|
+
// prerequisite for an otherwise valid package.
|
|
1015
|
+
async function resolveManagedApp(appDir) {
|
|
1016
|
+
const root = resolve(appDir)
|
|
1017
|
+
const { manifest } = await readManifest(root)
|
|
1018
|
+
const result = await apiJSON(APP_WORKSPACE_PATH)
|
|
1019
|
+
const items = Array.isArray(result?.items) ? result.items : []
|
|
1020
|
+
// Resolve the durable app ID from the public manifest identity. Existing
|
|
1021
|
+
// checkouts therefore work without a sidecar file or a link/repair command;
|
|
1022
|
+
// stale compatibility sidecars are deliberately ignored.
|
|
1023
|
+
const app = items.find((item) => matchesAppIdentity(manifest.id, item?.id, item?.slug, publicAppAliases(item)))
|
|
1024
|
+
if (!app) {
|
|
1025
|
+
throw new Error(`应用不存在或当前账户无权访问:${manifest.id}。请确认 manifest.id 是该应用的公开 slug;新应用请运行 proxy-app create。`)
|
|
1026
|
+
}
|
|
1027
|
+
return { root, manifest, app, binding: appBinding(app) }
|
|
367
1028
|
}
|
|
368
1029
|
|
|
369
1030
|
async function developerApp(appDir) {
|
|
370
|
-
|
|
371
|
-
const result = await apiJSON(APP_WORKSPACE_PATH)
|
|
372
|
-
const app = (result.items || []).find((item) => item.id === appID)
|
|
373
|
-
if (!app) throw new Error(`linked application was not found: ${appID}`)
|
|
374
|
-
return app
|
|
1031
|
+
return (await resolveManagedApp(appDir)).app
|
|
375
1032
|
}
|
|
376
1033
|
|
|
377
|
-
async function
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
1034
|
+
async function requestAppOpening(appID, version) {
|
|
1035
|
+
// The version is review material for the application opening request.
|
|
1036
|
+
// Pricing remains the application's saved configuration.
|
|
1037
|
+
return apiJSON(`${APP_WORKSPACE_PATH}/${encodeURIComponent(appID)}/opening`, {
|
|
1038
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
1039
|
+
body: JSON.stringify({ version }),
|
|
1040
|
+
})
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function pendingAppOpening(app) {
|
|
1044
|
+
if (Object.hasOwn(app, 'opening_request')) {
|
|
1045
|
+
return app.opening_request?.status === 'pending' ? app.opening_request : null
|
|
1046
|
+
}
|
|
1047
|
+
// Old servers expose the candidate through a version status. Never prefer
|
|
1048
|
+
// that compatibility mirror over the application's canonical request.
|
|
1049
|
+
const version = (app.versions || []).find((item) => item.review_status === 'review')
|
|
1050
|
+
return version ? { version: version.version, status: 'pending' } : null
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function reportPendingOpening(app, version) {
|
|
1054
|
+
const pending = pendingAppOpening(app)
|
|
1055
|
+
if (!pending) return false
|
|
1056
|
+
if (pending.version !== version) {
|
|
1057
|
+
throw new Error(`应用开放申请正在审核(预览版本 ${pending.version}),请在“我的应用”撤回申请后再更换审核材料。`)
|
|
1058
|
+
}
|
|
1059
|
+
const result = { submitted: false, reason: '应用开放申请正在审核,无需重复提交。', app_id: app.id, opening_request: pending, version }
|
|
1060
|
+
console.log(JSON.stringify(result, null, 2))
|
|
1061
|
+
return result
|
|
383
1062
|
}
|
|
384
1063
|
|
|
385
1064
|
async function uploadApp(appDir, output, options = {}) {
|
|
386
|
-
const
|
|
1065
|
+
const { app } = await resolveManagedApp(appDir)
|
|
387
1066
|
const { token, baseURL } = apiSettings()
|
|
388
|
-
const bundleInfo = await packApp(appDir, output, options)
|
|
389
|
-
if (binding.slug !== bundleInfo.manifest.id) {
|
|
390
|
-
throw new Error('应用标识不匹配,请先同步当前应用绑定,再重新 build 和 pack。')
|
|
391
|
-
}
|
|
1067
|
+
const bundleInfo = await packApp(appDir, output, { ...options, allowSourceMaps: false })
|
|
392
1068
|
const bundle = await readFile(bundleInfo.outputPath)
|
|
393
1069
|
const form = new FormData(); form.append('file', new Blob([bundle], { type: 'application/zip' }), `${bundleInfo.manifest.id}-${bundleInfo.manifest.version}.zip`)
|
|
394
|
-
const response = await fetch(`${baseURL}${APP_WORKSPACE_PATH}/${encodeURIComponent(
|
|
1070
|
+
const response = await fetch(`${baseURL}${APP_WORKSPACE_PATH}/${encodeURIComponent(app.id)}/versions`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
|
|
395
1071
|
const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(responseError(body, `upload failed: HTTP ${response.status}`))
|
|
396
|
-
|
|
397
|
-
|
|
1072
|
+
const uploaded = body.data || body
|
|
1073
|
+
console.log(JSON.stringify({
|
|
1074
|
+
uploaded,
|
|
1075
|
+
message: '版本已上传,线上版本未切换。测试后使用 publish 手动发布。',
|
|
1076
|
+
next: `proxy-app publish ${appDir} --version ${bundleInfo.manifest.version}`,
|
|
1077
|
+
}, null, 2))
|
|
1078
|
+
return uploaded
|
|
398
1079
|
}
|
|
399
1080
|
|
|
400
|
-
async function publishApp(appDir, output) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
1081
|
+
async function publishApp(appDir, output, selectedVersion) {
|
|
1082
|
+
// Existing uploaded versions are immutable. Only a new/local draft goes
|
|
1083
|
+
// through uploadApp's build; no dist is needed to publish a remote version.
|
|
1084
|
+
const info = await readManifest(resolve(appDir))
|
|
1085
|
+
const targetVersion = selectedVersion || info.manifest.version
|
|
1086
|
+
let remoteApp = await developerApp(appDir)
|
|
1087
|
+
let version = (remoteApp.versions || []).find((item) => item.version === targetVersion)
|
|
1088
|
+
if (selectedVersion && (!version || (!version.artifact_size && !version.artifact_sha256))) {
|
|
1089
|
+
throw new Error(`版本 ${targetVersion} 尚未上传。--version 只选择已有上传产物,请先上传该版本。`)
|
|
1090
|
+
}
|
|
1091
|
+
if (!remoteApp.current_version) {
|
|
1092
|
+
if (remoteApp.status === 'disabled') throw new Error('应用已被平台禁用,解除限制后才能申请开放。修复包可使用 proxy-app upload 上传。')
|
|
1093
|
+
const pending = reportPendingOpening(remoteApp, targetVersion)
|
|
1094
|
+
if (pending) return pending
|
|
405
1095
|
}
|
|
406
|
-
let version = (remoteApp.versions || []).find((item) => item.version === info.manifest.version)
|
|
407
1096
|
let uploaded = null
|
|
408
|
-
if (!version) {
|
|
1097
|
+
if (!version || (version.review_status === 'draft' && !version.artifact_size && !version.artifact_sha256)) {
|
|
409
1098
|
uploaded = await uploadApp(appDir, output)
|
|
410
1099
|
version = uploaded
|
|
1100
|
+
remoteApp = await developerApp(appDir)
|
|
1101
|
+
}
|
|
1102
|
+
if (!remoteApp.current_version) {
|
|
1103
|
+
const pending = reportPendingOpening(remoteApp, targetVersion)
|
|
1104
|
+
if (pending) return pending
|
|
1105
|
+
}
|
|
1106
|
+
if (remoteApp.current_version === targetVersion) {
|
|
1107
|
+
const result = { submitted: false, changed: false, reason: '该版本已是当前线上版本,无需切换。', app_id: remoteApp.id, version: targetVersion }
|
|
1108
|
+
console.log(JSON.stringify(result, null, 2))
|
|
1109
|
+
return result
|
|
411
1110
|
}
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
1111
|
+
if (remoteApp.current_version) {
|
|
1112
|
+
if (!['draft', 'rejected', 'review', 'published'].includes(version.review_status)) throw new Error(`version cannot be published from status: ${version.review_status || 'unknown'}`)
|
|
1113
|
+
const published = await apiJSON(`${APP_WORKSPACE_PATH}/${encodeURIComponent(remoteApp.id)}/versions/${encodeURIComponent(targetVersion)}/publish`, { method: 'POST' })
|
|
1114
|
+
const result = { uploaded, published, submitted: false, changed: true, message: '线上版本已手动切换;应用保持当前上架或限制状态。', app_id: remoteApp.id, previous_version: remoteApp.current_version, version: targetVersion }
|
|
1115
|
+
console.log(JSON.stringify(result, null, 2))
|
|
1116
|
+
return result
|
|
415
1117
|
}
|
|
416
|
-
if (!['draft', 'rejected'].includes(version.review_status)) throw new Error(`version cannot be
|
|
417
|
-
const submitted = await
|
|
418
|
-
|
|
1118
|
+
if (!['draft', 'rejected', 'review'].includes(version.review_status)) throw new Error(`version cannot be used as application review material from status: ${version.review_status || 'unknown'}`)
|
|
1119
|
+
const submitted = await requestAppOpening(remoteApp.id, targetVersion)
|
|
1120
|
+
const result = { uploaded, submitted: true, opening: submitted, changed: false, message: '应用开放申请已提交,所选版本用于预览和审核。', app_id: remoteApp.id, version: targetVersion }
|
|
1121
|
+
console.log(JSON.stringify(result, null, 2))
|
|
1122
|
+
return result
|
|
419
1123
|
}
|
|
420
1124
|
|
|
421
1125
|
async function statusApp(appDir) {
|
|
422
1126
|
const root = resolve(appDir)
|
|
423
1127
|
const { manifest } = await readManifest(root)
|
|
424
|
-
let binding = null
|
|
425
|
-
try { binding = JSON.parse(await readFile(join(root, '.proxy-app.json'), 'utf8')) } catch {}
|
|
426
|
-
if (!binding?.app_id) {
|
|
427
|
-
console.log(JSON.stringify({ linked: false, local_identifier: manifest.id, version: manifest.version, next: `proxy-app create ${appDir}` }, null, 2))
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
1128
|
const app = await developerApp(root)
|
|
431
1129
|
const version = (app.versions || []).find((item) => item.version === manifest.version)
|
|
432
|
-
console.log(JSON.stringify({ linked: true,
|
|
1130
|
+
console.log(JSON.stringify({ linked: true, identifier: app.slug, app_id: app.id, app_status: app.status, current_version: app.current_version || null, opening_request: app.opening_request ?? pendingAppOpening(app), local_version: manifest.version, remote_version: version || null, binding_source: 'manifest.id' }, null, 2))
|
|
433
1131
|
}
|
|
434
1132
|
|
|
435
1133
|
async function doctorApp(appDir) {
|
|
436
1134
|
const root = resolve(appDir)
|
|
437
1135
|
const checks = []
|
|
438
|
-
const major =
|
|
439
|
-
checks.push({ name: 'node', ok: major >=
|
|
1136
|
+
const [major, minor] = process.versions.node.split('.').map(Number)
|
|
1137
|
+
checks.push({ name: 'node', ok: major > 22 || (major === 22 && minor >= 18), detail: process.version })
|
|
440
1138
|
try {
|
|
441
1139
|
const info = await validateApp(root)
|
|
442
1140
|
checks.push({ name: 'manifest', ok: true, detail: `${info.manifest.id}@${info.manifest.version}` })
|
|
@@ -444,12 +1142,7 @@ async function doctorApp(appDir) {
|
|
|
444
1142
|
} catch (error) {
|
|
445
1143
|
checks.push({ name: 'manifest', ok: false, detail: error.message })
|
|
446
1144
|
}
|
|
447
|
-
|
|
448
|
-
const config = JSON.parse(await readFile(join(root, '.proxy-app.json'), 'utf8'))
|
|
449
|
-
checks.push({ name: 'binding', ok: Boolean(config.app_id && config.slug), detail: config.app_id || 'invalid .proxy-app.json' })
|
|
450
|
-
} catch {
|
|
451
|
-
checks.push({ name: 'binding', ok: false, detail: 'not linked; create or link before upload' })
|
|
452
|
-
}
|
|
1145
|
+
checks.push({ name: 'binding', ok: true, detail: '使用 manifest.id 解析平台应用,无需 link 或 repair' })
|
|
453
1146
|
const valid = checks.every((item) => item.ok)
|
|
454
1147
|
console.log(JSON.stringify({ ok: valid, ready_for_upload: valid, checks }, null, 2))
|
|
455
1148
|
if (!valid) process.exitCode = 1
|
|
@@ -457,36 +1150,24 @@ async function doctorApp(appDir) {
|
|
|
457
1150
|
|
|
458
1151
|
const [, , command, ...args] = process.argv
|
|
459
1152
|
try {
|
|
460
|
-
if (!command || command === '--help' || command === '-h') usage()
|
|
461
|
-
else
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (
|
|
465
|
-
await createApp(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
await validateApp(args[0] || '.')
|
|
479
|
-
} else if (command === 'pack') await packApp(args[0] || '.', outputArgument(args), { allowSource: args.includes('--source') })
|
|
480
|
-
else if (command === 'upload') {
|
|
481
|
-
if (!args[0]) throw new Error('upload requires <app-dir>')
|
|
482
|
-
await uploadApp(args[0], outputArgument(args), { allowSource: args.includes('--source') })
|
|
483
|
-
}
|
|
484
|
-
else if (command === 'publish') {
|
|
485
|
-
if (!args[0]) throw new Error('publish requires <app-dir>')
|
|
486
|
-
await publishApp(args[0], args[1])
|
|
487
|
-
} else {
|
|
488
|
-
usage()
|
|
489
|
-
throw new Error(`unknown command: ${command}`)
|
|
1153
|
+
if (!command || command === '--help' || command === '-h' || args.includes('--help') || args.includes('-h')) usage()
|
|
1154
|
+
else {
|
|
1155
|
+
const { appDir, appID, output, options } = commandArguments(command, args)
|
|
1156
|
+
if (command === 'dev') await devApp(appDir, options)
|
|
1157
|
+
else if (command === 'init') await initApp(appDir, options)
|
|
1158
|
+
else if (command === 'create') await createApp(appDir, options)
|
|
1159
|
+
else if (command === 'repair' || command === 'link') await repairApp(appDir, appID)
|
|
1160
|
+
else if (command === 'status') await statusApp(appDir)
|
|
1161
|
+
else if (command === 'doctor') await doctorApp(appDir)
|
|
1162
|
+
else if (command === 'validate') {
|
|
1163
|
+
const info = await validateApp(appDir)
|
|
1164
|
+
console.log(JSON.stringify({ valid: true, id: info.manifest.id, version: info.manifest.version, entry: info.entry }, null, 2))
|
|
1165
|
+
} else if (command === 'build') await buildApp(appDir)
|
|
1166
|
+
else if (command === 'pack') await packApp(appDir, output, { allowSource: options.includes('--source') })
|
|
1167
|
+
else if (command === 'upload') await uploadApp(appDir, output, { allowSource: options.includes('--source') })
|
|
1168
|
+
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)
|
|
490
1171
|
}
|
|
491
1172
|
} catch (error) {
|
|
492
1173
|
console.error(`proxy app: ${error instanceof Error ? error.message : String(error)}`)
|