@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/README.md +50 -13
- package/agent-contract.mjs +13 -6
- package/backend-container.mjs +199 -0
- package/local-dev-credentials.mjs +5 -5
- package/local-dev-relay.mjs +130 -524
- package/package.json +4 -2
- package/platform-settings.mjs +1 -0
- package/proxy-app.mjs +376 -49
- package/runtime-sources.mjs +83 -0
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
|
-
|
|
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
|
-
|
|
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).
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
|
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
|
-
|
|
561
|
-
|
|
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
|
|
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
|
|
658
|
-
"
|
|
659
|
-
|
|
660
|
-
"
|
|
661
|
-
|
|
662
|
-
'
|
|
663
|
-
'',
|
|
664
|
-
|
|
665
|
-
'
|
|
666
|
-
"
|
|
667
|
-
|
|
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
|
|
671
|
-
await writeFile(join(root, 'src', 'main.ts'), `import { agent, createAppClient } from '@ducking-mind/proxy-app-sdk'\nimport manifest from '../manifest.json'\nimport {
|
|
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.
|
|
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
|
|
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
|
|
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)
|