@ducking-mind/proxy-app-cli 0.1.0 → 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/proxy-app.mjs CHANGED
@@ -1,51 +1,97 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
- import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
4
+ import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
5
5
  import { createHash, randomUUID } from 'node:crypto'
6
6
  import { dirname, join, resolve, sep } from 'node:path'
7
+ import { createServer as createTCPServer } from 'node:net'
7
8
  import { zipSync } from 'fflate'
8
9
  import { createLocalDevelopmentRelay } from './local-dev-relay.mjs'
10
+ import { createLocalCredentialStore } from './local-dev-credentials.mjs'
11
+ import { assertSafeArtifactFile, assertSafeArtifactDirectory } from './artifact-security.mjs'
12
+ import { resolveAgentContract } from './agent-contract.mjs'
13
+ import { platformAPIBaseURL, platformPortalURL } from './platform-settings.mjs'
9
14
 
10
15
  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(['free', 'one_time', 'commission'])
14
- const ALLOWED_CATEGORIES = new Set(['productivity', 'creative', 'developer', 'data', 'communication', 'education', 'finance', 'other'])
15
- const BUNDLE_IGNORED_FILES = new Set(['.proxy-app.json'])
16
+ const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'platform.capabilities.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
17
+ const ALLOWED_APIS = new Set(['model.responses', 'model.chat', 'model.messages', 'model.embeddings', 'model.images', 'model.videos', 'model.music', 'model.voice_design', 'model.tts'])
18
+ const ALLOWED_PRICING_MODES = new Set(['commission'])
19
+ const ALLOWED_CATEGORIES = new Set(['ecommerce', 'comic', 'other'])
20
+ const RESERVED_AGENT_NAMESPACES = new Set(['app', 'codex', 'desktop', 'proxy', 'system'])
21
+ const BUNDLE_IGNORED_FILES = new Set(['.proxy-app.json', '.proxy-app-build'])
16
22
  const APP_WORKSPACE_PATH = '/portal/user/app-workspace'
23
+ // Public slugs are used verbatim, including single-segment names such as
24
+ // "canvas". The platform's opaque app_id remains a separate compatibility
25
+ // format; accepting a slug must not reinterpret it as that durable identity.
26
+ const PLATFORM_APP_ID_PATTERN = /^app_[a-z0-9][a-z0-9._-]{2,119}$/
27
+ const PUBLIC_APP_SLUG_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/
28
+ const validPublicAppSlug = value => typeof value === 'string' && value.length <= 120 && value === value.trim() && PUBLIC_APP_SLUG_PATTERN.test(value)
29
+ const validAppIdentifier = value => typeof value === 'string' && value === value.trim() && (PLATFORM_APP_ID_PATTERN.test(value) || validPublicAppSlug(value))
30
+ const publicAppAliases = app => Array.isArray(app?.app_aliases) ? app.app_aliases.filter(validPublicAppSlug) : []
31
+ const matchesAppIdentity = (identifier, appID, slug, aliases = []) => identifier === appID
32
+ || validPublicAppSlug(identifier) && (identifier === slug || aliases.includes(identifier))
33
+ const appBinding = app => ({
34
+ app_id: app.id, slug: app.slug,
35
+ ...(publicAppAliases(app).length ? { app_aliases: publicAppAliases(app) } : {}),
36
+ })
37
+
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))
17
40
 
