@ducking-mind/proxy-app-cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/proxy-app.mjs CHANGED
@@ -9,16 +9,21 @@ import { zipSync } from 'fflate'
9
9
  import { createLocalDevelopmentRelay } from './local-dev-relay.mjs'
10
10
  import { createLocalCredentialStore } from './local-dev-credentials.mjs'
11
11
  import { assertSafeArtifactFile, assertSafeArtifactDirectory } from './artifact-security.mjs'
12
+ import { readLocalFunctionSources } from './runtime-sources.mjs'
12
13
  import { resolveAgentContract } from './agent-contract.mjs'
13
- import { platformAPIBaseURL, platformPortalURL } from './platform-settings.mjs'
14
+ import { DEFAULT_PLATFORM_GATEWAY_URL, platformAPIBaseURL, platformPortalURL } from './platform-settings.mjs'
15
+ import { createBackendContainerSpec, dockerClientEnvironment, verifyDockerEngine } from './backend-container.mjs'
14
16
 
15
17
  const MAX_BUNDLE_BYTES = 50 * 1024 * 1024
18
+ const APP_BACKEND_TYPES = new Set(['platform', 'container'])
19
+ const APP_CONTAINER_RUNTIME = 'node22'
20
+ const APP_BACKEND_DEV_PATH = '/api'
16
21
  const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'platform.capabilities.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
17
22
  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
23
  const ALLOWED_PRICING_MODES = new Set(['commission'])
19
24
  const ALLOWED_CATEGORIES = new Set(['ecommerce', 'comic', 'other'])
20
25
  const RESERVED_AGENT_NAMESPACES = new Set(['app', 'codex', 'desktop', 'proxy', 'system'])
21
- const BUNDLE_IGNORED_FILES = new Set(['.proxy-app.json', '.proxy-app-build'])
26
+ const BUNDLE_IGNORED_FILES = new Set(['.proxy-app.json', '.proxy-app-session.json', '.proxy-app-build', '.proxy-app-runtime'])
22
27
  const APP_WORKSPACE_PATH = '/portal/user/app-workspace'
23
28
  // Public slugs are used verbatim, including single-segment names such as
24
29
  // "canvas". The platform's opaque app_id remains a separate compatibility
@@ -64,6 +69,8 @@ pack 无需提前执行 build 或 validate;已上传的版本由 publish 直
64
69
 
65
70
  upload 只上传;publish 显式切换线上版本。首次使用 publish 仍需申请应用开放。
66
71
  --version 仅选择已上传版本,不构建、不上传、不修改本地 manifest。
72
+ backend.type=container 仅支持本地联调;云端构建和容器路由尚未开放。
73
+ 容器后端本地运行需要已启动的 Docker Engine;服务进程在只读、非特权 Node 22 容器中启动。
67
74
  -o 与 --output 等价,也兼容原来的 <app-dir> <output.zip> 写法。
68
75
  --source 仅供 pack/upload 调试源码,跳过构建,不用于正式发布。
69
76
  应用分类:ecommerce(电商)、comic(漫剧)、other(其他,默认)。
@@ -73,19 +80,21 @@ Publish environment:
73
80
  PROXY_API_BASE_URL default: https://www.duckingmind.com/api
74
81
  Optional override when the platform uses a separate API host
75
82
  PROXY_PORTAL_URL Optional login Portal override; default: https://www.duckingmind.com
76
- PROXY_USER_ACCESS_TOKEN Current platform user session for app management/CI only; never passed to the app
83
+ PROXY_PLATFORM_GATEWAY_URL Gateway target for Vite /v1 proxy; default: ${DEFAULT_PLATFORM_GATEWAY_URL}
84
+ PROXY_USER_ACCESS_TOKEN Platform user session for management, CI or developer authorization; never passed to the app
77
85
  PROXY_ACCESS_TOKEN Legacy alias for PROXY_USER_ACCESS_TOKEN
78
86
 
79
87
  Local development:
80
88
  proxy-app dev <app-dir> [--port 5173]
81
- If the requested port is occupied, the next available loopback port is used.
89
+ An explicitly requested occupied port fails with a clear error; the default port may advance automatically.
82
90
  Checks the app binding and developer permission before starting the app.
83
91
  First authorize the developer using the terminal login URL; then app users
84
92
  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.
93
+ App business traffic always uses the App Session Gateway (/v1). Vite sends
94
+ /v1 directly to the Gateway; set PROXY_PLATFORM_GATEWAY_URL only when using
95
+ a different Gateway environment. Runtime user sessions stay in the browser;
96
+ the local authorization callback handles developer setup only.
97
+ macOS stores developer authorization in Keychain.
89
98
  --no-auth-cache uses memory only. If a cached developer account cannot access
90
99
  the app, dev automatically asks you to authorize with the app creator account.
91
100
 
