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