@ducking-mind/proxy-app-cli 0.1.1 → 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 +53 -15
- package/agent-contract.mjs +15 -11
- package/agent-runtime/package-guide.mjs +4 -53
- package/agent-runtime/protocol.mjs +4 -124
- package/backend-container.mjs +199 -0
- package/local-dev-credentials.mjs +5 -5
- package/local-dev-relay.mjs +129 -522
- package/package.json +7 -5
- package/platform-settings.mjs +20 -0
- package/prepare-agent-runtime.mjs +7 -40
- package/proxy-app.mjs +383 -304
- package/runtime-sources.mjs +83 -0
- package/agent-runtime/runtime-status.mjs +0 -70
- package/release-runtime/release-config.mjs +0 -154
- package/release-runtime/release-target.mjs +0 -48
package/proxy-app.mjs
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
-
import {
|
|
5
|
-
import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
6
5
|
import { createHash, randomUUID } from 'node:crypto'
|
|
7
|
-
import {
|
|
8
|
-
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
|
|
9
|
-
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
10
7
|
import { createServer as createTCPServer } from 'node:net'
|
|
11
8
|
import { zipSync } from 'fflate'
|
|
12
9
|
import { createLocalDevelopmentRelay } from './local-dev-relay.mjs'
|
|
13
10
|
import { createLocalCredentialStore } from './local-dev-credentials.mjs'
|
|
14
11
|
import { assertSafeArtifactFile, assertSafeArtifactDirectory } from './artifact-security.mjs'
|
|
12
|
+
import { readLocalFunctionSources } from './runtime-sources.mjs'
|
|
15
13
|
import { resolveAgentContract } from './agent-contract.mjs'
|
|
14
|
+
import { DEFAULT_PLATFORM_GATEWAY_URL, platformAPIBaseURL, platformPortalURL } from './platform-settings.mjs'
|
|
15
|
+
import { createBackendContainerSpec, dockerClientEnvironment, verifyDockerEngine } from './backend-container.mjs'
|
|
16
16
|
|
|
17
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'
|
|
18
21
|
const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'platform.capabilities.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
|
|
19
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'])
|
|
20
23
|
const ALLOWED_PRICING_MODES = new Set(['commission'])
|
|
21
24
|
const ALLOWED_CATEGORIES = new Set(['ecommerce', 'comic', 'other'])
|
|
22
25
|
const RESERVED_AGENT_NAMESPACES = new Set(['app', 'codex', 'desktop', 'proxy', 'system'])
|
|
23
|
-
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'])
|
|
24
27
|
const APP_WORKSPACE_PATH = '/portal/user/app-workspace'
|
|
25
28
|
// Public slugs are used verbatim, including single-segment names such as
|
|
26
29
|
// "canvas". The platform's opaque app_id remains a separate compatibility
|
|
@@ -37,24 +40,8 @@ const appBinding = app => ({
|
|
|
37
40
|
...(publicAppAliases(app).length ? { app_aliases: publicAppAliases(app) } : {}),
|
|
38
41
|
})
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const agentRuntimeDirectory = await stat(new URL('../desktop/agent/protocol.mjs', import.meta.url)).then(() => '../desktop/agent/').catch(error => {
|
|
43
|
-
if (error.code !== 'ENOENT') throw error
|
|
44
|
-
return './agent-runtime/'
|
|
45
|
-
})
|
|
46
|
-
const { normalizeManifestAgent } = await import(new URL(`${agentRuntimeDirectory}protocol.mjs`, import.meta.url))
|
|
47
|
-
const { loadAgentPackage, copyAgentPackage } = await import(new URL(`${agentRuntimeDirectory}package-guide.mjs`, import.meta.url))
|
|
48
|
-
|
|
49
|
-
// Source checkouts use the live desktop release helpers. npm packages carry a
|
|
50
|
-
// small prepackaged copy under release-runtime/ so `proxy-app` works after
|
|
51
|
-
// installation without the monorepo's app/desktop directory.
|
|
52
|
-
const releaseRuntimeDirectory = await stat(new URL('../desktop/scripts/release-config.mjs', import.meta.url)).then(() => '../desktop/scripts/').catch(error => {
|
|
53
|
-
if (error.code !== 'ENOENT') throw error
|
|
54
|
-
return './release-runtime/'
|
|
55
|
-
})
|
|
56
|
-
const { isAllowedReleaseURL, loadReleaseConfig, parseConfigOption, releaseConfigRoot, validateReleaseConfig } = await import(new URL(`${releaseRuntimeDirectory}release-config.mjs`, import.meta.url))
|
|
57
|
-
const { releaseFeedURL, releaseTarget } = await import(new URL(`${releaseRuntimeDirectory}release-target.mjs`, import.meta.url))
|
|
43
|
+
const { normalizeManifestAgent } = await import(new URL('./agent-runtime/protocol.mjs', import.meta.url))
|
|
44
|
+
const { loadAgentPackage, copyAgentPackage } = await import(new URL('./agent-runtime/package-guide.mjs', import.meta.url))
|
|
58
45
|
|
|
59
46
|
function usage() {
|
|
60
47
|
console.log(`在应用目录内,常用命令:
|
|
@@ -79,35 +66,35 @@ pack 无需提前执行 build 或 validate;已上传的版本由 publish 直
|
|
|
79
66
|
proxy-app pack [app-dir] [-o <output.zip>]
|
|
80
67
|
proxy-app upload [app-dir] [-o <output.zip>]
|
|
81
68
|
proxy-app publish [app-dir] [-o <output.zip> | --version <uploaded-version>]
|
|
82
|
-
proxy-app desktop-build [app-dir]
|
|
83
|
-
proxy-app desktop-pack [app-dir] [-o <output-directory>]
|
|
84
69
|
|
|
85
70
|
upload 只上传;publish 显式切换线上版本。首次使用 publish 仍需申请应用开放。
|
|
86
71
|
--version 仅选择已上传版本,不构建、不上传、不修改本地 manifest。
|
|
72
|
+
backend.type=container 仅支持本地联调;云端构建和容器路由尚未开放。
|
|
73
|
+
容器后端本地运行需要已启动的 Docker Engine;服务进程在只读、非特权 Node 22 容器中启动。
|
|
87
74
|
-o 与 --output 等价,也兼容原来的 <app-dir> <output.zip> 写法。
|
|
88
75
|
--source 仅供 pack/upload 调试源码,跳过构建,不用于正式发布。
|
|
89
|
-
desktop-pack 会在输出目录生成 desktop-release.json 和 electron-updater metadata;发布配置
|
|
90
|
-
使用 desktop/release.config.json,API Key 只用于发布请求,不会写入安装包。
|
|
91
76
|
应用分类:ecommerce(电商)、comic(漫剧)、other(其他,默认)。
|
|
92
77
|
|
|
93
78
|
Publish environment:
|
|
94
79
|
PROXY_PLATFORM_URL Optional single-origin platform URL; derives API (/api) and login Portal
|
|
95
|
-
PROXY_API_BASE_URL default:
|
|
80
|
+
PROXY_API_BASE_URL default: https://www.duckingmind.com/api
|
|
96
81
|
Optional override when the platform uses a separate API host
|
|
97
|
-
PROXY_PORTAL_URL Optional login Portal override; default:
|
|
98
|
-
|
|
82
|
+
PROXY_PORTAL_URL Optional login Portal override; default: https://www.duckingmind.com
|
|
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
|
|
99
85
|
PROXY_ACCESS_TOKEN Legacy alias for PROXY_USER_ACCESS_TOKEN
|
|
100
86
|
|
|
101
87
|
Local development:
|
|
102
88
|
proxy-app dev <app-dir> [--port 5173]
|
|
103
|
-
|
|
89
|
+
An explicitly requested occupied port fails with a clear error; the default port may advance automatically.
|
|
104
90
|
Checks the app binding and developer permission before starting the app.
|
|
105
91
|
First authorize the developer using the terminal login URL; then app users
|
|
106
92
|
sign in separately for their own App Session, models, quota and billing.
|
|
107
|
-
App business traffic always uses the App Session Gateway (/v1).
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
111
98
|
--no-auth-cache uses memory only. If a cached developer account cannot access
|
|
112
99
|
the app, dev automatically asks you to authorize with the app creator account.
|
|
113
100
|
|
|
@@ -124,7 +111,10 @@ function option(args, name, fallback = '') {
|
|
|
124
111
|
|
|
125
112
|
function reservePort(port) {
|
|
126
113
|
return new Promise((resolvePromise, reject) => {
|
|
127
|
-
|
|
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())
|
|
128
118
|
let settled = false
|
|
129
119
|
const settle = (callback, value) => {
|
|
130
120
|
if (settled) return
|
|
@@ -165,8 +155,6 @@ function commandArguments(command, args) {
|
|
|
165
155
|
pack: { values: ['--output'], flags: ['--source'], max: 2 },
|
|
166
156
|
upload: { values: ['--output'], flags: ['--source'], max: 2 },
|
|
167
157
|
publish: { values: ['--output', '--version'], max: 2 },
|
|
168
|
-
'desktop-build': { values: ['--config'], flags: ['--mac', '--macos', '-m', '--win', '--windows', '-w', '--linux', '-l', '--x64', '--arm64', '--ia32', '--env-overrides'], max: 1 },
|
|
169
|
-
'desktop-pack': { values: ['--output', '--config'], flags: ['--mac', '--macos', '-m', '--win', '--windows', '-w', '--linux', '-l', '--x64', '--arm64', '--ia32', '--env-overrides'], max: 2 },
|
|
170
158
|
}
|
|
171
159
|
const config = configurations[command]
|
|
172
160
|
if (!config) throw new Error(`unknown command: ${command}`)
|
|
@@ -214,27 +202,6 @@ function apiSettings() {
|
|
|
214
202
|
return { token, baseURL }
|
|
215
203
|
}
|
|
216
204
|
|
|
217
|
-
function platformAPIBaseURL() {
|
|
218
|
-
const explicit = process.env.PROXY_API_BASE_URL?.trim()
|
|
219
|
-
if (explicit) return explicit.replace(/\/$/, '')
|
|
220
|
-
const platform = process.env.PROXY_PLATFORM_URL?.trim()
|
|
221
|
-
if (!platform) return 'http://localhost:9003/api'
|
|
222
|
-
let url
|
|
223
|
-
try { url = new URL(platform) } catch { throw new Error('PROXY_PLATFORM_URL must be a valid platform origin, such as https://platform.example') }
|
|
224
|
-
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
|
225
|
-
throw new Error('PROXY_PLATFORM_URL must be a platform origin without a path, credentials, query, or hash')
|
|
226
|
-
}
|
|
227
|
-
return `${url.origin}/api`
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function platformPortalURL() {
|
|
231
|
-
const explicit = process.env.PROXY_PORTAL_URL?.trim() || process.env.PROXY_PUBLIC_URL?.trim()
|
|
232
|
-
if (explicit) return explicit
|
|
233
|
-
const platform = process.env.PROXY_PLATFORM_URL?.trim()
|
|
234
|
-
if (platform) return platform
|
|
235
|
-
return 'http://localhost:9004'
|
|
236
|
-
}
|
|
237
|
-
|
|
238
205
|
function responseError(body, fallback) {
|
|
239
206
|
const message = typeof body?.error === 'string' ? body.error : body?.error?.message || body?.message
|
|
240
207
|
const code = body?.code || body?.error?.code
|
|
@@ -260,6 +227,175 @@ function safeRelative(value) {
|
|
|
260
227
|
return normalized.split('/').filter(Boolean).join('/')
|
|
261
228
|
}
|
|
262
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
|
+
|
|
263
399
|
async function exists(path) {
|
|
264
400
|
try {
|
|
265
401
|
await stat(path)
|
|
@@ -284,10 +420,9 @@ async function readManifest(appDir) {
|
|
|
284
420
|
if (manifest.backend !== undefined && (!manifest.backend || typeof manifest.backend !== 'object' || Array.isArray(manifest.backend))) {
|
|
285
421
|
throw new Error('manifest.backend must be an object')
|
|
286
422
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
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)
|
|
291
426
|
// Permissions are optional metadata. An omitted list means the app accepts
|
|
292
427
|
// the platform's currently supported public capabilities; an explicit list
|
|
293
428
|
// remains available for legacy packages that intentionally narrow access.
|
|
@@ -347,6 +482,7 @@ async function readManifest(appDir) {
|
|
|
347
482
|
async function validateApp(appDir, { checkBuildManifest = true, source = false } = {}) {
|
|
348
483
|
const root = resolve(appDir)
|
|
349
484
|
const { manifest, entry } = await readManifest(root)
|
|
485
|
+
await validateBackendEntry(root, manifest.backend)
|
|
350
486
|
// The package identity is carried by manifest.id. A legacy sidecar may be
|
|
351
487
|
// present, but it is deliberately ignored here: local validation must not
|
|
352
488
|
// turn the removed link/repair workflow into an upload prerequisite. The
|
|
@@ -360,6 +496,7 @@ async function validateApp(appDir, { checkBuildManifest = true, source = false }
|
|
|
360
496
|
if (manifest.assets?.icon && !(await exists(join(artifactRoot, safeRelative(manifest.assets.icon))))) {
|
|
361
497
|
console.warn(`warning: manifest.assets.icon is missing: ${manifest.assets.icon}`)
|
|
362
498
|
}
|
|
499
|
+
await validateFunctionEntries(root, artifactRoot, manifest.functions, { requireArtifact: artifactRoot !== root })
|
|
363
500
|
if (checkBuildManifest && artifactRoot !== root && await exists(join(artifactRoot, 'manifest.json'))) {
|
|
364
501
|
const builtManifest = JSON.parse(await readFile(join(artifactRoot, 'manifest.json'), 'utf8'))
|
|
365
502
|
if (JSON.stringify(builtManifest) !== JSON.stringify(manifest)) {
|
|
@@ -470,6 +607,8 @@ async function devApp(appDir, args) {
|
|
|
470
607
|
throw new Error('package.json 的 dev:server 脚本必须直接调用 Vite 等开发服务器,不能调用 proxy-app dev 或 npm run dev。')
|
|
471
608
|
}
|
|
472
609
|
const { manifest } = await readManifest(root)
|
|
610
|
+
await validateBackendEntry(root, manifest.backend)
|
|
611
|
+
const functionFiles = await readLocalFunctionSources(root, manifest)
|
|
473
612
|
const supportedOptions = new Set(['--port', '--no-auth-cache'])
|
|
474
613
|
for (const arg of args) {
|
|
475
614
|
if (arg.startsWith('--') && !supportedOptions.has(arg)) {
|
|
@@ -480,12 +619,35 @@ async function devApp(appDir, args) {
|
|
|
480
619
|
// registration resolves the stable ID in memory before creating sessions.
|
|
481
620
|
const binding = { app_id: manifest.id, slug: manifest.id }
|
|
482
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
|
+
}
|
|
483
639
|
const devScript = ['dev:server', 'dev'].find(name => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim())
|
|
484
640
|
if (!devScript) throw new Error('package.json 缺少 dev:server 脚本,请配置 Vite 等开发服务器启动命令。')
|
|
485
641
|
const requestedPort = option(args, '--port', process.env.PROXY_APP_DEV_PORT || '5173')
|
|
486
642
|
if (!/^\d+$/.test(requestedPort) || Number(requestedPort) < 1 || Number(requestedPort) > 65535) throw new Error('--port must be between 1 and 65535')
|
|
487
643
|
const configuredEntryURL = process.env.PROXY_APP_DEV_URL?.trim() || ''
|
|
488
|
-
|
|
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
|
+
}
|
|
489
651
|
const selectedPort = portReservation?.port || Number(requestedPort)
|
|
490
652
|
if (!configuredEntryURL && selectedPort !== Number(requestedPort)) {
|
|
491
653
|
console.warn(`端口 ${requestedPort} 已被占用,改用 ${selectedPort}。`)
|
|
@@ -494,13 +656,20 @@ async function devApp(appDir, args) {
|
|
|
494
656
|
let parsedEntryURL
|
|
495
657
|
try { parsedEntryURL = new URL(entryURL) } catch { throw new Error('PROXY_APP_DEV_URL must be a valid loopback http(s) URL') }
|
|
496
658
|
if (!['http:', 'https:'].includes(parsedEntryURL.protocol) || !['localhost', '127.0.0.1', '::1'].includes(parsedEntryURL.hostname) || parsedEntryURL.username || parsedEntryURL.password) {
|
|
659
|
+
await portReservation?.release()
|
|
497
660
|
throw new Error('PROXY_APP_DEV_URL must use localhost, 127.0.0.1, or ::1 without credentials')
|
|
498
661
|
}
|
|
499
|
-
|
|
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
|
+
}
|
|
500
670
|
const baseURL = platformAPIBaseURL()
|
|
501
671
|
const portalURL = platformPortalURL()
|
|
502
672
|
const storeSettings = { baseURL, portalURL, appID: binding.app_id, projectPath: root }
|
|
503
|
-
const credentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore(storeSettings)
|
|
504
673
|
const developerCredentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore({
|
|
505
674
|
...storeSettings, purpose: 'development',
|
|
506
675
|
})
|
|
@@ -523,17 +692,17 @@ async function devApp(appDir, args) {
|
|
|
523
692
|
relay = await createLocalDevelopmentRelay({
|
|
524
693
|
baseURL,
|
|
525
694
|
portalURL,
|
|
526
|
-
relaySecret,
|
|
527
695
|
appID: binding.app_id,
|
|
528
696
|
appSlug: manifest.id,
|
|
529
697
|
version: manifest.version,
|
|
530
698
|
manifest,
|
|
699
|
+
functionFiles,
|
|
531
700
|
entryURL,
|
|
532
|
-
credentialStore: cachedOperations(credentialStore),
|
|
533
701
|
developerCredentialStore: cachedOperations(developerCredentialStore),
|
|
534
702
|
})
|
|
535
703
|
} catch (error) {
|
|
536
704
|
await portReservation?.release()
|
|
705
|
+
await backendPortReservation?.release()
|
|
537
706
|
throw error
|
|
538
707
|
}
|
|
539
708
|
const {
|
|
@@ -544,17 +713,35 @@ async function devApp(appDir, args) {
|
|
|
544
713
|
PROXY_PUBLIC_URL: _publicURL,
|
|
545
714
|
PROXY_APP_DEV_URL: _entryURL,
|
|
546
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,
|
|
547
719
|
PROXY_PLATFORM_GATEWAY_URL: _gatewayURL,
|
|
720
|
+
PROXY_PLATFORM_RUNTIME_URL: _runtimeURL,
|
|
721
|
+
PROXY_PLATFORM_BRIDGE_URL: _bridgeURL,
|
|
548
722
|
PROXY_PLATFORM_AUTH_URL: _authURL,
|
|
549
|
-
|
|
723
|
+
PROXY_APP_BACKEND_URL: _inheritedBackendURL,
|
|
724
|
+
PROXY_APP_BACKEND_PATH: _inheritedBackendPath,
|
|
550
725
|
...safeChildEnv
|
|
551
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
|
|
552
732
|
const childEnv = {
|
|
553
733
|
...safeChildEnv,
|
|
554
734
|
PROXY_APP_DEV_ACTIVE: root,
|
|
555
|
-
PROXY_PLATFORM_GATEWAY_URL:
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
} : {}),
|
|
558
745
|
}
|
|
559
746
|
const preparation = new AbortController()
|
|
560
747
|
let canceledSignal = ''
|
|
@@ -581,20 +768,36 @@ async function devApp(appDir, args) {
|
|
|
581
768
|
console.log('完成开发者授权并通过应用登记检查后才会启动本地应用。按 Ctrl+C 取消。')
|
|
582
769
|
},
|
|
583
770
|
})
|
|
771
|
+
childEnv.PROXY_APP_DEV_SESSION_GRANT = relay.getDevelopmentSessionGrant()
|
|
772
|
+
console.log('开发应用检查通过。')
|
|
773
|
+
preparation.signal.throwIfAborted()
|
|
584
774
|
preparation.signal.throwIfAborted()
|
|
585
775
|
removePreparationListeners()
|
|
586
|
-
console.log('开发应用检查通过。应用用户将在应用内独立登录。')
|
|
587
|
-
console.log(`Proxy App platform relay ready: ${relay.relayURL}`)
|
|
588
776
|
console.log(`Local app: ${entryURL}`)
|
|
589
|
-
|
|
590
|
-
|
|
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.')
|
|
591
783
|
await portReservation?.release()
|
|
592
784
|
portReservation = null
|
|
593
|
-
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)
|
|
594
797
|
} catch (error) {
|
|
595
798
|
if (!canceledSignal) {
|
|
596
799
|
const guidance = []
|
|
597
|
-
if (error?.stage === 'app_registration') {
|
|
800
|
+
if (error?.stage === 'app_registration' && ['APP_APP_REGISTRATION_FAILED', 'APP_LOGIN_SESSION_EXPIRED'].includes(error?.code)) {
|
|
598
801
|
if ([403, 404].includes(error.statusCode)) {
|
|
599
802
|
guidance.push('请核对 manifest.json 中的 id(应用 slug)与上述平台地址,确认应用已在该平台创建。开发不需要绑定或 repair。')
|
|
600
803
|
guidance.push('请使用应用创建者账户完成开发授权,并确认终端显示的平台地址与应用所在平台一致。')
|
|
@@ -602,9 +805,9 @@ async function devApp(appDir, args) {
|
|
|
602
805
|
} else if (error.statusCode === 401) {
|
|
603
806
|
guidance.push('请确认 PROXY_USER_ACCESS_TOKEN 属于应用创建者;如果该变量是旧账户令牌,请清除或取消设置后重新运行普通 proxy-app dev。')
|
|
604
807
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
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}`)
|
|
608
811
|
}
|
|
609
812
|
if (guidance.length) error.message += '\n' + guidance.join('\n')
|
|
610
813
|
throw error
|
|
@@ -614,10 +817,79 @@ async function devApp(appDir, args) {
|
|
|
614
817
|
} finally {
|
|
615
818
|
removePreparationListeners()
|
|
616
819
|
await portReservation?.release()
|
|
820
|
+
await backendPortReservation?.release()
|
|
617
821
|
await relay.close()
|
|
618
822
|
}
|
|
619
823
|
}
|
|
620
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
|
+
|
|
621
893
|
async function packApp(appDir, output, { allowSource = false, allowSourceMaps = allowSource } = {}) {
|
|
622
894
|
const info = allowSource ? await validateApp(appDir, { source: true }) : await buildApp(appDir)
|
|
623
895
|
const outputPath = resolve(output || join(info.root, `${info.manifest.id}-${info.manifest.version}.zip`))
|
|
@@ -637,6 +909,9 @@ async function packApp(appDir, output, { allowSource = false, allowSourceMaps =
|
|
|
637
909
|
}
|
|
638
910
|
if (info.artifactRoot === info.root) await collect(info.root)
|
|
639
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
|
+
}
|
|
640
915
|
if (JSON.stringify((await readManifest(info.root)).manifest) !== JSON.stringify(info.manifest)) {
|
|
641
916
|
throw new Error('manifest 或代码能力契约在打包期间发生变更,请重新运行 proxy-app pack。')
|
|
642
917
|
}
|
|
@@ -652,207 +927,6 @@ async function packApp(appDir, output, { allowSource = false, allowSourceMaps =
|
|
|
652
927
|
return { ...info, outputPath }
|
|
653
928
|
}
|
|
654
929
|
|
|
655
|
-
async function loadDesktopReleaseConfig(options = []) {
|
|
656
|
-
const parsed = parseConfigOption(options)
|
|
657
|
-
const config = await loadReleaseConfig({
|
|
658
|
-
root: releaseConfigRoot(),
|
|
659
|
-
cwd: process.env.INIT_CWD || process.cwd(),
|
|
660
|
-
configPath: parsed.configPath,
|
|
661
|
-
envOverrides: parsed.envOverrides,
|
|
662
|
-
})
|
|
663
|
-
// desktop-build/desktop-pack are also used for local staging. A release
|
|
664
|
-
// config is optional there; the main app's build:release command
|
|
665
|
-
// requires update.url before producing a publishable package.
|
|
666
|
-
return validateReleaseConfig(config)
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
async function resolveDesktopSource() {
|
|
670
|
-
const cliRoot = dirname(fileURLToPath(import.meta.url))
|
|
671
|
-
const checkout = join(cliRoot, '..', 'desktop')
|
|
672
|
-
if (await exists(join(checkout, 'host'))) return checkout
|
|
673
|
-
let packageManifest
|
|
674
|
-
try {
|
|
675
|
-
packageManifest = createRequire(join(cliRoot, 'package.json')).resolve('@ducking-mind/proxy-app-desktop-runtime/package.json')
|
|
676
|
-
} catch {
|
|
677
|
-
throw new Error('Electron runtime package is not installed. Install @ducking-mind/proxy-app-desktop-runtime before building a desktop app.')
|
|
678
|
-
}
|
|
679
|
-
const runtime = join(dirname(packageManifest), 'runtime')
|
|
680
|
-
if (!(await exists(join(runtime, 'host')))) throw new Error('Electron runtime package is incomplete; install a published runtime package with its runtime assets.')
|
|
681
|
-
return runtime
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function findPackageRoot(name, fromDirectory) {
|
|
685
|
-
const require = createRequire(join(fromDirectory, 'package.json'))
|
|
686
|
-
let current = dirname(require.resolve(name))
|
|
687
|
-
while (true) {
|
|
688
|
-
const manifest = join(current, 'package.json')
|
|
689
|
-
if (existsSync(manifest)) {
|
|
690
|
-
try {
|
|
691
|
-
if (JSON.parse(readFileSync(manifest, 'utf8')).name === name) return current
|
|
692
|
-
} catch { /* Continue looking above the resolved entry. */ }
|
|
693
|
-
}
|
|
694
|
-
const parent = dirname(current)
|
|
695
|
-
if (parent === current) throw new Error(`cannot resolve installed package: ${name}`)
|
|
696
|
-
current = parent
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
async function desktopBuildApp(appDir, options = []) {
|
|
701
|
-
const releaseConfig = await loadDesktopReleaseConfig(options)
|
|
702
|
-
const info = await buildApp(appDir)
|
|
703
|
-
await assertSafeArtifactDirectory(info.artifactRoot)
|
|
704
|
-
const staging = join(info.root, 'dist-electron')
|
|
705
|
-
await rm(staging, { recursive: true, force: true })
|
|
706
|
-
await mkdir(join(staging, 'app'), { recursive: true })
|
|
707
|
-
await cp(info.artifactRoot, join(staging, 'app'), { recursive: true, dereference: true })
|
|
708
|
-
const desktopSource = await resolveDesktopSource()
|
|
709
|
-
await mkdir(join(staging, 'single-app'), { recursive: true })
|
|
710
|
-
await cp(join(desktopSource, 'single-app', 'main.mjs'), join(staging, 'single-app', 'main.mjs'))
|
|
711
|
-
await cp(join(desktopSource, 'single-app', 'preload.cjs'), join(staging, 'single-app', 'preload.cjs'))
|
|
712
|
-
await cp(join(desktopSource, 'unified', 'renderer', 'assets', 'duck-mark-v2.png'), join(staging, 'single-app', 'status-tray-icon.png'))
|
|
713
|
-
await cp(join(desktopSource, 'host'), join(staging, 'host'), { recursive: true })
|
|
714
|
-
await mkdir(join(staging, 'agent'), { recursive: true })
|
|
715
|
-
for (const name of ['protocol.mjs', 'runtime-status.mjs']) await cp(join(desktopSource, 'agent', name), join(staging, 'agent', name))
|
|
716
|
-
const desktopPackage = JSON.parse(await readFile(join(desktopSource, 'package.json'), 'utf8'))
|
|
717
|
-
const installedElectron = await readFile(join(info.root, 'node_modules', 'electron', 'package.json'), 'utf8').then(JSON.parse).catch(() =>
|
|
718
|
-
readFile(join(desktopSource, 'node_modules', 'electron', 'package.json'), 'utf8').then(JSON.parse).catch(() => null))
|
|
719
|
-
const electronVersion = installedElectron?.version || String(desktopPackage.devDependencies?.electron || '44.9.0').replace(/^[^0-9]*/, '')
|
|
720
|
-
// Standalone Electron apps use the same updater as the unified shell. Copy
|
|
721
|
-
// only its runtime dependency tree into staging; never ship the desktop
|
|
722
|
-
// project's full devDependencies or its Electron binary.
|
|
723
|
-
const runtimeDependencies = new Set()
|
|
724
|
-
async function copyRuntimeDependency(name, fromDir = desktopSource) {
|
|
725
|
-
if (runtimeDependencies.has(name)) return
|
|
726
|
-
let source
|
|
727
|
-
try { source = findPackageRoot(name, fromDir) }
|
|
728
|
-
catch { throw new Error(`桌面运行依赖缺失:${name}`) }
|
|
729
|
-
runtimeDependencies.add(name)
|
|
730
|
-
await cp(source, join(staging, 'node_modules', name), { recursive: true, dereference: true })
|
|
731
|
-
const packageJSON = JSON.parse(await readFile(join(source, 'package.json'), 'utf8'))
|
|
732
|
-
for (const dependency of Object.keys(packageJSON.dependencies || {})) await copyRuntimeDependency(dependency, source)
|
|
733
|
-
}
|
|
734
|
-
await copyRuntimeDependency('electron-updater')
|
|
735
|
-
const targetPlatform = desktopTargetPlatform(releaseConfig.buildTarget?.platform || process.platform)
|
|
736
|
-
const targetArch = desktopTargetArch(releaseConfig.buildTarget?.arch || process.arch)
|
|
737
|
-
if (targetPlatform === 'darwin' && targetArch === 'ia32') throw new Error('macOS 不支持 ia32 发布架构')
|
|
738
|
-
const updateURL = resolveDesktopFeedURL(releaseConfig.updateURL, targetPlatform, targetArch)
|
|
739
|
-
const updateChannel = releaseConfig.updateChannel
|
|
740
|
-
await writeFile(join(staging, 'update-config.json'), `${JSON.stringify({ url: updateURL, channel: updateChannel }, null, 2)}\n`)
|
|
741
|
-
const builder = { appId: `com.duckingmind.proxy.app.${info.manifest.id.replaceAll('.', '-')}`, productName: info.manifest.name, electronVersion, artifactName: '${productName}-${version}-${arch}.${ext}', directories: { output: 'release' }, files: ['single-app/**', 'host/**', 'agent/**', 'app/**', 'node_modules/**', 'package.json'], extraResources: [{ from: 'update-config.json', to: 'update-config.json' }], asar: true, mac: { target: ['dmg', 'zip'] }, win: { target: ['nsis'] }, linux: { target: ['AppImage'] } }
|
|
742
|
-
// setFeedURL alone is insufficient: electron-updater reads app-update.yml
|
|
743
|
-
// for cache/signing configuration when downloading. Generic publication
|
|
744
|
-
// makes electron-builder embed it while --publish never keeps uploads local.
|
|
745
|
-
builder.publish = updateURL ? [{ provider: 'generic', url: updateURL, channel: updateChannel }] : null
|
|
746
|
-
await writeFile(join(staging, 'electron-builder.json'), JSON.stringify(builder, null, 2) + '\n')
|
|
747
|
-
await writeFile(join(staging, 'package.json'), JSON.stringify({ name: info.manifest.id.replaceAll('.', '-'), version: info.manifest.version, description: info.manifest.description || info.manifest.name, author: 'Proxy', private: true, type: 'module', main: 'single-app/main.mjs', dependencies: { 'electron-updater': desktopPackage.dependencies?.['electron-updater'] || '^6.6.2' } }, null, 2) + '\n')
|
|
748
|
-
await writeFile(join(staging, 'desktop-manifest.json'), JSON.stringify({ app_id: info.manifest.id, version: info.manifest.version, name: info.manifest.name }, null, 2) + '\n')
|
|
749
|
-
console.log(JSON.stringify({ staging, app_id: info.manifest.id, version: info.manifest.version }, null, 2))
|
|
750
|
-
return { ...info, staging, releaseConfig, targetPlatform, targetArch }
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
async function desktopPackApp(appDir, output, options = []) {
|
|
754
|
-
const info = await desktopBuildApp(appDir, options)
|
|
755
|
-
const outputPath = resolve(output || join(info.root, 'dist-electron', 'release'))
|
|
756
|
-
await mkdir(outputPath, { recursive: true })
|
|
757
|
-
const configPath = join(info.staging, 'electron-builder.json')
|
|
758
|
-
const config = JSON.parse(await readFile(configPath, 'utf8'))
|
|
759
|
-
config.directories = { ...(config.directories || {}), output: outputPath }
|
|
760
|
-
await writeFile(configPath, JSON.stringify(config, null, 2) + '\n')
|
|
761
|
-
const desktopRoot = await resolveDesktopSource()
|
|
762
|
-
const builderName = process.platform === 'win32' ? 'electron-builder.cmd' : 'electron-builder'
|
|
763
|
-
const commands = [join(info.root, 'node_modules', '.bin', builderName), join(desktopRoot, 'node_modules', '.bin', builderName)]
|
|
764
|
-
let builder = null
|
|
765
|
-
for (const candidate of commands) if (await exists(candidate)) { builder = candidate; break }
|
|
766
|
-
if (!builder) throw new Error('Electron 打包需要在当前应用依赖中安装 electron-builder。')
|
|
767
|
-
const targetPlatform = info.targetPlatform
|
|
768
|
-
const targetArch = info.targetArch
|
|
769
|
-
if (targetPlatform === 'darwin' && targetArch === 'ia32') throw new Error('macOS 不支持 ia32 发布架构')
|
|
770
|
-
await run(builder, ['--config', join(info.staging, 'electron-builder.json'), '--projectDir', info.staging, { darwin: '--mac', win32: '--win', linux: '--linux' }[targetPlatform], `--${targetArch}`, '--publish', 'never'], info.staging, process.env).catch((error) => {
|
|
771
|
-
throw new Error(`Electron 打包失败:${error.message}`)
|
|
772
|
-
})
|
|
773
|
-
const release = await writeDesktopReleaseRecord(info, outputPath, info.releaseConfig, targetPlatform, targetArch)
|
|
774
|
-
console.log(JSON.stringify({ output: outputPath, app_id: info.manifest.id, version: info.manifest.version, release }, null, 2))
|
|
775
|
-
return { ...info, outputPath, release }
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function desktopTargetPlatform(value = process.platform) {
|
|
779
|
-
if (value === 'darwin') return 'darwin'
|
|
780
|
-
if (value === 'win32') return 'win32'
|
|
781
|
-
if (value === 'linux') return 'linux'
|
|
782
|
-
throw new Error(`不支持的桌面发布平台:${value}`)
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function desktopTargetArch(value = process.arch) {
|
|
786
|
-
if (value === 'x64' || value === 'arm64') return value
|
|
787
|
-
if (value === 'ia32') return value
|
|
788
|
-
throw new Error(`不支持的桌面发布架构:${value}`)
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function resolveDesktopFeedURL(value, platform, arch) {
|
|
792
|
-
const raw = String(value || '').trim()
|
|
793
|
-
if (!raw) return ''
|
|
794
|
-
const resolved = raw.replaceAll('{platform}', platform).replaceAll('{arch}', arch).replace(/\/+$/, '')
|
|
795
|
-
let url
|
|
796
|
-
try { url = new URL(resolved) } catch { throw new Error('发布配置 update.url 必须是 HTTPS 或本机回环 HTTP 更新目录地址') }
|
|
797
|
-
if (!isAllowedReleaseURL(resolved)) throw new Error('发布配置 update.url 必须是无凭据、无查询参数的 HTTPS 或本机回环 HTTP 更新目录地址')
|
|
798
|
-
const explicitTarget = /\/(darwin|win32|linux)\/(arm64|x64|ia32)$/.exec(url.pathname)
|
|
799
|
-
if (explicitTarget && (explicitTarget[1] !== platform || explicitTarget[2] !== arch)) throw new Error(`更新地址目标与 ${platform}/${arch} 不一致,请使用 {platform}/{arch}`)
|
|
800
|
-
return resolved
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
/**
|
|
804
|
-
* When a standalone Electron app is built for distribution, emit the same
|
|
805
|
-
* importable record and generic-provider feed as the unified shell. Local
|
|
806
|
-
* builds use an upload placeholder that publish-release.mjs replaces.
|
|
807
|
-
*/
|
|
808
|
-
async function writeDesktopReleaseRecord(info, outputPath, releaseConfig, configuredPlatform, configuredArch) {
|
|
809
|
-
const artifactBase = String(releaseConfig.artifactBaseURL || '').trim().replace(/\/$/, '')
|
|
810
|
-
if (artifactBase && !artifactBase.startsWith('https://')) throw new Error('发布配置 release.artifactBaseUrl 必须使用 HTTPS')
|
|
811
|
-
const product = String(releaseConfig.product || info.manifest.id).trim()
|
|
812
|
-
const channel = releaseConfig.updateChannel
|
|
813
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(product)) throw new Error('发布配置 product 无效')
|
|
814
|
-
if (!['latest', 'beta'].includes(channel)) throw new Error('发布配置 update.channel 必须是 latest 或 beta')
|
|
815
|
-
const platform = desktopTargetPlatform(configuredPlatform)
|
|
816
|
-
const arch = desktopTargetArch(configuredArch)
|
|
817
|
-
if (platform === 'darwin' && arch === 'ia32') throw new Error('macOS 不支持 ia32 发布架构')
|
|
818
|
-
const targetExtension = platform === 'darwin' ? '.zip' : platform === 'win32' ? '.exe' : '.appimage'
|
|
819
|
-
const candidates = []
|
|
820
|
-
for (const entry of await readdir(outputPath, { withFileTypes: true })) {
|
|
821
|
-
if (!entry.isFile() || extname(entry.name).toLowerCase() !== targetExtension || !entry.name.includes(info.manifest.version)) continue
|
|
822
|
-
const archInName = /(?:^|[-_])(x64|arm64|ia32)(?:[-_.]|$)/i.exec(entry.name)?.[1]?.toLowerCase()
|
|
823
|
-
if (archInName && archInName !== arch) continue
|
|
824
|
-
const file = join(outputPath, entry.name)
|
|
825
|
-
const details = await stat(file)
|
|
826
|
-
if (details.size > 0) candidates.push({ file, name: entry.name, mtimeMs: details.mtimeMs })
|
|
827
|
-
}
|
|
828
|
-
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
829
|
-
const artifact = candidates[0]
|
|
830
|
-
if (!artifact) throw new Error(`未找到 ${platform}/${arch} 的 ${info.manifest.version} ${targetExtension} 发布产物`)
|
|
831
|
-
const data = await readFile(artifact.file)
|
|
832
|
-
const sha512 = createHash('sha512').update(data).digest('base64')
|
|
833
|
-
const artifactURL = artifactBase
|
|
834
|
-
? `${artifactBase}/${encodeURIComponent(basename(artifact.file))}`
|
|
835
|
-
: `https://upload.invalid/${encodeURIComponent(basename(artifact.file))}`
|
|
836
|
-
const release = {
|
|
837
|
-
product,
|
|
838
|
-
version: info.manifest.version,
|
|
839
|
-
channel,
|
|
840
|
-
platform,
|
|
841
|
-
arch,
|
|
842
|
-
artifact_url: artifactURL,
|
|
843
|
-
artifact_name: basename(artifact.file),
|
|
844
|
-
sha512,
|
|
845
|
-
size: data.length,
|
|
846
|
-
release_notes: String(releaseConfig.releaseNotes || '').trim(),
|
|
847
|
-
}
|
|
848
|
-
const metadataName = platform === 'darwin' ? `${channel}-mac.yml` : platform === 'linux' ? `${channel}-linux${arch === 'x64' ? '' : `-${arch}`}.yml` : `${channel}.yml`
|
|
849
|
-
const metadata = `version: ${release.version}\nfiles:\n - url: ${JSON.stringify(artifactURL)}\n sha512: ${sha512}\n size: ${data.length}\npath: ${JSON.stringify(release.artifact_name)}\nsha512: ${sha512}\nreleaseDate: ${new Date().toISOString()}\nreleaseNotes: ${JSON.stringify(release.release_notes)}\n`
|
|
850
|
-
await writeFile(join(outputPath, metadataName), metadata)
|
|
851
|
-
const recordPath = join(outputPath, 'desktop-release.json')
|
|
852
|
-
await writeFile(recordPath, `${JSON.stringify(release, null, 2)}\n`)
|
|
853
|
-
return { ...release, metadata: metadataName, record: recordPath }
|
|
854
|
-
}
|
|
855
|
-
|
|
856
930
|
function defaultAgentNamespace(identifier) {
|
|
857
931
|
return identifier.replace(/^app\./, '').replace(/[^a-z0-9]+/g, '_').slice(0, 48) || 'app'
|
|
858
932
|
}
|
|
@@ -892,7 +966,7 @@ async function writeProject(root, app) {
|
|
|
892
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')
|
|
893
967
|
await writeFile(join(root, 'src', 'agent-tools.mjs'), [
|
|
894
968
|
'// This module is shared by the application and the package builder.',
|
|
895
|
-
'// Keep
|
|
969
|
+
'// Keep metadata JSON-serializable and keep each handler beside its registry entry.',
|
|
896
970
|
'export const agentMetadata = Object.freeze({',
|
|
897
971
|
' namespace: ' + JSON.stringify(namespace) + ", targets: ['web', 'desktop'], contract_version: '1.0.0',",
|
|
898
972
|
' summary: ' + JSON.stringify(app.name + ' 的应用能力') + ', keywords: [' + JSON.stringify(app.name) + '],',
|
|
@@ -900,28 +974,28 @@ async function writeProject(root, app) {
|
|
|
900
974
|
" examples: 'agent/examples.json',",
|
|
901
975
|
'})',
|
|
902
976
|
'',
|
|
903
|
-
'export
|
|
904
|
-
"
|
|
905
|
-
|
|
906
|
-
"
|
|
907
|
-
|
|
908
|
-
'
|
|
909
|
-
'',
|
|
910
|
-
|
|
911
|
-
'
|
|
912
|
-
"
|
|
913
|
-
|
|
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] : []",
|
|
914
988
|
'}',
|
|
915
989
|
].join('\n') + '\n')
|
|
916
|
-
await writeFile(join(root, 'src', 'agent-tools.d.mts'), `import type { AgentCapabilityDeclaration, AgentContractMetadata } from '@ducking-mind/proxy-app-sdk'\nexport const agentMetadata: AgentContractMetadata\nexport
|
|
917
|
-
await writeFile(join(root, 'src', 'main.ts'), `import { agent, createAppClient } from '@ducking-mind/proxy-app-sdk'\nimport manifest from '../manifest.json'\nimport {
|
|
918
|
-
await writeFile(join(root, 'vite.config.ts'), "import { defineConfig } from 'vite'\nimport { createPlatformDevProxy } from '@ducking-mind/proxy-app-sdk/vite'\n\nexport default defineConfig({\n base: './',\n server: {\n host: '127.0.0.1',\n proxy: createPlatformDevProxy(),\n },\n})\n")
|
|
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")
|
|
919
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')
|
|
920
|
-
await writeFile(join(root, 'package.json'), JSON.stringify({ name: app.slug.replaceAll('.', '-'), private: true, type: 'module', scripts: { dev: 'proxy-app dev', 'dev:server': 'vite', typecheck: 'tsc --noEmit', build: 'npm run typecheck && vite build' }, dependencies: { '@ducking-mind/proxy-app-sdk': '^0.
|
|
921
|
-
await writeFile(join(root, 'agent', 'guide.md'), `# ${app.name} 能力说明\n\n这是由 “proxy-app init” 生成的能力指南。外部 Codex 通过主应用 MCP 调用能力,应用本身不实现 Agent。\n\n当前提供 “proxy_${namespace}_read_metadata”,用于确认应用 ID
|
|
922
|
-
await writeFile(join(root, 'agent', 'workflow.md'), `# 调用流程\n\n1. 先读取应用状态和本指南。\n2. 调用 “proxy_${namespace}_read_metadata” 确认当前版本。\n3. 新增写能力时,先读取业务 revision,并为每次新操作生成唯一 “idempotency_key”。\n4. Web
|
|
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`)
|
|
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`)
|
|
923
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')
|
|
924
|
-
await writeFile(join(root, 'README.md'), `# ${app.name}\n\n## 开发\n\n~~~bash\nnpm install\nnpx proxy-app create\nnpm run dev\n~~~\n\n已有平台应用时跳过 create,将 manifest.id 设为平台应用的公开 slug 后直接运行 npm run dev,无需 link 或 repair。启动时会先完成开发者授权与应用检查,再运行开发服务器,不要求手工配置用户 token。底层服务器命令配置在 package.json 的 dev:server 中。\n\n## 能力接入\n\n
|
|
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`)
|
|
925
999
|
}
|
|
926
1000
|
|
|
927
1001
|
async function replaceIdentifier(root, previous, next) {
|
|
@@ -1062,6 +1136,10 @@ function reportPendingOpening(app, version) {
|
|
|
1062
1136
|
}
|
|
1063
1137
|
|
|
1064
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
|
+
}
|
|
1065
1143
|
const { app } = await resolveManagedApp(appDir)
|
|
1066
1144
|
const { token, baseURL } = apiSettings()
|
|
1067
1145
|
const bundleInfo = await packApp(appDir, output, { ...options, allowSourceMaps: false })
|
|
@@ -1082,6 +1160,9 @@ async function publishApp(appDir, output, selectedVersion) {
|
|
|
1082
1160
|
// Existing uploaded versions are immutable. Only a new/local draft goes
|
|
1083
1161
|
// through uploadApp's build; no dist is needed to publish a remote version.
|
|
1084
1162
|
const info = await readManifest(resolve(appDir))
|
|
1163
|
+
if (info.manifest.backend?.type === 'container') {
|
|
1164
|
+
throw new Error('当前平台尚未支持云端容器后端构建与部署;此应用可通过 proxy-app dev 本地联调,不能 publish。')
|
|
1165
|
+
}
|
|
1085
1166
|
const targetVersion = selectedVersion || info.manifest.version
|
|
1086
1167
|
let remoteApp = await developerApp(appDir)
|
|
1087
1168
|
let version = (remoteApp.versions || []).find((item) => item.version === targetVersion)
|
|
@@ -1166,8 +1247,6 @@ try {
|
|
|
1166
1247
|
else if (command === 'pack') await packApp(appDir, output, { allowSource: options.includes('--source') })
|
|
1167
1248
|
else if (command === 'upload') await uploadApp(appDir, output, { allowSource: options.includes('--source') })
|
|
1168
1249
|
else if (command === 'publish') await publishApp(appDir, output, option(options, '--version'))
|
|
1169
|
-
else if (command === 'desktop-build') await desktopBuildApp(appDir, options)
|
|
1170
|
-
else if (command === 'desktop-pack') await desktopPackApp(appDir, output, options)
|
|
1171
1250
|
}
|
|
1172
1251
|
} catch (error) {
|
|
1173
1252
|
console.error(`proxy app: ${error instanceof Error ? error.message : String(error)}`)
|