@@ -102,7 +111,10 @@ function option(args, name, fallback = '') {
102
111
 
103
112
  function reservePort(port) {
104
113
  return new Promise((resolvePromise, reject) => {
105
- const server = createTCPServer()
114
+ // A platform health check or browser may connect while this socket reserves
115
+ // the selected port. Destroy probes immediately so server.close() cannot
116
+ // wait forever for a connection the temporary listener never serves.
117
+ const server = createTCPServer((socket) => socket.destroy())
106
118
  let settled = false
107
119
  const settle = (callback, value) => {
108
120
  if (settled) return
@@ -215,6 +227,175 @@ function safeRelative(value) {
215
227
  return normalized.split('/').filter(Boolean).join('/')
216
228
  }
217
229
 
230
+ function validateBackendManifest(backend) {
231
+ if (backend === undefined || backend?.type === undefined || backend?.type === 'platform') return
232
+ if (!APP_BACKEND_TYPES.has(backend.type)) throw new Error('manifest.backend.type must be platform or container')
233
+ if (backend.type !== 'container') return
234
+ const supportedFields = new Set(['type', 'runtime', 'entry', 'port', 'health_check', 'routes', 'resources'])
235
+ for (const name of Object.keys(backend)) {
236
+ if (!supportedFields.has(name)) throw new Error(`unsupported manifest.backend field: ${name}`)
237
+ }
238
+ if (backend.runtime !== APP_CONTAINER_RUNTIME) throw new Error(`manifest.backend.runtime must be ${APP_CONTAINER_RUNTIME}`)
239
+ if (typeof backend.entry !== 'string') throw new Error('manifest.backend.entry is required for container backends')
240
+ let entry
241
+ try {
242
+ entry = safeRelative(backend.entry)
243
+ } catch (error) {
244
+ throw new Error(`manifest.backend.entry is invalid: ${error.message}`)
245
+ }
246
+ if (backend.entry.replaceAll('\\', '/') !== entry) {
247
+ throw new Error('manifest.backend.entry must be a normalized relative path')
248
+ }
249
+ if (!entry.startsWith('server/')) throw new Error('manifest.backend.entry must be inside server/')
250
+ if (entry.split('/').some((part) => part === '.' || part === '')) throw new Error('manifest.backend.entry must be a normalized relative path')
251
+ if (!['.js', '.mjs', '.cjs'].includes(entry.slice(entry.lastIndexOf('.')).toLowerCase())) {
252
+ throw new Error('manifest.backend.entry must be a JavaScript file (.js, .mjs, or .cjs)')
253
+ }
254
+ if (!Number.isInteger(backend.port) || backend.port < 1 || backend.port > 65535) {
255
+ throw new Error('manifest.backend.port must be an integer between 1 and 65535')
256
+ }
257
+ if (backend.health_check !== undefined && (
258
+ typeof backend.health_check !== 'string' || !backend.health_check.startsWith('/') ||
259
+ backend.health_check.startsWith('//') || /[?#\\]/.test(backend.health_check) || backend.health_check.split('/').includes('..')
260
+ )) {
261
+ throw new Error('manifest.backend.health_check must be an absolute safe path')
262
+ }
263
+ if (backend.routes !== undefined) {
264
+ if (!Array.isArray(backend.routes) || backend.routes.length === 0) throw new Error('manifest.backend.routes must be a non-empty array')
265
+ const seen = new Set()
266
+ for (const route of backend.routes) {
267
+ if (typeof route !== 'string' || !route.startsWith(`${APP_BACKEND_DEV_PATH}/`) ||
268
+ route.startsWith('//') || /[?#\\]/.test(route) || route.split('/').includes('..') ||
269
+ !(route.endsWith('/*') || !route.includes('*'))) {
270
+ throw new Error(`manifest.backend.routes entries must be safe ${APP_BACKEND_DEV_PATH} paths`)
271
+ }
272
+ if (seen.has(route)) throw new Error(`duplicate manifest.backend.routes entry: ${route}`)
273
+ seen.add(route)
274
+ }
275
+ }
276
+ if (backend.resources !== undefined) {
277
+ const resources = backend.resources
278
+ if (!resources || typeof resources !== 'object' || Array.isArray(resources)) throw new Error('manifest.backend.resources must be an object')
279
+ const bounds = { cpu_millicores: [50, 4000], memory_mib: [64, 8192], max_concurrency: [1, 1000] }
280
+ for (const [name, value] of Object.entries(resources)) {
281
+ const range = bounds[name]
282
+ if (!range) throw new Error(`unsupported manifest.backend.resources field: ${name}`)
283
+ if (!Number.isInteger(value) || value < range[0] || value > range[1]) {
284
+ throw new Error(`manifest.backend.resources.${name} must be an integer between ${range[0]} and ${range[1]}`)
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ // Runtime Host evaluates release entries as ES modules in QuickJS. Keep the
291
+ // CLI contract aligned with Admin so a package cannot pass local validation
292
+ // and then fail publication because its entry is CommonJS.
293
+ const FUNCTION_ENTRY_EXTENSIONS = new Set(['.js', '.mjs'])
294
+ const FUNCTION_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
295
+
296
+ function validateFunctionPath(value, field, { source = false } = {}) {
297
+ if (typeof value !== 'string') throw new Error(`manifest.functions.${field} must be a string`)
298
+ let normalized
299
+ try {
300
+ normalized = safeRelative(value)
301
+ } catch (error) {
302
+ throw new Error(`manifest.functions.${field} is invalid: ${error.message}`)
303
+ }
304
+ if (value.replaceAll('\\', '/') !== normalized || normalized.split('/').some(part => part === '.' || part === '')) {
305
+ throw new Error(`manifest.functions.${field} must be a normalized relative path`)
306
+ }
307
+ const extension = normalized.slice(normalized.lastIndexOf('.')).toLowerCase()
308
+ const allowed = source ? FUNCTION_SOURCE_EXTENSIONS : FUNCTION_ENTRY_EXTENSIONS
309
+ if (!allowed.has(extension)) {
310
+ throw new Error(`manifest.functions.${field} must be a ${source ? 'TypeScript or JavaScript' : 'JavaScript'} file`)
311
+ }
312
+ if (!source && !normalized.startsWith('runtime/')) throw new Error(`manifest.functions.${field} must be inside runtime/`)
313
+ if (source && !normalized.startsWith('src/')) throw new Error(`manifest.functions.${field} must be inside src/`)
314
+ return normalized
315
+ }
316
+
317
+ function validateFunctionRoutes(routes, field) {
318
+ if (routes === undefined) return
319
+ if (!Array.isArray(routes) || routes.length === 0) throw new Error(`manifest.functions.${field}.routes must be a non-empty array`)
320
+ const seen = new Set()
321
+ for (const route of routes) {
322
+ if (typeof route !== 'string' || !route.startsWith('/api/') || route.startsWith('//') || /[?#\\]/.test(route) || route.split('/').includes('..') || route.includes('*') && !route.endsWith('/*')) {
323
+ throw new Error(`manifest.functions.${field}.routes entries must be safe /api/ paths`)
324
+ }
325
+ if (seen.has(route)) throw new Error(`duplicate manifest.functions.${field}.routes entry: ${route}`)
326
+ seen.add(route)
327
+ }
328
+ }
329
+
330
+ function validateFunctionsManifest(functions) {
331
+ if (functions === undefined) return
332
+ if (!functions || typeof functions !== 'object' || Array.isArray(functions)) throw new Error('manifest.functions must be an object')
333
+ const names = Object.keys(functions)
334
+ if (!names.length || names.some(name => !['request', 'task'].includes(name))) throw new Error('manifest.functions may contain only request and task')
335
+ for (const name of names) {
336
+ const descriptor = functions[name]
337
+ if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) throw new Error(`manifest.functions.${name} must be an object`)
338
+ const supported = name === 'request'
339
+ ? new Set(['entry', 'source', 'routes', 'timeout_ms'])
340
+ : new Set(['entry', 'source', 'concurrency', 'max_attempts', 'timeout_ms'])
341
+ for (const field of Object.keys(descriptor)) if (!supported.has(field)) throw new Error(`unsupported manifest.functions.${name} field: ${field}`)
342
+ validateFunctionPath(descriptor.entry, `${name}.entry`)
343
+ if (descriptor.source !== undefined) validateFunctionPath(descriptor.source, `${name}.source`, { source: true })
344
+ if (name === 'request') {
345
+ validateFunctionRoutes(descriptor.routes, name)
346
+ if (!descriptor.routes?.length) throw new Error('manifest.functions.request.routes must declare at least one /api/ path')
347
+ if (descriptor.timeout_ms !== undefined && (!Number.isInteger(descriptor.timeout_ms) || descriptor.timeout_ms < 100 || descriptor.timeout_ms > 10 * 60 * 1000)) {
348
+ throw new Error('manifest.functions.request.timeout_ms must be an integer between 100 and 600000')
349
+ }
350
+ } else {
351
+ const bounds = { concurrency: [1, 1000], max_attempts: [1, 20], timeout_ms: [100, 3_600_000] }
352
+ for (const [field, value] of Object.entries(descriptor)) {
353
+ const range = bounds[field]
354
+ if (!range) continue
355
+ if (!Number.isInteger(value) || value < range[0] || value > range[1]) throw new Error(`manifest.functions.task.${field} must be an integer between ${range[0]} and ${range[1]}`)
356
+ }
357
+ }
358
+ }
359
+ if (functions.request && functions.task && functions.request.entry === functions.task.entry) {
360
+ throw new Error('manifest.functions.request.entry and task.entry must be different')
361
+ }
362
+ }
363
+
364
+ function validateDatabaseManifest(database) {
365
+ if (database !== undefined) throw new Error('manifest.database is no longer supported; use context.platform.request() through Gateway APIs')
366
+ }
367
+
368
+ async function validateFunctionEntries(appDir, artifactRoot, functions, { requireArtifact = false } = {}) {
369
+ if (!functions) return
370
+ for (const [name, descriptor] of Object.entries(functions)) {
371
+ if (descriptor.source && !(await exists(join(resolve(appDir), safeRelative(descriptor.source))))) {
372
+ throw new Error(`manifest.functions.${name}.source does not exist: ${descriptor.source}`)
373
+ }
374
+ const entry = safeRelative(descriptor.entry)
375
+ if (requireArtifact && !(await exists(join(artifactRoot, entry)))) {
376
+ throw new Error(`manifest.functions.${name}.entry does not exist in build output: ${entry}`)
377
+ }
378
+ }
379
+ }
380
+
381
+ async function validateBackendEntry(appDir, backend) {
382
+ if (backend?.type !== 'container') return
383
+ const relative = safeRelative(backend.entry)
384
+ const segments = relative.split('/')
385
+ let current = resolve(appDir)
386
+ for (const segment of segments) {
387
+ current = join(current, segment)
388
+ const info = await lstat(current).catch((error) => {
389
+ if (error.code === 'ENOENT') throw new Error(`manifest.backend.entry does not exist: ${relative}`)
390
+ throw error
391
+ })
392
+ if (info.isSymbolicLink()) throw new Error(`manifest.backend.entry cannot contain symbolic links: ${relative}`)
393
+ if (current !== join(resolve(appDir), relative) && !info.isDirectory()) throw new Error(`manifest.backend.entry parent is not a directory: ${relative}`)
394
+ }
395
+ const info = await lstat(current)
396
+ if (!info.isFile()) throw new Error(`manifest.backend.entry must be a regular file: ${relative}`)
397
+ }
398
+
218
399
  async function exists(path) {
219
400
  try {
220
401
  await stat(path)
@@ -239,10 +420,9 @@ async function readManifest(appDir) {
239
420
  if (manifest.backend !== undefined && (!manifest.backend || typeof manifest.backend !== 'object' || Array.isArray(manifest.backend))) {
240
421
  throw new Error('manifest.backend must be an object')
241
422
  }
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')
423
+ validateBackendManifest(manifest.backend)
424
+ validateFunctionsManifest(manifest.functions)
425
+ validateDatabaseManifest(manifest.database)
246
426
  // Permissions are optional metadata. An omitted list means the app accepts
247
427
  // the platform's currently supported public capabilities; an explicit list
248
428
  // remains available for legacy packages that intentionally narrow access.
@@ -302,6 +482,7 @@ async function readManifest(appDir) {
302
482
  async function validateApp(appDir, { checkBuildManifest = true, source = false } = {}) {
303
483
  const root = resolve(appDir)
304
484
  const { manifest, entry } = await readManifest(root)
485
+ await validateBackendEntry(root, manifest.backend)
305
486
  // The package identity is carried by manifest.id. A legacy sidecar may be
306
487
  // present, but it is deliberately ignored here: local validation must not
307
488
  // turn the removed link/repair workflow into an upload prerequisite. The
@@ -315,6 +496,7 @@ async function validateApp(appDir, { checkBuildManifest = true, source = false }
315
496
  if (manifest.assets?.icon && !(await exists(join(artifactRoot, safeRelative(manifest.assets.icon))))) {
316
497
  console.warn(`warning: manifest.assets.icon is missing: ${manifest.assets.icon}`)
317
498
  }
499
+ await validateFunctionEntries(root, artifactRoot, manifest.functions, { requireArtifact: artifactRoot !== root })
318
500
  if (checkBuildManifest && artifactRoot !== root && await exists(join(artifactRoot, 'manifest.json'))) {
319
501
  const builtManifest = JSON.parse(await readFile(join(artifactRoot, 'manifest.json'), 'utf8'))
320
502
  if (JSON.stringify(builtManifest) !== JSON.stringify(manifest)) {
@@ -425,6 +607,8 @@ async function devApp(appDir, args) {
425
607
  throw new Error('package.json 的 dev:server 脚本必须直接调用 Vite 等开发服务器,不能调用 proxy-app dev 或 npm run dev。')
426
608
  }
427
609
  const { manifest } = await readManifest(root)
610
+ await validateBackendEntry(root, manifest.backend)
611
+ const functionFiles = await readLocalFunctionSources(root, manifest)
428
612
  const supportedOptions = new Set(['--port', '--no-auth-cache'])
429
613
  for (const arg of args) {
430
614
  if (arg.startsWith('--') && !supportedOptions.has(arg)) {
@@ -435,12 +619,35 @@ async function devApp(appDir, args) {
435
619
  // registration resolves the stable ID in memory before creating sessions.
436
620
  const binding = { app_id: manifest.id, slug: manifest.id }
437
621
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
622
+ const backend = manifest.backend?.type === 'container' ? manifest.backend : null
623
+ const gatewayURL = process.env.PROXY_PLATFORM_GATEWAY_URL?.trim() || DEFAULT_PLATFORM_GATEWAY_URL
624
+ let backendContainer
625
+ if (backend) {
626
+ const dockerBin = process.env.PROXY_APP_DOCKER_BIN || 'docker'
627
+ const dockerEnv = dockerClientEnvironment(process.env)
628
+ await verifyDockerEngine({ dockerBin, env: dockerEnv })
629
+ backendContainer = await createBackendContainerSpec({
630
+ appDir: root,
631
+ backend,
632
+ appID: manifest.id,
633
+ version: manifest.version,
634
+ gatewayURL,
635
+ dockerBin,
636
+ dockerEnv,
637
+ })
638
+ }
438
639
  const devScript = ['dev:server', 'dev'].find(name => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim())
439
640
  if (!devScript) throw new Error('package.json 缺少 dev:server 脚本,请配置 Vite 等开发服务器启动命令。')
440
641
  const requestedPort = option(args, '--port', process.env.PROXY_APP_DEV_PORT || '5173')
441
642
  if (!/^\d+$/.test(requestedPort) || Number(requestedPort) < 1 || Number(requestedPort) > 65535) throw new Error('--port must be between 1 and 65535')
442
643
  const configuredEntryURL = process.env.PROXY_APP_DEV_URL?.trim() || ''
443
- let portReservation = configuredEntryURL ? null : await reserveAvailablePort(Number(requestedPort))
644
+ const portWasExplicit = args.includes('--port') || Boolean(process.env.PROXY_APP_DEV_PORT?.trim())
645
+ let portReservation = configuredEntryURL ? null : portWasExplicit
646
+ ? await reservePort(Number(requestedPort))
647
+ : await reserveAvailablePort(Number(requestedPort))
648
+ if (!configuredEntryURL && portWasExplicit && !portReservation) {
649
+ throw new Error(`端口 ${requestedPort} 已被占用;请停止占用该端口的旧开发服务器,或改用 --port <可用端口> 后重试。`)
650
+ }
444
651
  const selectedPort = portReservation?.port || Number(requestedPort)
445
652
  if (!configuredEntryURL && selectedPort !== Number(requestedPort)) {
446
653
  console.warn(`端口 ${requestedPort} 已被占用,改用 ${selectedPort}。`)
@@ -449,13 +656,20 @@ async function devApp(appDir, args) {
449
656
  let parsedEntryURL
450
657
  try { parsedEntryURL = new URL(entryURL) } catch { throw new Error('PROXY_APP_DEV_URL must be a valid loopback http(s) URL') }
451
658
  if (!['http:', 'https:'].includes(parsedEntryURL.protocol) || !['localhost', '127.0.0.1', '::1'].includes(parsedEntryURL.hostname) || parsedEntryURL.username || parsedEntryURL.password) {
659
+ await portReservation?.release()
452
660
  throw new Error('PROXY_APP_DEV_URL must use localhost, 127.0.0.1, or ::1 without credentials')
453
661
  }
454
- const relaySecret = randomUUID()
662
+ let backendPortReservation = null
663
+ if (backend) {
664
+ backendPortReservation = await reservePort(backend.port)
665
+ if (!backendPortReservation) {
666
+ await portReservation?.release()
667
+ throw new Error(`应用后端端口 ${backend.port} 已被占用;请修改 manifest.backend.port 后重试。`)
668
+ }
669
+ }
455
670
  const baseURL = platformAPIBaseURL()
456
671
  const portalURL = platformPortalURL()
457
672
  const storeSettings = { baseURL, portalURL, appID: binding.app_id, projectPath: root }
458
- const credentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore(storeSettings)
459
673
  const developerCredentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore({
460
674
  ...storeSettings, purpose: 'development',
461
675
  })
@@ -478,17 +692,17 @@ async function devApp(appDir, args) {
478
692
  relay = await createLocalDevelopmentRelay({
479
693
  baseURL,
480
694
  portalURL,
481
- relaySecret,
482
695
  appID: binding.app_id,
483
696
  appSlug: manifest.id,
484
697
  version: manifest.version,
485
698
  manifest,
699
+ functionFiles,
486
700
  entryURL,
487
- credentialStore: cachedOperations(credentialStore),
488
701
  developerCredentialStore: cachedOperations(developerCredentialStore),
489
702
  })
490
703
  } catch (error) {
491
704
  await portReservation?.release()
705
+ await backendPortReservation?.release()
492
706
  throw error
493
707
  }
494
708
  const {
@@ -499,17 +713,35 @@ async function devApp(appDir, args) {
499
713
  PROXY_PUBLIC_URL: _publicURL,
500
714
  PROXY_APP_DEV_URL: _entryURL,
501
715
  PROXY_APP_DEV_PORT: _devPort,
716
+ PROXY_APP_DEV_AUTH_RELAY_URL: _staleAuthRelayURL,
717
+ PROXY_APP_DEV_AUTH_RELAY_SECRET: _staleAuthRelaySecret,
718
+ PROXY_APP_DEV_SESSION_GRANT: _staleDevelopmentSessionGrant,
502
719
  PROXY_PLATFORM_GATEWAY_URL: _gatewayURL,
720
+ PROXY_PLATFORM_RUNTIME_URL: _runtimeURL,
721
+ PROXY_PLATFORM_BRIDGE_URL: _bridgeURL,
503
722
  PROXY_PLATFORM_AUTH_URL: _authURL,
504
- PROXY_PLATFORM_RELAY_SECRET: _inheritedRelaySecret,
723
+ PROXY_APP_BACKEND_URL: _inheritedBackendURL,
724
+ PROXY_APP_BACKEND_PATH: _inheritedBackendPath,
505
725
  ...safeChildEnv
506
726
  } = process.env
727
+ // Keep the same-origin Vite proxy pointed at Gateway. Runtime user sessions
728
+ // are obtained by the Portal in the browser; the CLI relay is limited to
729
+ // developer authorization and draft registration.
730
+ const selectedGatewayURL = _gatewayURL?.trim() || gatewayURL
731
+ const browserPortalURL = new URL(portalURL).origin
507
732
  const childEnv = {
508
733
  ...safeChildEnv,
509
734
  PROXY_APP_DEV_ACTIVE: root,
510
- PROXY_PLATFORM_GATEWAY_URL: `${relay.relayURL}/v1`,
511
- PROXY_PLATFORM_AUTH_URL: `${relay.relayURL}/auth`,
512
- PROXY_PLATFORM_RELAY_SECRET: relaySecret,
735
+ PROXY_PLATFORM_GATEWAY_URL: selectedGatewayURL,
736
+ VITE_PROXY_PORTAL_URL: browserPortalURL,
737
+ PROXY_APP_DEV_SESSION_API_URL: baseURL,
738
+ PROXY_APP_DEV_SESSION_PORTAL_URL: browserPortalURL,
739
+ PROXY_APP_DEV_SESSION_ID: manifest.id,
740
+ PROXY_APP_DEV_SESSION_VERSION: manifest.version,
741
+ ...(backend ? {
742
+ PROXY_APP_BACKEND_URL: `http://127.0.0.1:${backend.port}`,
743
+ PROXY_APP_BACKEND_PATH: APP_BACKEND_DEV_PATH,
744
+ } : {}),
513
745
  }
514
746
  const preparation = new AbortController()
515
747
  let canceledSignal = ''
@@ -536,20 +768,36 @@ async function devApp(appDir, args) {
536
768
  console.log('完成开发者授权并通过应用登记检查后才会启动本地应用。按 Ctrl+C 取消。')
537
769
  },
538
770
  })
771
+ childEnv.PROXY_APP_DEV_SESSION_GRANT = relay.getDevelopmentSessionGrant()
772
+ console.log('开发应用检查通过。')
773
+ preparation.signal.throwIfAborted()
539
774
  preparation.signal.throwIfAborted()
540
775
  removePreparationListeners()
541
- console.log('开发应用检查通过。应用用户将在应用内独立登录。')
542
- console.log(`Proxy App platform relay ready: ${relay.relayURL}`)
543
776
  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.')
777
+ if (backend) {
778
+ console.log(`Local app backend: http://127.0.0.1:${backend.port}${APP_BACKEND_DEV_PATH}/*`)
779
+ console.log(`Backend runtime: Docker (${backend.runtime}); source is mounted read-only and app dependencies are installed in container tmpfs.`)
780
+ }
781
+ console.log('App transport: Gateway App Session (/v1); browser requests never leave the Gateway data plane.')
782
+ console.log('App Session: sign in through the Portal when prompted; the browser keeps it in this tab.')
546
783
  await portReservation?.release()
547
784
  portReservation = null
548
- await runChild('npm', ['run', devScript, '--', '--host', '127.0.0.1', '--port', String(selectedPort), '--strictPort'], root, childEnv)
785
+ await backendPortReservation?.release()
786
+ backendPortReservation = null
787
+ const children = []
788
+ if (backendContainer) children.push(backendContainer)
789
+ children.push({
790
+ command: 'npm',
791
+ args: ['run', devScript, '--', '--host', '127.0.0.1', '--port', String(selectedPort), '--strictPort'],
792
+ cwd: root,
793
+ env: childEnv,
794
+ name: 'npm',
795
+ })
796
+ await runChildren(children)
549
797
  } catch (error) {
550
798
  if (!canceledSignal) {
551
799
  const guidance = []
552
- if (error?.stage === 'app_registration') {
800
+ if (error?.stage === 'app_registration' && ['APP_APP_REGISTRATION_FAILED', 'APP_LOGIN_SESSION_EXPIRED'].includes(error?.code)) {
553
801
  if ([403, 404].includes(error.statusCode)) {
554
802
  guidance.push('请核对 manifest.json 中的 id(应用 slug)与上述平台地址,确认应用已在该平台创建。开发不需要绑定或 repair。')
555
803
  guidance.push('请使用应用创建者账户完成开发授权,并确认终端显示的平台地址与应用所在平台一致。')
@@ -557,9 +805,9 @@ async function devApp(appDir, args) {
557
805
  } else if (error.statusCode === 401) {
558
806
  guidance.push('请确认 PROXY_USER_ACCESS_TOKEN 属于应用创建者;如果该变量是旧账户令牌,请清除或取消设置后重新运行普通 proxy-app dev。')
559
807
  }
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
- }
808
+ }
809
+ if (typeof error.requestId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(error.requestId)) {
810
+ guidance.push(`请求编号:${error.requestId}`)
563
811
  }
564
812
  if (guidance.length) error.message += '\n' + guidance.join('\n')
565
813
  throw error
@@ -569,10 +817,79 @@ async function devApp(appDir, args) {
569
817
  } finally {
570
818
  removePreparationListeners()
571
819
  await portReservation?.release()
820
+ await backendPortReservation?.release()
572
821
  await relay.close()
573
822
  }
574
823
  }
575
824
 
825
+ function runChildren(specifications) {
826
+ return new Promise((resolvePromise, reject) => {
827
+ const children = []
828
+ let stopping = false
829
+ let settled = false
830
+ let firstError = null
831
+ let remaining = specifications.length
832
+ const removeSignalHandlers = () => {
833
+ process.removeListener('SIGINT', onInterrupt)
834
+ process.removeListener('SIGTERM', onTerminate)
835
+ }
836
+ const stopChild = (child, signal = 'SIGTERM') => {
837
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null) return
838
+ try {
839
+ if (process.platform !== 'win32') process.kill(-child.pid, signal)
840
+ else child.kill(signal)
841
+ } catch { /* Child exited between the state check and signal. */ }
842
+ }
843
+ const finish = () => {
844
+ if (settled || remaining > 0) return
845
+ settled = true
846
+ removeSignalHandlers()
847
+ if (firstError) reject(firstError)
848
+ else resolvePromise()
849
+ }
850
+ const stopAll = () => {
851
+ if (stopping) return
852
+ stopping = true
853
+ for (const child of children) stopChild(child)
854
+ const forceKill = setTimeout(() => {
855
+ for (const child of children) stopChild(child, 'SIGKILL')
856
+ }, 5000)
857
+ forceKill.unref()
858
+ }
859
+ const onInterrupt = () => { stopAll() }
860
+ const onTerminate = () => { stopAll() }
861
+ process.once('SIGINT', onInterrupt)
862
+ process.once('SIGTERM', onTerminate)
863
+ for (const specification of specifications) {
864
+ const child = spawn(specification.command, specification.args, {
865
+ cwd: specification.cwd,
866
+ env: specification.env,
867
+ stdio: 'inherit',
868
+ detached: process.platform !== 'win32',
869
+ shell: process.platform === 'win32',
870
+ })
871
+ children.push(child)
872
+ let childFinished = false
873
+ const finishChild = (error) => {
874
+ if (childFinished) return
875
+ childFinished = true
876
+ if (error && !firstError) firstError = error
877
+ remaining -= 1
878
+ if (error) stopAll()
879
+ finish()
880
+ }
881
+ child.once('error', (error) => {
882
+ finishChild(error)
883
+ })
884
+ child.once('close', (code, signal) => {
885
+ const unexpectedError = !stopping && code !== 0 ? new Error(`${specification.name} exited with ${code ?? signal}`) : null
886
+ finishChild(unexpectedError)
887
+ if (!stopping) stopAll()
888
+ })
889
+ }
890
+ })
891
+ }
892
+
576
893
  async function packApp(appDir, output, { allowSource = false, allowSourceMaps = allowSource } = {}) {
577
894
  const info = allowSource ? await validateApp(appDir, { source: true }) : await buildApp(appDir)
578
895
  const outputPath = resolve(output || join(info.root, `${info.manifest.id}-${info.manifest.version}.zip`))
@@ -592,6 +909,9 @@ async function packApp(appDir, output, { allowSource = false, allowSourceMaps =
592
909
  }
593
910
  if (info.artifactRoot === info.root) await collect(info.root)
594
911
  else await collect(info.artifactRoot)
912
+ if (info.artifactRoot !== info.root && info.manifest.backend?.type === 'container') {
913
+ await collect(join(info.root, 'server'), 'server')
914
+ }
595
915
  if (JSON.stringify((await readManifest(info.root)).manifest) !== JSON.stringify(info.manifest)) {
596
916
  throw new Error('manifest 或代码能力契约在打包期间发生变更,请重新运行 proxy-app pack。')
597
917
  }
@@ -646,7 +966,7 @@ async function writeProject(root, app) {
646
966
  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')
647
967
  await writeFile(join(root, 'src', 'agent-tools.mjs'), [
648
968
  '// This module is shared by the application and the package builder.',
649
- '// Keep it independent of browser state and expose only JSON-serializable metadata.',
969
+ '// Keep metadata JSON-serializable and keep each handler beside its registry entry.',
650
970
  'export const agentMetadata = Object.freeze({',
651
971
  ' namespace: ' + JSON.stringify(namespace) + ", targets: ['web', 'desktop'], contract_version: '1.0.0',",
652
972
  ' summary: ' + JSON.stringify(app.name + ' 的应用能力') + ', keywords: [' + JSON.stringify(app.name) + '],',
@@ -654,28 +974,28 @@ async function writeProject(root, app) {
654
974
  " examples: 'agent/examples.json',",
655
975
  '})',
656
976
  '',
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',
977
+ 'export function registerAgentTools(registry, app) {',
978
+ " if (!registry || typeof registry.register !== 'function') throw new TypeError('agent registry must expose register()')",
979
+ ' const collectOnly = app === undefined',
980
+ " if (!collectOnly && (!app || typeof app !== 'object')) throw new TypeError('app metadata is required')",
981
+ ' const unregister = registry.register({',
982
+ " name: 'read_metadata', title: '读取应用元数据', description: '读取应用的标识、名称和版本。',",
983
+ " access: 'read', idempotent: true, targets: ['web', 'desktop'],",
984
+ " input_schema: { type: 'object', properties: {}, required: [], additionalProperties: false },",
985
+ " output_schema: { type: 'object', properties: { appId: { type: 'string' }, version: { type: 'string' }, name: { type: 'string' } }, required: ['appId', 'version', 'name'], additionalProperties: false },",
986
+ " }, collectOnly ? undefined : async () => ({ appId: app.id, version: app.version, name: app.name }))",
987
+ " return typeof unregister === 'function' ? [unregister] : []",
668
988
  '}',
669
989
  ].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`)
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")
990
+ await writeFile(join(root, 'src', 'agent-tools.d.mts'), `import type { AgentCapabilityDeclaration, AgentContractMetadata } from '@ducking-mind/proxy-app-sdk'\nexport const agentMetadata: AgentContractMetadata\nexport function registerAgentTools(\n registry: { register(declaration: AgentCapabilityDeclaration, handler?: ((input: unknown, context: unknown) => unknown) | undefined): (() => void) | undefined },\n app?: { id: string; version: string; name: string },\n): Array<() => void>\n`)
991
+ await writeFile(join(root, 'src', 'main.ts'), `import { agent, createAppClient } from '@ducking-mind/proxy-app-sdk'\nimport manifest from '../manifest.json'\nimport { registerAgentTools } from './agent-tools.mjs'\n\nconst client = createAppClient(manifest.id, {\n runtime: import.meta.env.DEV ? 'local' : undefined,\n localDevelopment: import.meta.env.DEV,\n localAppVersion: manifest.version,\n appVersion: manifest.version,\n portalURL: import.meta.env.DEV ? (import.meta.env.VITE_PROXY_PORTAL_URL || 'https://www.duckingmind.com') : window.location.origin,\n})\nconst root = document.querySelector<HTMLElement>('#app')\nif (root) root.textContent = manifest.name\n\nconst disposers = registerAgentTools({\n register(declaration, handler) {\n if (typeof handler !== 'function') throw new Error('Agent handler is missing: ' + declaration.name)\n return agent.register(declaration, handler)\n },\n}, manifest)\nconst unregister = () => disposers.splice(0).reverse().forEach(dispose => dispose())\nif (import.meta.hot) import.meta.hot.dispose(unregister)\nagent.setStatus({ state: 'ready' }, { scope: 'app', priority: 1 })\nvoid agent.ready()\n`)
992
+ await writeFile(join(root, 'vite.config.ts'), "import { defineConfig } from 'vite'\nimport { createPlatformDevProxy, createPlatformDevSessionPlugin } from '@ducking-mind/proxy-app-sdk/vite'\n\nexport default defineConfig({\n base: './',\n plugins: [createPlatformDevSessionPlugin()],\n server: {\n host: '127.0.0.1',\n proxy: createPlatformDevProxy(),\n },\n})\n")
673
993
  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`)
994
+ 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.2.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')
995
+ 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” 的同一条 registry.register 调用中实现;构建执行注册函数并收集发布目录,无需在 manifest 中重复声明能力。\n`)
676
996
  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
997
  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`)
998
+ 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 应用。能力契约与处理器必须在 “src/agent-tools.mjs” 的同一条 registry.register 调用中实现;CLI 执行 “registerAgentTools” 并从实际注册路径生成发布目录。不要导出独立能力数组,也不要在源码 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`)
679
999
  }
680
1000
 
681
1001
  async function replaceIdentifier(root, previous, next) {
@@ -816,6 +1136,10 @@ function reportPendingOpening(app, version) {
816
1136
  }
817
1137
 
818
1138
  async function uploadApp(appDir, output, options = {}) {
1139
+ const local = await readManifest(resolve(appDir))
1140
+ if (local.manifest.backend?.type === 'container') {
1141
+ throw new Error('当前平台尚未支持云端容器后端构建与部署;容器后端版本不会上传。可先运行 proxy-app dev 本地联调。')
1142
+ }
819
1143
  const { app } = await resolveManagedApp(appDir)
820
1144
  const { token, baseURL } = apiSettings()
821
1145
  const bundleInfo = await packApp(appDir, output, { ...options, allowSourceMaps: false })
@@ -836,6 +1160,9 @@ async function publishApp(appDir, output, selectedVersion) {
836
1160
  // Existing uploaded versions are immutable. Only a new/local draft goes
837
1161
  // through uploadApp's build; no dist is needed to publish a remote version.
838
1162
  const info = await readManifest(resolve(appDir))
1163
+ if (info.manifest.backend?.type === 'container') {
1164
+ throw new Error('当前平台尚未支持云端容器后端构建与部署;此应用可通过 proxy-app dev 本地联调,不能 publish。')
1165
+ }
839
1166
  const targetVersion = selectedVersion || info.manifest.version
840
1167
  let remoteApp = await developerApp(appDir)
841
1168
  let version = (remoteApp.versions || []).find((item) => item.version === targetVersion)