18
41
  function usage() {
19
- console.log(`Usage:
20
- proxy-app init <app-dir> [--name <name>]
21
- proxy-app create <app-dir> [--category productivity] [--description text]
22
- proxy-app link <app-dir> <app-id>
23
- proxy-app status <app-dir>
24
- proxy-app doctor <app-dir>
25
- proxy-app dev <app-dir> [--port <port>] [--legacy-bridge]
26
- proxy-app validate <app-dir>
27
- proxy-app build <app-dir>
28
- proxy-app pack <app-dir> [output.zip]
29
- proxy-app upload <app-dir> [output.zip]
30
- proxy-app publish <app-dir> [output.zip]
31
-
32
- Add --source to pack/upload only for local debugging; release packages require dist.
33
- Categories: productivity, creative, developer, data, communication, education, finance, other.
42
+ console.log(`在应用目录内,常用命令:
43
+ proxy-app dev 开发授权与检查通过后启动应用
44
+ proxy-app pack 重新构建 → 校验 → 生成 ZIP
45
+ proxy-app upload 重新构建、打包并上传待发布版本,不切换线上版本
46
+ proxy-app publish 手动发布当前 manifest 版本;缺少上传产物时先构建上传
47
+ proxy-app publish --version 0.1.0 切换到已上传版本,也可回滚
48
+
49
+ 首次使用:proxy-app init <app-dir>,安装依赖后运行 proxy-app create。
50
+ 应用开发使用 manifest.id 中的公开 slug;管理命令仍可使用 app_id。
51
+ pack 无需提前执行 build 或 validate;已上传的版本由 publish 直接推进发布。
52
+
53
+ 完整命令(app-dir 默认当前目录):
54
+ proxy-app init [app-dir] [--name <name>]
55
+ proxy-app create [app-dir] [--name <name>] [--category other] [--description text]
56
+ proxy-app status [app-dir]
57
+ proxy-app doctor [app-dir]
58
+ proxy-app dev [app-dir] [--port <port>] [--no-auth-cache]
59
+ proxy-app validate [app-dir]
60
+ proxy-app build [app-dir]
61
+ proxy-app pack [app-dir] [-o <output.zip>]
62
+ proxy-app upload [app-dir] [-o <output.zip>]
63
+ proxy-app publish [app-dir] [-o <output.zip> | --version <uploaded-version>]
64
+
65
+ upload 只上传;publish 显式切换线上版本。首次使用 publish 仍需申请应用开放。
66
+ --version 仅选择已上传版本,不构建、不上传、不修改本地 manifest。
67
+ -o 与 --output 等价,也兼容原来的 <app-dir> <output.zip> 写法。
68
+ --source 仅供 pack/upload 调试源码,跳过构建,不用于正式发布。
69
+ 应用分类:ecommerce(电商)、comic(漫剧)、other(其他,默认)。
34
70
 
35
71
  Publish environment:
36
- PROXY_API_BASE_URL default: http://localhost:9003/api
72
+ PROXY_PLATFORM_URL Optional single-origin platform URL; derives API (/api) and login Portal
73
+ PROXY_API_BASE_URL default: https://www.duckingmind.com/api
74
+ Optional override when the platform uses a separate API host
75
+ PROXY_PORTAL_URL Optional login Portal override; default: https://www.duckingmind.com
37
76
  PROXY_USER_ACCESS_TOKEN Current platform user session for app management/CI only; never passed to the app
38
77
  PROXY_ACCESS_TOKEN Legacy alias for PROXY_USER_ACCESS_TOKEN
39
78
 
40
79
  Local development:
41
80
  proxy-app dev <app-dir> [--port 5173]
42
- Starts a real platform relay. The app opens platform login on first use;
43
- requests then use the signed-in user's App Session, models, routing, quota
44
- and billing. No separate developer identity or runtime credential exists.
45
- HTTP APIs are the default. --legacy-bridge enables v1 Bridge compatibility.
81
+ If the requested port is occupied, the next available loopback port is used.
82
+ Checks the app binding and developer permission before starting the app.
83
+ First authorize the developer using the terminal login URL; then app users
84
+ sign in separately for their own App Session, models, quota and billing.
85
+ App business traffic always uses the App Session Gateway (/v1). The local
86
+ relay exposes only the authentication control path and never an Admin Runtime
87
+ or legacy Bridge endpoint.
88
+ macOS stores developer and app-user authorization separately in Keychain.
89
+ --no-auth-cache uses memory only. If a cached developer account cannot access
90
+ the app, dev automatically asks you to authorize with the app creator account.
46
91
 
47
92
  Platform verification:
48
- Build and pack the app, upload the ZIP, then publish the selected version.
93
+ Publish builds and uploads new candidates before requesting application opening.
94
+ Once the application is open, later versions publish without another review.
49
95
  `)
50
96
  }
51
97
 
@@ -54,13 +100,92 @@ function option(args, name, fallback = '') {
54
100
  return index >= 0 && args[index + 1] ? args[index + 1] : fallback
55
101
  }
56
102
 
57
- function outputArgument(args) {
58
- return args.slice(1).find((arg) => arg !== '--source') || undefined
103
+ function reservePort(port) {
104
+ return new Promise((resolvePromise, reject) => {
105
+ const server = createTCPServer()
106
+ let settled = false
107
+ const settle = (callback, value) => {
108
+ if (settled) return
109
+ settled = true
110
+ callback(value)
111
+ }
112
+ server.once('error', (error) => {
113
+ if (error?.code === 'EADDRINUSE') settle(resolvePromise, null)
114
+ else settle(reject, error)
115
+ })
116
+ server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
117
+ settle(resolvePromise, {
118
+ port,
119
+ release: () => {
120
+ if (!server.listening) return Promise.resolve()
121
+ return new Promise((resolveClose) => server.close(() => resolveClose()))
122
+ },
123
+ })
124
+ })
125
+ })
126
+ }
127
+
128
+ async function reserveAvailablePort(startPort) {
129
+ for (let port = startPort; port <= 65535; port += 1) {
130
+ const reservation = await reservePort(port)
131
+ if (reservation) return reservation
132
+ }
133
+ throw new Error(`从端口 ${startPort} 开始没有可用的本地端口`)
134
+ }
135
+
136
+ function commandArguments(command, args) {
137
+ const configurations = {
138
+ init: { values: ['--name'], max: 1 },
139
+ create: { values: ['--name', '--category', '--description'], max: 1 },
140
+ repair: { max: 2 }, link: { max: 2 }, status: { max: 1 }, doctor: { max: 1 },
141
+ validate: { max: 1 }, build: { max: 1 },
142
+ dev: { values: ['--port'], flags: ['--no-auth-cache'], max: 1 },
143
+ pack: { values: ['--output'], flags: ['--source'], max: 2 },
144
+ upload: { values: ['--output'], flags: ['--source'], max: 2 },
145
+ publish: { values: ['--output', '--version'], max: 2 },
146
+ }
147
+ const config = configurations[command]
148
+ if (!config) throw new Error(`unknown command: ${command}`)
149
+ const positional = []
150
+ const options = []
151
+ const seen = new Set()
152
+ for (let i = 0; i < args.length; i++) {
153
+ const arg = args[i]
154
+ if (arg === '--') { positional.push(...args.slice(i + 1)); break }
155
+ if (!arg.startsWith('-')) { positional.push(arg); continue }
156
+ const key = arg === '-o' ? '--output' : arg
157
+ if (![...(config.values || []), ...(config.flags || [])].includes(key)) {
158
+ throw new Error(`unsupported ${command} option: ${arg}${command === 'dev' ? '; local development always uses the real platform' : ''}`)
159
+ }
160
+ if (seen.has(key)) throw new Error(`duplicate option: ${key}`)
161
+ seen.add(key)
162
+ options.push(key)
163
+ if (config.values?.includes(key)) {
164
+ if (args[i + 1] === undefined || args[i + 1].startsWith('-') || !args[i + 1].trim()) throw new Error(`missing value for ${arg}`)
165
+ options.push(args[++i])
166
+ }
167
+ }
168
+ if (positional.length > config.max) throw new Error(`too many arguments for ${command}`)
169
+ if (command === 'repair') {
170
+ if (!positional.length) return { appDir: '.', appID: '', options }
171
+ return { appDir: positional.length === 2 ? positional[0] : '.', appID: positional.at(-1), options }
172
+ }
173
+ if (command === 'link') {
174
+ if (!positional.length) return { appDir: '.', appID: '', options }
175
+ return { appDir: positional.length === 2 ? positional[0] : '.', appID: positional.at(-1), options }
176
+ }
177
+ const output = option(options, '--output') || positional[1]
178
+ if (seen.has('--output') && positional[1]) throw new Error('specify output once: use --output or the positional output path')
179
+ if (command === 'publish' && seen.has('--version')) {
180
+ 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')
181
+ if (output) throw new Error('--version selects an uploaded version without building; do not specify an output path')
182
+ }
183
+ return { appDir: positional[0] || '.', output, options }
59
184
  }
60
185
 
61
186
  function apiSettings() {
62
187
  const token = process.env.PROXY_USER_ACCESS_TOKEN || process.env.PROXY_ACCESS_TOKEN
63
- const baseURL = (process.env.PROXY_API_BASE_URL || 'http://localhost:9003/api').replace(/\/$/, '')
188
+ const baseURL = platformAPIBaseURL()
64
189
  if (!token) throw new Error('PROXY_USER_ACCESS_TOKEN is required for app management commands; it is never passed to the app runtime')
65
190
  return { token, baseURL }
66
191
  }
@@ -108,18 +233,43 @@ async function readManifest(appDir) {
108
233
  }
109
234
  if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('manifest must be a JSON object')
110
235
  if (manifest.schema !== 'proxy.app/v1') throw new Error('manifest.schema must be proxy.app/v1')
111
- if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)+(?:[.-][a-z0-9]+)*$/.test(manifest.id || '')) throw new Error('manifest.id is invalid')
236
+ if (!validAppIdentifier(manifest.id)) throw new Error('manifest.id is invalid; expected a 1–120 character lowercase slug or platform app_id')
112
237
  if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.version || '')) throw new Error('manifest.version is invalid')
113
238
  if (typeof manifest.name !== 'string' || !manifest.name.trim()) throw new Error('manifest.name is required')
114
- if (manifest.runtime?.type !== 'iframe') throw new Error('manifest.runtime.type must be iframe')
115
- if (manifest.backend?.type !== 'platform') throw new Error('manifest.backend.type must be platform')
116
- if (!Array.isArray(manifest.permissions)) throw new Error('manifest.permissions must be an array')
239
+ if (manifest.backend !== undefined && (!manifest.backend || typeof manifest.backend !== 'object' || Array.isArray(manifest.backend))) {
240
+ throw new Error('manifest.backend must be an object')
241
+ }
242
+ // App traffic always goes through the platform gateway. Treating backend as
243
+ // optional keeps the manifest focused on app-specific capabilities while
244
+ // preserving an explicit rejection for unsupported backends.
245
+ if ((manifest.backend?.type || 'platform') !== 'platform') throw new Error('manifest.backend.type must be platform')
246
+ // Permissions are optional metadata. An omitted list means the app accepts
247
+ // the platform's currently supported public capabilities; an explicit list
248
+ // remains available for legacy packages that intentionally narrow access.
249
+ if (manifest.permissions !== undefined && !Array.isArray(manifest.permissions)) throw new Error('manifest.permissions must be an array')
117
250
  const permissions = new Set()
118
- for (const permission of manifest.permissions) {
251
+ for (const permission of manifest.permissions || []) {
119
252
  if (!ALLOWED_PERMISSIONS.has(permission)) throw new Error(`unsupported app permission: ${permission}`)
120
253
  if (permissions.has(permission)) throw new Error(`duplicate app permission: ${permission}`)
121
254
  permissions.add(permission)
122
255
  }
256
+ const sourceManifest = structuredClone(manifest)
257
+ if (manifest.agent !== undefined && (!manifest.agent || typeof manifest.agent !== 'object' || Array.isArray(manifest.agent))) throw new Error('manifest.agent must be an object')
258
+ if (manifest.agent?.enabled !== undefined && typeof manifest.agent.enabled !== 'boolean') throw new Error('manifest.agent.enabled must be boolean')
259
+ manifest = await resolveAgentContract(appDir, manifest)
260
+ if (manifest.agent !== undefined) {
261
+ if (!manifest.agent || typeof manifest.agent !== 'object' || Array.isArray(manifest.agent)) throw new Error('manifest.agent must be an object')
262
+ if (manifest.agent.enabled !== undefined && typeof manifest.agent.enabled !== 'boolean') throw new Error('manifest.agent.enabled must be boolean')
263
+ try {
264
+ const agent = normalizeManifestAgent(manifest)
265
+ if (agent) {
266
+ if (RESERVED_AGENT_NAMESPACES.has(agent.namespace)) throw new Error(`reserved agent namespace: ${agent.namespace}`)
267
+ if (!Array.isArray(manifest.agent.capabilities)) throw new Error('manifest.agent.capabilities must be an array')
268
+ }
269
+ } catch (error) {
270
+ throw new Error(`invalid manifest.agent: ${error.message}`)
271
+ }
272
+ }
123
273
  if (manifest.apis !== undefined && !Array.isArray(manifest.apis)) throw new Error('manifest.apis must be an array')
124
274
  const apis = new Set()
125
275
  for (const api of manifest.apis || []) {
@@ -130,24 +280,34 @@ async function readManifest(appDir) {
130
280
  if (manifest.pricing !== undefined && (!manifest.pricing || typeof manifest.pricing !== 'object' || Array.isArray(manifest.pricing))) {
131
281
  throw new Error('manifest.pricing must be an object')
132
282
  }
133
- const pricing = manifest.pricing || { mode: 'free', currency: 'CNY' }
134
- const mode = pricing.mode || 'free'
135
- const oneTimePrice = pricing.one_time_price || 0
136
- const commissionRate = pricing.commission_rate || 0
137
- if (!ALLOWED_PRICING_MODES.has(mode)) throw new Error('manifest.pricing.mode must be free, one_time, or commission')
138
- if (!/^[A-Z]{3,10}$/.test(pricing.currency || 'CNY')) throw new Error('manifest.pricing.currency is invalid')
139
- if (!Number.isFinite(oneTimePrice) || !Number.isFinite(commissionRate) || oneTimePrice < 0 || commissionRate < 0) throw new Error('manifest.pricing values must be non-negative numbers')
140
- if (mode === 'free' && (oneTimePrice !== 0 || commissionRate !== 0)) throw new Error('free pricing requires zero one_time_price and commission_rate')
141
- if (mode === 'one_time' && (oneTimePrice <= 0 || commissionRate !== 0)) throw new Error('one_time pricing requires positive one_time_price and zero commission_rate')
142
- if (mode === 'commission' && (commissionRate <= 0 || commissionRate > 100 || oneTimePrice !== 0)) throw new Error('commission pricing requires commission_rate between 0 and 100 and zero one_time_price')
143
- const entry = safeRelative(manifest.runtime.entry)
144
- return { manifest, entry }
145
- }
146
-
147
- async function validateApp(appDir) {
283
+ if (manifest.pricing !== undefined) {
284
+ const pricing = manifest.pricing
285
+ const mode = pricing.mode
286
+ const oneTimePrice = pricing.one_time_price || 0
287
+ const commissionRate = pricing.commission_rate || 0
288
+ if (mode === 'free') throw new Error('free app pricing is no longer supported; configure commission pricing on the platform')
289
+ if (mode === 'one_time') throw new Error('one-time app pricing is no longer supported; configure commission pricing on the platform')
290
+ if (!ALLOWED_PRICING_MODES.has(mode)) throw new Error('manifest.pricing.mode must be commission')
291
+ if (!/^[A-Z]{3,10}$/.test(pricing.currency || 'USD')) throw new Error('manifest.pricing.currency is invalid')
292
+ if (!Number.isFinite(oneTimePrice) || !Number.isFinite(commissionRate) || oneTimePrice < 0 || commissionRate < 0) throw new Error('manifest.pricing values must be non-negative numbers')
293
+ if (commissionRate <= 0 || commissionRate > 100 || oneTimePrice !== 0) throw new Error('commission pricing requires commission_rate between 0 and 100 and zero one_time_price')
294
+ }
295
+ // Every application uses the same top-level page. Hosting mode and desktop
296
+ // support are platform policy rather than app manifest configuration. Keep
297
+ // accepting those fields when reading older manifests; they are ignored.
298
+ const entry = 'index.html'
299
+ return { manifest, sourceManifest, entry }
300
+ }
301
+
302
+ async function validateApp(appDir, { checkBuildManifest = true, source = false } = {}) {
148
303
  const root = resolve(appDir)
149
304
  const { manifest, entry } = await readManifest(root)
150
- const artifactRoot = (await exists(join(root, 'dist'))) ? join(root, 'dist') : root
305
+ // The package identity is carried by manifest.id. A legacy sidecar may be
306
+ // present, but it is deliberately ignored here: local validation must not
307
+ // turn the removed link/repair workflow into an upload prerequisite. The
308
+ // server performs the authoritative identity check against the app selected
309
+ // by the management API before accepting a version.
310
+ const artifactRoot = !source && (await exists(join(root, 'dist'))) ? join(root, 'dist') : root
151
311
  if (!(await exists(join(artifactRoot, entry)))) throw new Error(`runtime entry is missing: ${entry}`)
152
312
  if (artifactRoot !== root && /(?:src|href)=["']\//i.test(await readFile(join(artifactRoot, entry), 'utf8'))) {
153
313
  throw new Error("built entry contains root-relative assets; configure the bundler to emit relative URLs (Vite: base: './')")
@@ -155,17 +315,94 @@ async function validateApp(appDir) {
155
315
  if (manifest.assets?.icon && !(await exists(join(artifactRoot, safeRelative(manifest.assets.icon))))) {
156
316
  console.warn(`warning: manifest.assets.icon is missing: ${manifest.assets.icon}`)
157
317
  }
318
+ if (checkBuildManifest && artifactRoot !== root && await exists(join(artifactRoot, 'manifest.json'))) {
319
+ const builtManifest = JSON.parse(await readFile(join(artifactRoot, 'manifest.json'), 'utf8'))
320
+ if (JSON.stringify(builtManifest) !== JSON.stringify(manifest)) {
321
+ throw new Error('构建产物与当前 manifest 不一致,请运行 proxy-app pack 重新构建打包。')
322
+ }
323
+ }
324
+ await loadAgentPackage(artifactRoot, manifest)
158
325
  return { root, artifactRoot, manifest, entry }
159
326
  }
160
327
 
161
- function run(command, args, cwd) {
328
+ function run(command, args, cwd, env = process.env) {
162
329
  return new Promise((resolvePromise, reject) => {
163
- const child = spawn(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
164
- child.on('error', reject)
165
- child.on('exit', (code, signal) => code === 0 ? resolvePromise() : reject(new Error(`${command} exited with ${code ?? signal}`)))
330
+ const child = spawn(command, args, { cwd, env, stdio: 'inherit', detached: process.platform !== 'win32', shell: process.platform === 'win32' })
331
+ let canceled = false
332
+ const stop = (signal) => {
333
+ canceled = true
334
+ try {
335
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal)
336
+ else child.kill(signal)
337
+ } catch { /* The build may already have exited. */ }
338
+ }
339
+ const interrupt = () => stop('SIGINT')
340
+ const terminate = () => stop('SIGTERM')
341
+ const cleanup = () => {
342
+ process.removeListener('SIGINT', interrupt)
343
+ process.removeListener('SIGTERM', terminate)
344
+ }
345
+ process.once('SIGINT', interrupt)
346
+ process.once('SIGTERM', terminate)
347
+ child.on('error', (error) => { cleanup(); reject(error) })
348
+ child.on('close', (code, signal) => {
349
+ cleanup()
350
+ if (code === 0 && !canceled) resolvePromise()
351
+ else reject(new Error(`${command} exited with ${code ?? signal}${canceled ? ' (构建已取消)' : ''}`))
352
+ })
166
353
  })
167
354
  }
168
355
 
356
+ async function buildApp(appDir) {
357
+ const root = resolve(appDir)
358
+ if (process.env.PROXY_APP_BUILD_ACTIVE === root) throw new Error('package.json 的 build 脚本不能调用 proxy-app build/pack/upload/publish,请直接调用 Vite 等构建工具。')
359
+ const before = await readManifest(root)
360
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
361
+ if (!pkg.scripts?.build) throw new Error('package.json 缺少 build 脚本,无法重新构建应用。')
362
+ const staging = join(root, '.proxy-app-build')
363
+ const dist = join(root, 'dist')
364
+ try { await mkdir(staging) } catch (error) {
365
+ if (error.code === 'EEXIST') throw new Error('该应用正在构建(.proxy-app-build)。若上次进程异常退出,请先检查此目录内保留的旧 dist,再清理目录重试。')
366
+ throw error
367
+ }
368
+ let previousDist = false
369
+ let started = false
370
+ let preserveBackup = false
371
+ try {
372
+ if (await exists(dist)) {
373
+ await rename(dist, join(staging, 'dist'))
374
+ previousDist = true
375
+ }
376
+ started = true
377
+ console.log('正在重新构建应用…')
378
+ await run('npm', ['run', 'build'], root, { ...process.env, PROXY_APP_BUILD_ACTIVE: root })
379
+ const outputInfo = await lstat(dist).catch((error) => { if (error.code !== 'ENOENT') throw error; return null })
380
+ if (!outputInfo?.isDirectory() || outputInfo.isSymbolicLink()) throw new Error('build output is missing or invalid: build 脚本必须生成真实的 dist 目录。')
381
+ await copyAgentPackage(root, dist, before.manifest)
382
+ const info = await validateApp(root, { checkBuildManifest: false })
383
+ if (info.artifactRoot === root) throw new Error('build output is missing: build 脚本必须生成 dist 目录,不能把源码作为发布包。')
384
+ if (JSON.stringify(before.manifest) !== JSON.stringify(info.manifest)) {
385
+ throw new Error('manifest 或代码能力契约在构建期间发生变更,请重新运行 proxy-app pack,避免配置与资源版本不一致。')
386
+ }
387
+ // Stamp only newly generated output, never a successful no-op over old dist.
388
+ await writeFile(join(info.artifactRoot, 'manifest.json'), JSON.stringify(info.manifest, null, 2) + '\n')
389
+ return info
390
+ } catch (error) {
391
+ if (started) {
392
+ try {
393
+ await rm(dist, { recursive: true, force: true })
394
+ if (previousDist) await rename(join(staging, 'dist'), dist)
395
+ } catch {
396
+ preserveBackup = true
397
+ throw new Error(`${error.message}\n旧构建保留在 ${staging},请检查后恢复。`)
398
+ }
399
+ }
400
+ throw error
401
+ } finally {
402
+ if (!preserveBackup) await rm(staging, { recursive: true, force: true })
403
+ }
404
+ }
405
+
169
406
  function runChild(command, args, cwd, env) {
170
407
  return new Promise((resolvePromise, reject) => {
171
408
  const child = spawn(command, args, { cwd, env, stdio: 'inherit', shell: process.platform === 'win32' })
@@ -184,38 +421,76 @@ function runChild(command, args, cwd, env) {
184
421
 
185
422
  async function devApp(appDir, args) {
186
423
  const root = resolve(appDir)
424
+ if (process.env.PROXY_APP_DEV_ACTIVE === root) {
425
+ throw new Error('package.json 的 dev:server 脚本必须直接调用 Vite 等开发服务器,不能调用 proxy-app dev 或 npm run dev。')
426
+ }
187
427
  const { manifest } = await readManifest(root)
188
- const supportedOptions = new Set(['--port', '--legacy-bridge'])
428
+ const supportedOptions = new Set(['--port', '--no-auth-cache'])
189
429
  for (const arg of args) {
190
430
  if (arg.startsWith('--') && !supportedOptions.has(arg)) {
191
431
  throw new Error(`unsupported dev option: ${arg}; local development always uses the real platform`)
192
432
  }
193
433
  }
194
- const binding = await projectBinding(root)
195
- if (binding.slug !== manifest.id) throw new Error('应用标识与本地绑定不一致,请先运行 proxy-app link 或重新创建应用')
434
+ // Development starts with the public manifest identity. Owner-authorized
435
+ // registration resolves the stable ID in memory before creating sessions.
436
+ const binding = { app_id: manifest.id, slug: manifest.id }
437
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
438
+ const devScript = ['dev:server', 'dev'].find(name => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim())
439
+ if (!devScript) throw new Error('package.json 缺少 dev:server 脚本,请配置 Vite 等开发服务器启动命令。')
196
440
  const requestedPort = option(args, '--port', process.env.PROXY_APP_DEV_PORT || '5173')
197
441
  if (!/^\d+$/.test(requestedPort) || Number(requestedPort) < 1 || Number(requestedPort) > 65535) throw new Error('--port must be between 1 and 65535')
198
- const entryURL = process.env.PROXY_APP_DEV_URL || `http://127.0.0.1:${requestedPort}/`
442
+ const configuredEntryURL = process.env.PROXY_APP_DEV_URL?.trim() || ''
443
+ let portReservation = configuredEntryURL ? null : await reserveAvailablePort(Number(requestedPort))
444
+ const selectedPort = portReservation?.port || Number(requestedPort)
445
+ if (!configuredEntryURL && selectedPort !== Number(requestedPort)) {
446
+ console.warn(`端口 ${requestedPort} 已被占用,改用 ${selectedPort}。`)
447
+ }
448
+ const entryURL = configuredEntryURL || `http://127.0.0.1:${selectedPort}/`
199
449
  let parsedEntryURL
200
450
  try { parsedEntryURL = new URL(entryURL) } catch { throw new Error('PROXY_APP_DEV_URL must be a valid loopback http(s) URL') }
201
451
  if (!['http:', 'https:'].includes(parsedEntryURL.protocol) || !['localhost', '127.0.0.1', '::1'].includes(parsedEntryURL.hostname) || parsedEntryURL.username || parsedEntryURL.password) {
202
452
  throw new Error('PROXY_APP_DEV_URL must use localhost, 127.0.0.1, or ::1 without credentials')
203
453
  }
204
454
  const relaySecret = randomUUID()
205
- const baseURL = (process.env.PROXY_API_BASE_URL || 'http://localhost:9003/api').replace(/\/$/, '')
206
- const portalURL = process.env.PROXY_PORTAL_URL || process.env.PROXY_PUBLIC_URL || 'http://localhost:9004'
207
- const legacyBridge = args.includes('--legacy-bridge')
208
- const relay = await createLocalDevelopmentRelay({
209
- baseURL,
210
- portalURL,
211
- relaySecret,
212
- appID: binding.app_id,
213
- appSlug: manifest.id,
214
- version: manifest.version,
215
- manifest,
216
- entryURL,
217
- legacyBridge,
455
+ const baseURL = platformAPIBaseURL()
456
+ const portalURL = platformPortalURL()
457
+ const storeSettings = { baseURL, portalURL, appID: binding.app_id, projectPath: root }
458
+ const credentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore(storeSettings)
459
+ const developerCredentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore({
460
+ ...storeSettings, purpose: 'development',
218
461
  })
462
+ const cachedOperations = (store) => {
463
+ if (!store) return null
464
+ let fallbackReported = store.kind !== 'keychain'
465
+ const operation = (method) => async (...values) => {
466
+ try { return await store[method](...values) }
467
+ finally {
468
+ if (!fallbackReported && store.kind === 'memory') {
469
+ fallbackReported = true
470
+ console.warn(store.reason)
471
+ }
472
+ }
473
+ }
474
+ return { load: operation('load'), save: operation('save'), clear: operation('clear') }
475
+ }
476
+ let relay
477
+ try {
478
+ relay = await createLocalDevelopmentRelay({
479
+ baseURL,
480
+ portalURL,
481
+ relaySecret,
482
+ appID: binding.app_id,
483
+ appSlug: manifest.id,
484
+ version: manifest.version,
485
+ manifest,
486
+ entryURL,
487
+ credentialStore: cachedOperations(credentialStore),
488
+ developerCredentialStore: cachedOperations(developerCredentialStore),
489
+ })
490
+ } catch (error) {
491
+ await portReservation?.release()
492
+ throw error
493
+ }
219
494
  const {
220
495
  PROXY_USER_ACCESS_TOKEN: _userToken,
221
496
  PROXY_ACCESS_TOKEN: _legacyToken,
@@ -225,35 +500,81 @@ async function devApp(appDir, args) {
225
500
  PROXY_APP_DEV_URL: _entryURL,
226
501
  PROXY_APP_DEV_PORT: _devPort,
227
502
  PROXY_PLATFORM_GATEWAY_URL: _gatewayURL,
228
- PROXY_PLATFORM_RUNTIME_URL: _runtimeURL,
229
- PROXY_PLATFORM_BRIDGE_URL: _bridgeURL,
230
503
  PROXY_PLATFORM_AUTH_URL: _authURL,
231
504
  PROXY_PLATFORM_RELAY_SECRET: _inheritedRelaySecret,
232
505
  ...safeChildEnv
233
506
  } = process.env
234
507
  const childEnv = {
235
508
  ...safeChildEnv,
509
+ PROXY_APP_DEV_ACTIVE: root,
236
510
  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
511
  PROXY_PLATFORM_AUTH_URL: `${relay.relayURL}/auth`,
239
512
  PROXY_PLATFORM_RELAY_SECRET: relaySecret,
240
513
  }
241
- console.log(`Proxy App platform relay ready: ${relay.relayURL}`)
242
- console.log(`Local app: ${entryURL}`)
243
- console.log(`Runtime transport: ${legacyBridge ? 'legacy Bridge compatibility' : 'same-origin HTTP (/v1, /api/app-runtime/v1)'}`)
244
- console.log('Platform mode: sign in from the app to use real models, provider routing, quota and billing.')
514
+ const preparation = new AbortController()
515
+ let canceledSignal = ''
516
+ const cancel = (signal) => { canceledSignal = signal; preparation.abort() }
517
+ const interrupt = () => cancel('SIGINT')
518
+ const terminate = () => cancel('SIGTERM')
519
+ const removePreparationListeners = () => {
520
+ process.removeListener('SIGINT', interrupt)
521
+ process.removeListener('SIGTERM', terminate)
522
+ }
523
+ process.once('SIGINT', interrupt)
524
+ process.once('SIGTERM', terminate)
245
525
  try {
246
- await runChild('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', requestedPort, '--strictPort'], root, childEnv)
526
+ console.log(`正在检查开发应用:${manifest.id}@${manifest.version}`)
527
+ console.log(`平台 API 地址:${baseURL}`)
528
+ console.log(`开发授权缓存:${developerCredentialStore?.kind === 'keychain' ? 'macOS 钥匙串,与应用用户登录独立保存' : '仅当前进程内存'}`)
529
+ await relay.prepareDevelopment({
530
+ token: _userToken || _legacyToken || '',
531
+ signal: preparation.signal,
532
+ onAuthorizationRequired: ({ login_url }) => {
533
+ console.log('开发环境尚未授权,或已有开发授权已失效。请使用应用创建者账户打开以下链接:')
534
+ console.log('开发者授权入口:')
535
+ console.log(login_url)
536
+ console.log('完成开发者授权并通过应用登记检查后才会启动本地应用。按 Ctrl+C 取消。')
537
+ },
538
+ })
539
+ preparation.signal.throwIfAborted()
540
+ removePreparationListeners()
541
+ console.log('开发应用检查通过。应用用户将在应用内独立登录。')
542
+ console.log(`Proxy App platform relay ready: ${relay.relayURL}`)
543
+ console.log(`Local app: ${entryURL}`)
544
+ console.log('App transport: App Session Gateway (/v1); local relay auth is /__platform/auth.')
545
+ console.log('Platform mode: sign in from the app to use real models, provider routing, quota and billing.')
546
+ await portReservation?.release()
547
+ portReservation = null
548
+ await runChild('npm', ['run', devScript, '--', '--host', '127.0.0.1', '--port', String(selectedPort), '--strictPort'], root, childEnv)
549
+ } catch (error) {
550
+ if (!canceledSignal) {
551
+ const guidance = []
552
+ if (error?.stage === 'app_registration') {
553
+ if ([403, 404].includes(error.statusCode)) {
554
+ guidance.push('请核对 manifest.json 中的 id(应用 slug)与上述平台地址,确认应用已在该平台创建。开发不需要绑定或 repair。')
555
+ guidance.push('请使用应用创建者账户完成开发授权,并确认终端显示的平台地址与应用所在平台一致。')
556
+ guidance.push('如果设置了 PROXY_USER_ACCESS_TOKEN,请确认它属于应用创建者;旧账户令牌请清除或取消设置后重新运行普通 proxy-app dev。')
557
+ } else if (error.statusCode === 401) {
558
+ guidance.push('请确认 PROXY_USER_ACCESS_TOKEN 属于应用创建者;如果该变量是旧账户令牌,请清除或取消设置后重新运行普通 proxy-app dev。')
559
+ }
560
+ if (typeof error.requestId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(error.requestId)) {
561
+ guidance.push(`请求编号:${error.requestId}`)
562
+ }
563
+ }
564
+ if (guidance.length) error.message += '\n' + guidance.join('\n')
565
+ throw error
566
+ }
567
+ console.error('已取消开发环境初始化,本地应用未启动。')
568
+ process.exitCode = canceledSignal === 'SIGINT' ? 130 : 143
247
569
  } finally {
570
+ removePreparationListeners()
571
+ await portReservation?.release()
248
572
  await relay.close()
249
573
  }
250
574
  }
251
575
 
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
- }
576
+ async function packApp(appDir, output, { allowSource = false, allowSourceMaps = allowSource } = {}) {
577
+ const info = allowSource ? await validateApp(appDir, { source: true }) : await buildApp(appDir)
257
578
  const outputPath = resolve(output || join(info.root, `${info.manifest.id}-${info.manifest.version}.zip`))
258
579
  const files = {}
259
580
  const collect = async (directory, prefix = '') => {
@@ -271,7 +592,13 @@ async function packApp(appDir, output, { allowSource = false } = {}) {
271
592
  }
272
593
  if (info.artifactRoot === info.root) await collect(info.root)
273
594
  else await collect(info.artifactRoot)
274
- files['manifest.json'] = new Uint8Array(await readFile(join(info.root, 'manifest.json')))
595
+ if (JSON.stringify((await readManifest(info.root)).manifest) !== JSON.stringify(info.manifest)) {
596
+ throw new Error('manifest 或代码能力契约在打包期间发生变更,请重新运行 proxy-app pack。')
597
+ }
598
+ files['manifest.json'] = new TextEncoder().encode(JSON.stringify(info.manifest, null, 2) + '\n')
599
+ for (const [filePath, data] of Object.entries(files)) {
600
+ assertSafeArtifactFile(filePath, data, { allowSourceMaps })
601
+ }
275
602
  const bundle = zipSync(files, { level: 9 })
276
603
  if (bundle.length > MAX_BUNDLE_BYTES) throw new Error(`bundle is larger than ${MAX_BUNDLE_BYTES} bytes`)
277
604
  await mkdir(dirname(outputPath), { recursive: true })
@@ -280,14 +607,75 @@ async function packApp(appDir, output, { allowSource = false } = {}) {
280
607
  return { ...info, outputPath }
281
608
  }
282
609
 
610
+ function defaultAgentNamespace(identifier) {
611
+ return identifier.replace(/^app\./, '').replace(/[^a-z0-9]+/g, '_').slice(0, 48) || 'app'
612
+ }
613
+
614
+ async function replaceGeneratedNamespace(root, previous, next) {
615
+ if (!previous || previous === next) return
616
+ for (const relative of ['agent/guide.md', 'agent/workflow.md', 'README.md', 'src/agent-tools.mjs']) {
617
+ const target = join(root, relative)
618
+ const source = await readFile(target, 'utf8').catch(() => null)
619
+ if (source !== null) {
620
+ const replaced = source.replaceAll(`proxy_${previous}_`, `proxy_${next}_`)
621
+ .replaceAll(`"namespace": "${previous}"`, `"namespace": "${next}"`)
622
+ .replaceAll(`namespace: ${JSON.stringify(previous)}`, `namespace: ${JSON.stringify(next)}`)
623
+ .replaceAll(`'namespace': '${previous}'`, `'namespace': '${next}'`)
624
+ .replaceAll(`namespace: '${previous}'`, `namespace: '${next}'`)
625
+ if (replaced !== source) await writeFile(target, replaced)
626
+ }
627
+ }
628
+ }
629
+
630
+ async function replaceGeneratedIdentifier(root, previous, next) {
631
+ if (!previous || previous === next) return
632
+ const target = join(root, 'agent', 'examples.json')
633
+ const source = await readFile(target, 'utf8').catch(() => null)
634
+ if (source !== null) await writeFile(target, source.replaceAll(previous, next))
635
+ }
636
+
283
637
  async function writeProject(root, app) {
638
+ const cliPackage = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'))
284
639
  await mkdir(join(root, 'src'), { recursive: true })
285
- await writeFile(join(root, 'manifest.json'), JSON.stringify({ schema: 'proxy.app/v1', id: app.slug, version: '0.1.0', name: app.name, description: app.description || '', runtime: { type: 'iframe', entry: 'index.html' }, permissions: [], apis: [], backend: { type: 'platform' }, pricing: { mode: 'free', currency: 'CNY' } }, null, 2) + '\n')
640
+ await mkdir(join(root, 'agent'), { recursive: true })
641
+ const namespace = defaultAgentNamespace(app.slug)
642
+ const manifest = {
643
+ schema: 'proxy.app/v1', id: app.slug, version: '0.1.0', name: app.name, description: app.description || '',
644
+ }
645
+ await writeFile(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
286
646
  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', 'main.ts'), `import { createAppClient } from '@ducking-mind/proxy-app-sdk'\n\nconst client = createAppClient(${JSON.stringify(app.slug)}, { localDevelopment: import.meta.env.DEV, localAppVersion: '0.1.0' })\nconst root = document.querySelector<HTMLElement>('#app')\nif (root) root.textContent = ${JSON.stringify(app.name)}\nvoid client.platform.capabilities().then(console.log)\n`)
647
+ await writeFile(join(root, 'src', 'agent-tools.mjs'), [
648
+ '// This module is shared by the application and the package builder.',
649
+ '// Keep it independent of browser state and expose only JSON-serializable metadata.',
650
+ 'export const agentMetadata = Object.freeze({',
651
+ ' namespace: ' + JSON.stringify(namespace) + ", targets: ['web', 'desktop'], contract_version: '1.0.0',",
652
+ ' summary: ' + JSON.stringify(app.name + ' 的应用能力') + ', keywords: [' + JSON.stringify(app.name) + '],',
653
+ " guide: { entry: 'agent/guide.md', topics: [{ id: 'workflow', title: '调用流程', entry: 'agent/workflow.md' }] },",
654
+ " examples: 'agent/examples.json',",
655
+ '})',
656
+ '',
657
+ 'export const capabilities = [{',
658
+ " name: 'read_metadata', title: '读取应用元数据', description: '读取应用的标识、名称和版本。',",
659
+ " access: 'read', idempotent: true, targets: ['web', 'desktop'],",
660
+ " input_schema: { type: 'object', properties: {}, required: [], additionalProperties: false },",
661
+ " output_schema: { type: 'object', properties: { appId: { type: 'string' }, version: { type: 'string' }, name: { type: 'string' } }, required: ['appId', 'version', 'name'], additionalProperties: false },",
662
+ '}]',
663
+ '',
664
+ 'export function capability(name) {',
665
+ ' const declaration = capabilities.find(item => item.name === name)',
666
+ " if (!declaration) throw new Error('Unknown application capability: ' + name)",
667
+ ' return declaration',
668
+ '}',
669
+ ].join('\n') + '\n')
670
+ 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`)
671
+ 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
672
  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: 'ES2020', module: 'ESNext', moduleResolution: 'Bundler', strict: true, lib: ['ES2020', 'DOM'], types: ['vite/client'], noEmit: true }, include: ['src', 'vite.config.ts'] }, null, 2) + '\n')
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')
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')
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')
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`)
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`)
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')
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`)
291
679
  }
292
680
 
293
681
  async function replaceIdentifier(root, previous, next) {
@@ -314,26 +702,30 @@ async function initApp(appDir, args) {
314
702
  const name = option(args, '--name', root.split(sep).pop() || 'Proxy App')
315
703
  const app = { id: '', slug: `app.proxy.local.${randomUUID().replaceAll('-', '').slice(0, 16)}`, name, description: '' }
316
704
  await writeProject(root, app)
317
- console.log(JSON.stringify({ created: root, local_identifier: app.slug, next: [`cd ${root}`, 'npm install', `proxy-app create ${root}`, `proxy-app dev ${root}`, 'npm run build'] }, null, 2))
705
+ 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
706
  }
319
707
 
320
708
  async function createApp(appDir, args) {
321
709
  const root = resolve(appDir)
322
710
  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)
711
+ const { sourceManifest: manifest } = await readManifest(root)
324
712
  const localIdentifier = manifest.id
325
713
  const name = option(args, '--name', manifest.name || root.split(sep).pop() || 'Proxy App')
326
- const category = option(args, '--category', 'productivity')
714
+ const category = option(args, '--category', 'other')
327
715
  if (!ALLOWED_CATEGORIES.has(category)) throw new Error(`unsupported app category: ${category}`)
328
716
  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' || typeof app.slug !== 'string' || !/^[a-z0-9]+(?:[.-][a-z0-9]+)+(?:[.-][a-z0-9]+)*$/.test(app.slug)) {
330
- throw new Error('platform returned an invalid app binding; expected id and lowercase reverse-domain slug')
717
+ if (!app || typeof app.id !== 'string' || !app.id || !validPublicAppSlug(app.slug)) {
718
+ throw new Error('platform returned an invalid app binding; expected app_id and a 1–120 character lowercase slug')
331
719
  }
332
- manifest.id = app.slug
720
+ const previousNamespace = defaultAgentNamespace(localIdentifier)
721
+ const nextNamespace = defaultAgentNamespace(app.id)
722
+ await replaceGeneratedNamespace(root, previousNamespace, nextNamespace)
723
+ manifest.id = app.id
724
+ await replaceGeneratedIdentifier(root, localIdentifier, app.id)
333
725
  await writeFile(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
334
- await replaceIdentifier(root, localIdentifier, app.slug)
335
- await writeFile(join(root, '.proxy-app.json'), JSON.stringify({ app_id: app.id, slug: app.slug }, null, 2) + '\n')
336
- console.log(JSON.stringify({ created: root, app_id: app.id, app_identifier: app.slug, next: [`proxy-app upload ${root}`] }, null, 2))
726
+ await replaceIdentifier(root, localIdentifier, app.id)
727
+ await writeFile(join(root, '.proxy-app.json'), JSON.stringify(appBinding(app), null, 2) + '\n')
728
+ console.log(JSON.stringify({ created: root, identifier: app.slug, app_id: app.id, next: [`proxy-app upload ${root}`] }, null, 2))
337
729
  }
338
730
 
339
731
  async function linkApp(appDir, appID) {
@@ -342,101 +734,161 @@ async function linkApp(appDir, appID) {
342
734
  const app = (result.items || []).find((item) => item.id === appID)
343
735
  if (!app) throw new Error(`application not found: ${appID}`)
344
736
  const manifestPath = join(root, 'manifest.json')
345
- const { manifest } = await readManifest(root)
346
- if (typeof app.slug !== 'string' || !/^[a-z0-9]+(?:[.-][a-z0-9]+)+(?:[.-][a-z0-9]+)*$/.test(app.slug)) {
737
+ const { sourceManifest: manifest } = await readManifest(root)
738
+ if (!validPublicAppSlug(app.slug) || typeof app.id !== 'string' || !app.id) {
347
739
  throw new Error(`application has an invalid identifier: ${app.slug || '(missing)'}`)
348
740
  }
349
741
  const previous = manifest.id
350
- manifest.id = app.slug
742
+ const previousNamespace = defaultAgentNamespace(previous)
743
+ const nextNamespace = defaultAgentNamespace(app.id)
744
+ await replaceGeneratedNamespace(root, previousNamespace, nextNamespace)
745
+ manifest.id = app.id
746
+ await replaceGeneratedIdentifier(root, previous, app.id)
351
747
  await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
352
- await replaceIdentifier(root, previous, app.slug)
353
- await writeFile(join(root, '.proxy-app.json'), JSON.stringify({ app_id: app.id, slug: app.slug }, null, 2) + '\n')
354
- console.log(JSON.stringify({ linked: root, app_id: app.id, app_identifier: app.slug }, null, 2))
748
+ await replaceIdentifier(root, previous, app.id)
749
+ await writeFile(join(root, '.proxy-app.json'), JSON.stringify(appBinding(app), null, 2) + '\n')
750
+ console.log(JSON.stringify({ linked: root, identifier: app.slug, app_id: app.id }, null, 2))
355
751
  }
356
752
 
357
- async function projectBinding(appDir) {
358
- try {
359
- const config = JSON.parse(await readFile(join(resolve(appDir), '.proxy-app.json'), 'utf8'))
360
- if (typeof config.app_id === 'string' && config.app_id && typeof config.slug === 'string' && /^[a-z0-9]+(?:[.-][a-z0-9]+)+(?:[.-][a-z0-9]+)*$/.test(config.slug)) return config
361
- } catch {}
362
- throw new Error('app binding is missing or invalid; run proxy-app create or proxy-app link first')
753
+ // Repair is the user-facing binding command. It keeps the legacy slug in the
754
+ // local file for compatibility, but users only need to provide app_id.
755
+ async function repairApp(appDir, appID) {
756
+ const root = resolve(appDir)
757
+ const existing = await readFile(join(root, '.proxy-app.json'), 'utf8').then(JSON.parse).catch(() => ({}))
758
+ const target = appID || existing.app_id
759
+ if (!target) throw new Error('缺少 app_id,请使用 proxy-app repair <app-id>')
760
+ await linkApp(root, target)
363
761
  }
364
762
 
365
- async function projectAppID(appDir) {
366
- return (await projectBinding(appDir)).app_id
763
+ // Management commands use the platform app identity carried by manifest.id.
764
+ // A sidecar binding may remain in an older checkout, but it is intentionally
765
+ // ignored: existing apps can be uploaded/published directly after setting
766
+ // manifest.id to the app's public slug (or stable app_id). This keeps the
767
+ // development and release flows consistent and avoids making `link` a hidden
768
+ // prerequisite for an otherwise valid package.
769
+ async function resolveManagedApp(appDir) {
770
+ const root = resolve(appDir)
771
+ const { manifest } = await readManifest(root)
772
+ const result = await apiJSON(APP_WORKSPACE_PATH)
773
+ const items = Array.isArray(result?.items) ? result.items : []
774
+ // Resolve the durable app ID from the public manifest identity. Existing
775
+ // checkouts therefore work without a sidecar file or a link/repair command;
776
+ // stale compatibility sidecars are deliberately ignored.
777
+ const app = items.find((item) => matchesAppIdentity(manifest.id, item?.id, item?.slug, publicAppAliases(item)))
778
+ if (!app) {
779
+ throw new Error(`应用不存在或当前账户无权访问:${manifest.id}。请确认 manifest.id 是该应用的公开 slug;新应用请运行 proxy-app create。`)
780
+ }
781
+ return { root, manifest, app, binding: appBinding(app) }
367
782
  }
368
783
 
369
784
  async function developerApp(appDir) {
370
- const appID = await projectAppID(appDir)
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
785
+ return (await resolveManagedApp(appDir)).app
375
786
  }
376
787
 
377
- async function submitAppVersion(appID, manifest) {
378
- const { token, baseURL } = apiSettings()
379
- const submit = await fetch(`${baseURL}${APP_WORKSPACE_PATH}/${encodeURIComponent(appID)}/versions/${encodeURIComponent(manifest.version)}/submit`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ pricing_mode: manifest.pricing?.mode || 'free', one_time_price: manifest.pricing?.one_time_price || 0, commission_rate: manifest.pricing?.commission_rate || 0, currency: manifest.pricing?.currency || 'CNY' }) })
380
- const submitBody = await submit.json().catch(() => ({}))
381
- if (!submit.ok) throw new Error(responseError(submitBody, `submit failed: HTTP ${submit.status}`))
382
- return submitBody.data || submitBody
788
+ async function requestAppOpening(appID, version) {
789
+ // The version is review material for the application opening request.
790
+ // Pricing remains the application's saved configuration.
791
+ return apiJSON(`${APP_WORKSPACE_PATH}/${encodeURIComponent(appID)}/opening`, {
792
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
793
+ body: JSON.stringify({ version }),
794
+ })
795
+ }
796
+
797
+ function pendingAppOpening(app) {
798
+ if (Object.hasOwn(app, 'opening_request')) {
799
+ return app.opening_request?.status === 'pending' ? app.opening_request : null
800
+ }
801
+ // Old servers expose the candidate through a version status. Never prefer
802
+ // that compatibility mirror over the application's canonical request.
803
+ const version = (app.versions || []).find((item) => item.review_status === 'review')
804
+ return version ? { version: version.version, status: 'pending' } : null
805
+ }
806
+
807
+ function reportPendingOpening(app, version) {
808
+ const pending = pendingAppOpening(app)
809
+ if (!pending) return false
810
+ if (pending.version !== version) {
811
+ throw new Error(`应用开放申请正在审核(预览版本 ${pending.version}),请在“我的应用”撤回申请后再更换审核材料。`)
812
+ }
813
+ const result = { submitted: false, reason: '应用开放申请正在审核,无需重复提交。', app_id: app.id, opening_request: pending, version }
814
+ console.log(JSON.stringify(result, null, 2))
815
+ return result
383
816
  }
384
817
 
385
818
  async function uploadApp(appDir, output, options = {}) {
386
- const binding = await projectBinding(appDir)
819
+ const { app } = await resolveManagedApp(appDir)
387
820
  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
- }
821
+ const bundleInfo = await packApp(appDir, output, { ...options, allowSourceMaps: false })
392
822
  const bundle = await readFile(bundleInfo.outputPath)
393
823
  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(binding.app_id)}/versions`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
824
+ const response = await fetch(`${baseURL}${APP_WORKSPACE_PATH}/${encodeURIComponent(app.id)}/versions`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
395
825
  const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(responseError(body, `upload failed: HTTP ${response.status}`))
396
- console.log(JSON.stringify({ uploaded: body.data || body, next: `proxy-app publish ${appDir}` }, null, 2))
397
- return body.data || body
826
+ const uploaded = body.data || body
827
+ console.log(JSON.stringify({
828
+ uploaded,
829
+ message: '版本已上传,线上版本未切换。测试后使用 publish 手动发布。',
830
+ next: `proxy-app publish ${appDir} --version ${bundleInfo.manifest.version}`,
831
+ }, null, 2))
832
+ return uploaded
398
833
  }
399
834
 
400
- async function publishApp(appDir, output) {
401
- const info = await validateApp(appDir)
402
- const remoteApp = await developerApp(appDir)
403
- if (remoteApp.slug !== info.manifest.id) {
404
- throw new Error('应用标识不匹配,请先同步当前应用绑定,再重新 build 和 pack。')
835
+ async function publishApp(appDir, output, selectedVersion) {
836
+ // Existing uploaded versions are immutable. Only a new/local draft goes
837
+ // through uploadApp's build; no dist is needed to publish a remote version.
838
+ const info = await readManifest(resolve(appDir))
839
+ const targetVersion = selectedVersion || info.manifest.version
840
+ let remoteApp = await developerApp(appDir)
841
+ let version = (remoteApp.versions || []).find((item) => item.version === targetVersion)
842
+ if (selectedVersion && (!version || (!version.artifact_size && !version.artifact_sha256))) {
843
+ throw new Error(`版本 ${targetVersion} 尚未上传。--version 只选择已有上传产物,请先上传该版本。`)
844
+ }
845
+ if (!remoteApp.current_version) {
846
+ if (remoteApp.status === 'disabled') throw new Error('应用已被平台禁用,解除限制后才能申请开放。修复包可使用 proxy-app upload 上传。')
847
+ const pending = reportPendingOpening(remoteApp, targetVersion)
848
+ if (pending) return pending
405
849
  }
406
- let version = (remoteApp.versions || []).find((item) => item.version === info.manifest.version)
407
850
  let uploaded = null
408
- if (!version) {
851
+ if (!version || (version.review_status === 'draft' && !version.artifact_size && !version.artifact_sha256)) {
409
852
  uploaded = await uploadApp(appDir, output)
410
853
  version = uploaded
854
+ remoteApp = await developerApp(appDir)
411
855
  }
412
- if (['review', 'published'].includes(version.review_status)) {
413
- console.log(JSON.stringify({ submitted: false, reason: `version is already ${version.review_status}`, app_id: remoteApp.id, version: info.manifest.version }, null, 2))
414
- return
856
+ if (!remoteApp.current_version) {
857
+ const pending = reportPendingOpening(remoteApp, targetVersion)
858
+ if (pending) return pending
415
859
  }
416
- if (!['draft', 'rejected'].includes(version.review_status)) throw new Error(`version cannot be submitted from status: ${version.review_status || 'unknown'}`)
417
- const submitted = await submitAppVersion(remoteApp.id, info.manifest)
418
- console.log(JSON.stringify({ uploaded, submitted, app_id: remoteApp.id, version: info.manifest.version }, null, 2))
860
+ if (remoteApp.current_version === targetVersion) {
861
+ const result = { submitted: false, changed: false, reason: '该版本已是当前线上版本,无需切换。', app_id: remoteApp.id, version: targetVersion }
862
+ console.log(JSON.stringify(result, null, 2))
863
+ return result
864
+ }
865
+ if (remoteApp.current_version) {
866
+ if (!['draft', 'rejected', 'review', 'published'].includes(version.review_status)) throw new Error(`version cannot be published from status: ${version.review_status || 'unknown'}`)
867
+ const published = await apiJSON(`${APP_WORKSPACE_PATH}/${encodeURIComponent(remoteApp.id)}/versions/${encodeURIComponent(targetVersion)}/publish`, { method: 'POST' })
868
+ const result = { uploaded, published, submitted: false, changed: true, message: '线上版本已手动切换;应用保持当前上架或限制状态。', app_id: remoteApp.id, previous_version: remoteApp.current_version, version: targetVersion }
869
+ console.log(JSON.stringify(result, null, 2))
870
+ return result
871
+ }
872
+ 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'}`)
873
+ const submitted = await requestAppOpening(remoteApp.id, targetVersion)
874
+ const result = { uploaded, submitted: true, opening: submitted, changed: false, message: '应用开放申请已提交,所选版本用于预览和审核。', app_id: remoteApp.id, version: targetVersion }
875
+ console.log(JSON.stringify(result, null, 2))
876
+ return result
419
877
  }
420
878
 
421
879
  async function statusApp(appDir) {
422
880
  const root = resolve(appDir)
423
881
  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
882
  const app = await developerApp(root)
431
883
  const version = (app.versions || []).find((item) => item.version === manifest.version)
432
- console.log(JSON.stringify({ linked: true, app_id: app.id, app_identifier: app.slug, app_status: app.status, local_version: manifest.version, remote_version: version || null }, null, 2))
884
+ 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
885
  }
434
886
 
435
887
  async function doctorApp(appDir) {
436
888
  const root = resolve(appDir)
437
889
  const checks = []
438
- const major = Number(process.versions.node.split('.')[0])
439
- checks.push({ name: 'node', ok: major >= 20, detail: process.version })
890
+ const [major, minor] = process.versions.node.split('.').map(Number)
891
+ checks.push({ name: 'node', ok: major > 22 || (major === 22 && minor >= 18), detail: process.version })
440
892
  try {
441
893
  const info = await validateApp(root)
442
894
  checks.push({ name: 'manifest', ok: true, detail: `${info.manifest.id}@${info.manifest.version}` })
@@ -444,12 +896,7 @@ async function doctorApp(appDir) {
444
896
  } catch (error) {
445
897
  checks.push({ name: 'manifest', ok: false, detail: error.message })
446
898
  }
447
- try {
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
- }
899
+ checks.push({ name: 'binding', ok: true, detail: '使用 manifest.id 解析平台应用,无需 link 或 repair' })
453
900
  const valid = checks.every((item) => item.ok)
454
901
  console.log(JSON.stringify({ ok: valid, ready_for_upload: valid, checks }, null, 2))
455
902
  if (!valid) process.exitCode = 1
@@ -457,36 +904,22 @@ async function doctorApp(appDir) {
457
904
 
458
905
  const [, , command, ...args] = process.argv
459
906
  try {
460
- if (!command || command === '--help' || command === '-h') usage()
461
- else if (command === 'dev') await devApp(args[0] || '.', args.slice(1))
462
- else if (command === 'init') await initApp(args[0] || '.', args.slice(1))
463
- else if (command === 'create') {
464
- if (!args[0]) throw new Error('create requires <app-dir>')
465
- await createApp(args[0], args.slice(1))
466
- }
467
- else if (command === 'link') {
468
- if (!args[1]) throw new Error('link requires <app-dir> <app-id>')
469
- await linkApp(args[0], args[1])
470
- }
471
- else if (command === 'status') await statusApp(args[0] || '.')
472
- else if (command === 'doctor') await doctorApp(args[0] || '.')
473
- else if (command === 'validate') {
474
- const info = await validateApp(args[0] || '.')
475
- console.log(JSON.stringify({ valid: true, id: info.manifest.id, version: info.manifest.version, entry: info.entry }, null, 2))
476
- } else if (command === 'build') {
477
- await run('npm', ['run', 'build'], resolve(args[0] || '.'))
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}`)
907
+ if (!command || command === '--help' || command === '-h' || args.includes('--help') || args.includes('-h')) usage()
908
+ else {
909
+ const { appDir, appID, output, options } = commandArguments(command, args)
910
+ if (command === 'dev') await devApp(appDir, options)
911
+ else if (command === 'init') await initApp(appDir, options)
912
+ else if (command === 'create') await createApp(appDir, options)
913
+ else if (command === 'repair' || command === 'link') await repairApp(appDir, appID)
914
+ else if (command === 'status') await statusApp(appDir)
915
+ else if (command === 'doctor') await doctorApp(appDir)
916
+ else if (command === 'validate') {
917
+ const info = await validateApp(appDir)
918
+ console.log(JSON.stringify({ valid: true, id: info.manifest.id, version: info.manifest.version, entry: info.entry }, null, 2))
919
+ } else if (command === 'build') await buildApp(appDir)
920
+ else if (command === 'pack') await packApp(appDir, output, { allowSource: options.includes('--source') })
921
+ else if (command === 'upload') await uploadApp(appDir, output, { allowSource: options.includes('--source') })
922
+ else if (command === 'publish') await publishApp(appDir, output, option(options, '--version'))
490
923
  }
491
924
  } catch (error) {
492
925
  console.error(`proxy app: ${error instanceof Error ? error.message : String(error)}`)