@ducking-mind/proxy-app-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/proxy-app.mjs CHANGED
@@ -1,51 +1,119 @@
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 { existsSync, readFileSync } from 'node:fs'
5
+ import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
5
6
  import { createHash, randomUUID } from 'node:crypto'
6
- import { dirname, join, resolve, sep } from 'node:path'
7
+ import { createRequire } from 'node:module'
8
+ import { basename, dirname, extname, join, resolve, sep } from 'node:path'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { createServer as createTCPServer } from 'node:net'
7
11
  import { zipSync } from 'fflate'
8
12
  import { createLocalDevelopmentRelay } from './local-dev-relay.mjs'
13
+ import { createLocalCredentialStore } from './local-dev-credentials.mjs'
14
+ import { assertSafeArtifactFile, assertSafeArtifactDirectory } from './artifact-security.mjs'
15
+ import { resolveAgentContract } from './agent-contract.mjs'
9
16
 
10
17
  const MAX_BUNDLE_BYTES = 50 * 1024 * 1024
11
- const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
12
- const ALLOWED_APIS = new Set(['model.responses', 'model.chat', 'model.messages', 'model.embeddings', 'model.images', 'model.videos', 'model.music'])
13
- const ALLOWED_PRICING_MODES = new Set(['free', 'one_time', 'commission'])
14
- const ALLOWED_CATEGORIES = new Set(['productivity', 'creative', 'developer', 'data', 'communication', 'education', 'finance', 'other'])
15
- const BUNDLE_IGNORED_FILES = new Set(['.proxy-app.json'])
18
+ const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'platform.capabilities.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
19
+ 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
+ const ALLOWED_PRICING_MODES = new Set(['commission'])
21
+ const ALLOWED_CATEGORIES = new Set(['ecommerce', 'comic', 'other'])
22
+ const RESERVED_AGENT_NAMESPACES = new Set(['app', 'codex', 'desktop', 'proxy', 'system'])
23
+ const BUNDLE_IGNORED_FILES = new Set(['.proxy-app.json', '.proxy-app-build'])
16
24
  const APP_WORKSPACE_PATH = '/portal/user/app-workspace'
25
+ // Public slugs are used verbatim, including single-segment names such as
26
+ // "canvas". The platform's opaque app_id remains a separate compatibility
27
+ // format; accepting a slug must not reinterpret it as that durable identity.
28
+ const PLATFORM_APP_ID_PATTERN = /^app_[a-z0-9][a-z0-9._-]{2,119}$/
29
+ const PUBLIC_APP_SLUG_PATTERN = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/
30
+ const validPublicAppSlug = value => typeof value === 'string' && value.length <= 120 && value === value.trim() && PUBLIC_APP_SLUG_PATTERN.test(value)
31
+ const validAppIdentifier = value => typeof value === 'string' && value === value.trim() && (PLATFORM_APP_ID_PATTERN.test(value) || validPublicAppSlug(value))
32
+ const publicAppAliases = app => Array.isArray(app?.app_aliases) ? app.app_aliases.filter(validPublicAppSlug) : []
33
+ const matchesAppIdentity = (identifier, appID, slug, aliases = []) => identifier === appID
34
+ || validPublicAppSlug(identifier) && (identifier === slug || aliases.includes(identifier))
35
+ const appBinding = app => ({
36
+ app_id: app.id, slug: app.slug,
37
+ ...(publicAppAliases(app).length ? { app_aliases: publicAppAliases(app) } : {}),
38
+ })
39
+
40
+ // npm packages carry the same pure protocol modules as the desktop host.
41
+ // Source checkouts prefer the live implementation so validation cannot drift.
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))
17
58
 
18
59
  function usage() {
19
- console.log(`Usage:
20
- proxy-app init <app-dir> [--name <name>]
21
- proxy-app create <app-dir> [--category productivity] [--description text]
22
- proxy-app link <app-dir> <app-id>
23
- proxy-app status <app-dir>
24
- proxy-app doctor <app-dir>
25
- proxy-app dev <app-dir> [--port <port>] [--legacy-bridge]
26
- proxy-app validate <app-dir>
27
- proxy-app build <app-dir>
28
- proxy-app pack <app-dir> [output.zip]
29
- proxy-app upload <app-dir> [output.zip]
30
- proxy-app publish <app-dir> [output.zip]
31
-
32
- Add --source to pack/upload only for local debugging; release packages require dist.
33
- Categories: productivity, creative, developer, data, communication, education, finance, other.
60
+ console.log(`在应用目录内,常用命令:
61
+ proxy-app dev 开发授权与检查通过后启动应用
62
+ proxy-app pack 重新构建 → 校验 → 生成 ZIP
63
+ proxy-app upload 重新构建、打包并上传待发布版本,不切换线上版本
64
+ proxy-app publish 手动发布当前 manifest 版本;缺少上传产物时先构建上传
65
+ proxy-app publish --version 0.1.0 切换到已上传版本,也可回滚
66
+
67
+ 首次使用:proxy-app init <app-dir>,安装依赖后运行 proxy-app create。
68
+ 应用开发使用 manifest.id 中的公开 slug;管理命令仍可使用 app_id。
69
+ pack 无需提前执行 build 或 validate;已上传的版本由 publish 直接推进发布。
70
+
71
+ 完整命令(app-dir 默认当前目录):
72
+ proxy-app init [app-dir] [--name <name>]
73
+ proxy-app create [app-dir] [--name <name>] [--category other] [--description text]
74
+ proxy-app status [app-dir]
75
+ proxy-app doctor [app-dir]
76
+ proxy-app dev [app-dir] [--port <port>] [--no-auth-cache]
77
+ proxy-app validate [app-dir]
78
+ proxy-app build [app-dir]
79
+ proxy-app pack [app-dir] [-o <output.zip>]
80
+ proxy-app upload [app-dir] [-o <output.zip>]
81
+ 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
+
85
+ upload 只上传;publish 显式切换线上版本。首次使用 publish 仍需申请应用开放。
86
+ --version 仅选择已上传版本,不构建、不上传、不修改本地 manifest。
87
+ -o 与 --output 等价,也兼容原来的 <app-dir> <output.zip> 写法。
88
+ --source 仅供 pack/upload 调试源码,跳过构建,不用于正式发布。
89
+ desktop-pack 会在输出目录生成 desktop-release.json 和 electron-updater metadata;发布配置
90
+ 使用 desktop/release.config.json,API Key 只用于发布请求,不会写入安装包。
91
+ 应用分类:ecommerce(电商)、comic(漫剧)、other(其他,默认)。
34
92
 
35
93
  Publish environment:
94
+ PROXY_PLATFORM_URL Optional single-origin platform URL; derives API (/api) and login Portal
36
95
  PROXY_API_BASE_URL default: http://localhost:9003/api
96
+ Optional override when the platform uses a separate API host
97
+ PROXY_PORTAL_URL Optional login Portal override; default: http://localhost:9004
37
98
  PROXY_USER_ACCESS_TOKEN Current platform user session for app management/CI only; never passed to the app
38
99
  PROXY_ACCESS_TOKEN Legacy alias for PROXY_USER_ACCESS_TOKEN
39
100
 
40
101
  Local development:
41
102
  proxy-app dev <app-dir> [--port 5173]
42
- Starts a real platform relay. The app opens platform login on first use;
43
- requests then use the signed-in user's App Session, models, routing, quota
44
- and billing. No separate developer identity or runtime credential exists.
45
- HTTP APIs are the default. --legacy-bridge enables v1 Bridge compatibility.
103
+ If the requested port is occupied, the next available loopback port is used.
104
+ Checks the app binding and developer permission before starting the app.
105
+ First authorize the developer using the terminal login URL; then app users
106
+ sign in separately for their own App Session, models, quota and billing.
107
+ App business traffic always uses the App Session Gateway (/v1). The local
108
+ relay exposes only the authentication control path and never an Admin Runtime
109
+ or legacy Bridge endpoint.
110
+ macOS stores developer and app-user authorization separately in Keychain.
111
+ --no-auth-cache uses memory only. If a cached developer account cannot access
112
+ the app, dev automatically asks you to authorize with the app creator account.
46
113
 
47
114
  Platform verification:
48
- Build and pack the app, upload the ZIP, then publish the selected version.
115
+ Publish builds and uploads new candidates before requesting application opening.
116
+ Once the application is open, later versions publish without another review.
49
117
  `)
50
118
  }
51
119
 
@@ -54,17 +122,119 @@ function option(args, name, fallback = '') {
54
122
  return index >= 0 && args[index + 1] ? args[index + 1] : fallback
55
123
  }
56
124
 
57
- function outputArgument(args) {
58
- return args.slice(1).find((arg) => arg !== '--source') || undefined
125
+ function reservePort(port) {
126
+ return new Promise((resolvePromise, reject) => {
127
+ const server = createTCPServer()
128
+ let settled = false
129
+ const settle = (callback, value) => {
130
+ if (settled) return
131
+ settled = true
132
+ callback(value)
133
+ }
134
+ server.once('error', (error) => {
135
+ if (error?.code === 'EADDRINUSE') settle(resolvePromise, null)
136
+ else settle(reject, error)
137
+ })
138
+ server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
139
+ settle(resolvePromise, {
140
+ port,
141
+ release: () => {
142
+ if (!server.listening) return Promise.resolve()
143
+ return new Promise((resolveClose) => server.close(() => resolveClose()))
144
+ },
145
+ })
146
+ })
147
+ })
148
+ }
149
+
150
+ async function reserveAvailablePort(startPort) {
151
+ for (let port = startPort; port <= 65535; port += 1) {
152
+ const reservation = await reservePort(port)
153
+ if (reservation) return reservation
154
+ }
155
+ throw new Error(`从端口 ${startPort} 开始没有可用的本地端口`)
156
+ }
157
+
158
+ function commandArguments(command, args) {
159
+ const configurations = {
160
+ init: { values: ['--name'], max: 1 },
161
+ create: { values: ['--name', '--category', '--description'], max: 1 },
162
+ repair: { max: 2 }, link: { max: 2 }, status: { max: 1 }, doctor: { max: 1 },
163
+ validate: { max: 1 }, build: { max: 1 },
164
+ dev: { values: ['--port'], flags: ['--no-auth-cache'], max: 1 },
165
+ pack: { values: ['--output'], flags: ['--source'], max: 2 },
166
+ upload: { values: ['--output'], flags: ['--source'], max: 2 },
167
+ 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
+ }
171
+ const config = configurations[command]
172
+ if (!config) throw new Error(`unknown command: ${command}`)
173
+ const positional = []
174
+ const options = []
175
+ const seen = new Set()
176
+ for (let i = 0; i < args.length; i++) {
177
+ const arg = args[i]
178
+ if (arg === '--') { positional.push(...args.slice(i + 1)); break }
179
+ if (!arg.startsWith('-')) { positional.push(arg); continue }
180
+ const key = arg === '-o' ? '--output' : arg
181
+ if (![...(config.values || []), ...(config.flags || [])].includes(key)) {
182
+ throw new Error(`unsupported ${command} option: ${arg}${command === 'dev' ? '; local development always uses the real platform' : ''}`)
183
+ }
184
+ if (seen.has(key)) throw new Error(`duplicate option: ${key}`)
185
+ seen.add(key)
186
+ options.push(key)
187
+ if (config.values?.includes(key)) {
188
+ if (args[i + 1] === undefined || args[i + 1].startsWith('-') || !args[i + 1].trim()) throw new Error(`missing value for ${arg}`)
189
+ options.push(args[++i])
190
+ }
191
+ }
192
+ if (positional.length > config.max) throw new Error(`too many arguments for ${command}`)
193
+ if (command === 'repair') {
194
+ if (!positional.length) return { appDir: '.', appID: '', options }
195
+ return { appDir: positional.length === 2 ? positional[0] : '.', appID: positional.at(-1), options }
196
+ }
197
+ if (command === 'link') {
198
+ if (!positional.length) return { appDir: '.', appID: '', options }
199
+ return { appDir: positional.length === 2 ? positional[0] : '.', appID: positional.at(-1), options }
200
+ }
201
+ const output = option(options, '--output') || positional[1]
202
+ if (seen.has('--output') && positional[1]) throw new Error('specify output once: use --output or the positional output path')
203
+ if (command === 'publish' && seen.has('--version')) {
204
+ 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')
205
+ if (output) throw new Error('--version selects an uploaded version without building; do not specify an output path')
206
+ }
207
+ return { appDir: positional[0] || '.', output, options }
59
208
  }
60
209
 
61
210
  function apiSettings() {
62
211
  const token = process.env.PROXY_USER_ACCESS_TOKEN || process.env.PROXY_ACCESS_TOKEN
63
- const baseURL = (process.env.PROXY_API_BASE_URL || 'http://localhost:9003/api').replace(/\/$/, '')
212
+ const baseURL = platformAPIBaseURL()
64
213
  if (!token) throw new Error('PROXY_USER_ACCESS_TOKEN is required for app management commands; it is never passed to the app runtime')
65
214
  return { token, baseURL }
66
215
  }
67
216
 
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
+
68
238
  function responseError(body, fallback) {
69
239
  const message = typeof body?.error === 'string' ? body.error : body?.error?.message || body?.message
70
240
  const code = body?.code || body?.error?.code
@@ -108,18 +278,43 @@ async function readManifest(appDir) {
108
278
  }
109
279
  if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('manifest must be a JSON object')
110
280
  if (manifest.schema !== 'proxy.app/v1') throw new Error('manifest.schema must be proxy.app/v1')
111
- if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)+(?:[.-][a-z0-9]+)*$/.test(manifest.id || '')) throw new Error('manifest.id is invalid')
281
+ if (!validAppIdentifier(manifest.id)) throw new Error('manifest.id is invalid; expected a 1–120 character lowercase slug or platform app_id')
112
282
  if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.version || '')) throw new Error('manifest.version is invalid')
113
283
  if (typeof manifest.name !== 'string' || !manifest.name.trim()) throw new Error('manifest.name is required')
114
- if (manifest.runtime?.type !== 'iframe') throw new Error('manifest.runtime.type must be iframe')
115
- if (manifest.backend?.type !== 'platform') throw new Error('manifest.backend.type must be platform')
116
- if (!Array.isArray(manifest.permissions)) throw new Error('manifest.permissions must be an array')
284
+ if (manifest.backend !== undefined && (!manifest.backend || typeof manifest.backend !== 'object' || Array.isArray(manifest.backend))) {
285
+ throw new Error('manifest.backend must be an object')
286
+ }
287
+ // App traffic always goes through the platform gateway. Treating backend as
288
+ // optional keeps the manifest focused on app-specific capabilities while
289
+ // preserving an explicit rejection for unsupported backends.
290
+ if ((manifest.backend?.type || 'platform') !== 'platform') throw new Error('manifest.backend.type must be platform')
291
+ // Permissions are optional metadata. An omitted list means the app accepts
292
+ // the platform's currently supported public capabilities; an explicit list
293
+ // remains available for legacy packages that intentionally narrow access.
294
+ if (manifest.permissions !== undefined && !Array.isArray(manifest.permissions)) throw new Error('manifest.permissions must be an array')
117
295
  const permissions = new Set()
118
- for (const permission of manifest.permissions) {
296
+ for (const permission of manifest.permissions || []) {
119
297
  if (!ALLOWED_PERMISSIONS.has(permission)) throw new Error(`unsupported app permission: ${permission}`)
120
298
  if (permissions.has(permission)) throw new Error(`duplicate app permission: ${permission}`)
121
299
  permissions.add(permission)
122
300
  }
301
+ const sourceManifest = structuredClone(manifest)
302
+ if (manifest.agent !== undefined && (!manifest.agent || typeof manifest.agent !== 'object' || Array.isArray(manifest.agent))) throw new Error('manifest.agent must be an object')
303
+ if (manifest.agent?.enabled !== undefined && typeof manifest.agent.enabled !== 'boolean') throw new Error('manifest.agent.enabled must be boolean')
304
+ manifest = await resolveAgentContract(appDir, manifest)
305
+ if (manifest.agent !== undefined) {
306
+ if (!manifest.agent || typeof manifest.agent !== 'object' || Array.isArray(manifest.agent)) throw new Error('manifest.agent must be an object')
307
+ if (manifest.agent.enabled !== undefined && typeof manifest.agent.enabled !== 'boolean') throw new Error('manifest.agent.enabled must be boolean')
308
+ try {
309
+ const agent = normalizeManifestAgent(manifest)
310
+ if (agent) {
311
+ if (RESERVED_AGENT_NAMESPACES.has(agent.namespace)) throw new Error(`reserved agent namespace: ${agent.namespace}`)
312
+ if (!Array.isArray(manifest.agent.capabilities)) throw new Error('manifest.agent.capabilities must be an array')
313
+ }
314
+ } catch (error) {
315
+ throw new Error(`invalid manifest.agent: ${error.message}`)
316
+ }
317
+ }
123
318
  if (manifest.apis !== undefined && !Array.isArray(manifest.apis)) throw new Error('manifest.apis must be an array')
124
319
  const apis = new Set()
125
320
  for (const api of manifest.apis || []) {
@@ -130,24 +325,34 @@ async function readManifest(appDir) {
130
325
  if (manifest.pricing !== undefined && (!manifest.pricing || typeof manifest.pricing !== 'object' || Array.isArray(manifest.pricing))) {
131
326
  throw new Error('manifest.pricing must be an object')
132
327
  }
133
- const pricing = manifest.pricing || { mode: 'free', currency: 'CNY' }
134
- const mode = pricing.mode || 'free'
135
- const oneTimePrice = pricing.one_time_price || 0
136
- const commissionRate = pricing.commission_rate || 0
137
- if (!ALLOWED_PRICING_MODES.has(mode)) throw new Error('manifest.pricing.mode must be free, one_time, or commission')
138
- if (!/^[A-Z]{3,10}$/.test(pricing.currency || 'CNY')) throw new Error('manifest.pricing.currency is invalid')
139
- if (!Number.isFinite(oneTimePrice) || !Number.isFinite(commissionRate) || oneTimePrice < 0 || commissionRate < 0) throw new Error('manifest.pricing values must be non-negative numbers')
140
- if (mode === 'free' && (oneTimePrice !== 0 || commissionRate !== 0)) throw new Error('free pricing requires zero one_time_price and commission_rate')
141
- if (mode === 'one_time' && (oneTimePrice <= 0 || commissionRate !== 0)) throw new Error('one_time pricing requires positive one_time_price and zero commission_rate')
142
- if (mode === 'commission' && (commissionRate <= 0 || commissionRate > 100 || oneTimePrice !== 0)) throw new Error('commission pricing requires commission_rate between 0 and 100 and zero one_time_price')
143
- const entry = safeRelative(manifest.runtime.entry)
144
- return { manifest, entry }
145
- }
146
-
147
- async function validateApp(appDir) {
328
+ if (manifest.pricing !== undefined) {
329
+ const pricing = manifest.pricing
330
+ const mode = pricing.mode
331
+ const oneTimePrice = pricing.one_time_price || 0
332
+ const commissionRate = pricing.commission_rate || 0
333
+ if (mode === 'free') throw new Error('free app pricing is no longer supported; configure commission pricing on the platform')
334
+ if (mode === 'one_time') throw new Error('one-time app pricing is no longer supported; configure commission pricing on the platform')
335
+ if (!ALLOWED_PRICING_MODES.has(mode)) throw new Error('manifest.pricing.mode must be commission')
336
+ if (!/^[A-Z]{3,10}$/.test(pricing.currency || 'USD')) throw new Error('manifest.pricing.currency is invalid')
337
+ if (!Number.isFinite(oneTimePrice) || !Number.isFinite(commissionRate) || oneTimePrice < 0 || commissionRate < 0) throw new Error('manifest.pricing values must be non-negative numbers')
338
+ if (commissionRate <= 0 || commissionRate > 100 || oneTimePrice !== 0) throw new Error('commission pricing requires commission_rate between 0 and 100 and zero one_time_price')
339
+ }
340
+ // Every application uses the same top-level page. Hosting mode and desktop
341
+ // support are platform policy rather than app manifest configuration. Keep
342
+ // accepting those fields when reading older manifests; they are ignored.
343
+ const entry = 'index.html'
344
+ return { manifest, sourceManifest, entry }
345
+ }
346
+
347
+ async function validateApp(appDir, { checkBuildManifest = true, source = false } = {}) {
148
348
  const root = resolve(appDir)
149
349
  const { manifest, entry } = await readManifest(root)
150
- const artifactRoot = (await exists(join(root, 'dist'))) ? join(root, 'dist') : root
350
+ // The package identity is carried by manifest.id. A legacy sidecar may be
351
+ // present, but it is deliberately ignored here: local validation must not
352
+ // turn the removed link/repair workflow into an upload prerequisite. The
353
+ // server performs the authoritative identity check against the app selected
354
+ // by the management API before accepting a version.
355
+ const artifactRoot = !source && (await exists(join(root, 'dist'))) ? join(root, 'dist') : root
151
356
  if (!(await exists(join(artifactRoot, entry)))) throw new Error(`runtime entry is missing: ${entry}`)
152
357
  if (artifactRoot !== root && /(?:src|href)=["']\//i.test(await readFile(join(artifactRoot, entry), 'utf8'))) {
153
358
  throw new Error("built entry contains root-relative assets; configure the bundler to emit relative URLs (Vite: base: './')")
@@ -155,17 +360,94 @@ async function validateApp(appDir) {
155
360
  if (manifest.assets?.icon && !(await exists(join(artifactRoot, safeRelative(manifest.assets.icon))))) {
156
361
  console.warn(`warning: manifest.assets.icon is missing: ${manifest.assets.icon}`)
157
362
  }
363
+ if (checkBuildManifest && artifactRoot !== root && await exists(join(artifactRoot, 'manifest.json'))) {
364
+ const builtManifest = JSON.parse(await readFile(join(artifactRoot, 'manifest.json'), 'utf8'))
365
+ if (JSON.stringify(builtManifest) !== JSON.stringify(manifest)) {
366
+ throw new Error('构建产物与当前 manifest 不一致,请运行 proxy-app pack 重新构建打包。')
367
+ }
368
+ }
369
+ await loadAgentPackage(artifactRoot, manifest)
158
370
  return { root, artifactRoot, manifest, entry }
159
371
  }
160
372
 
161
- function run(command, args, cwd) {
373
+ function run(command, args, cwd, env = process.env) {
162
374
  return new Promise((resolvePromise, reject) => {
163
- const child = spawn(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
164
- child.on('error', reject)
165
- child.on('exit', (code, signal) => code === 0 ? resolvePromise() : reject(new Error(`${command} exited with ${code ?? signal}`)))
375
+ const child = spawn(command, args, { cwd, env, stdio: 'inherit', detached: process.platform !== 'win32', shell: process.platform === 'win32' })
376
+ let canceled = false
377
+ const stop = (signal) => {
378
+ canceled = true
379
+ try {
380
+ if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal)
381
+ else child.kill(signal)
382
+ } catch { /* The build may already have exited. */ }
383
+ }
384
+ const interrupt = () => stop('SIGINT')
385
+ const terminate = () => stop('SIGTERM')
386
+ const cleanup = () => {
387
+ process.removeListener('SIGINT', interrupt)
388
+ process.removeListener('SIGTERM', terminate)
389
+ }
390
+ process.once('SIGINT', interrupt)
391
+ process.once('SIGTERM', terminate)
392
+ child.on('error', (error) => { cleanup(); reject(error) })
393
+ child.on('close', (code, signal) => {
394
+ cleanup()
395
+ if (code === 0 && !canceled) resolvePromise()
396
+ else reject(new Error(`${command} exited with ${code ?? signal}${canceled ? ' (构建已取消)' : ''}`))
397
+ })
166
398
  })
167
399
  }
168
400
 
401
+ async function buildApp(appDir) {
402
+ const root = resolve(appDir)
403
+ if (process.env.PROXY_APP_BUILD_ACTIVE === root) throw new Error('package.json 的 build 脚本不能调用 proxy-app build/pack/upload/publish,请直接调用 Vite 等构建工具。')
404
+ const before = await readManifest(root)
405
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
406
+ if (!pkg.scripts?.build) throw new Error('package.json 缺少 build 脚本,无法重新构建应用。')
407
+ const staging = join(root, '.proxy-app-build')
408
+ const dist = join(root, 'dist')
409
+ try { await mkdir(staging) } catch (error) {
410
+ if (error.code === 'EEXIST') throw new Error('该应用正在构建(.proxy-app-build)。若上次进程异常退出,请先检查此目录内保留的旧 dist,再清理目录重试。')
411
+ throw error
412
+ }
413
+ let previousDist = false
414
+ let started = false
415
+ let preserveBackup = false
416
+ try {
417
+ if (await exists(dist)) {
418
+ await rename(dist, join(staging, 'dist'))
419
+ previousDist = true
420
+ }
421
+ started = true
422
+ console.log('正在重新构建应用…')
423
+ await run('npm', ['run', 'build'], root, { ...process.env, PROXY_APP_BUILD_ACTIVE: root })
424
+ const outputInfo = await lstat(dist).catch((error) => { if (error.code !== 'ENOENT') throw error; return null })
425
+ if (!outputInfo?.isDirectory() || outputInfo.isSymbolicLink()) throw new Error('build output is missing or invalid: build 脚本必须生成真实的 dist 目录。')
426
+ await copyAgentPackage(root, dist, before.manifest)
427
+ const info = await validateApp(root, { checkBuildManifest: false })
428
+ if (info.artifactRoot === root) throw new Error('build output is missing: build 脚本必须生成 dist 目录,不能把源码作为发布包。')
429
+ if (JSON.stringify(before.manifest) !== JSON.stringify(info.manifest)) {
430
+ throw new Error('manifest 或代码能力契约在构建期间发生变更,请重新运行 proxy-app pack,避免配置与资源版本不一致。')
431
+ }
432
+ // Stamp only newly generated output, never a successful no-op over old dist.
433
+ await writeFile(join(info.artifactRoot, 'manifest.json'), JSON.stringify(info.manifest, null, 2) + '\n')
434
+ return info
435
+ } catch (error) {
436
+ if (started) {
437
+ try {
438
+ await rm(dist, { recursive: true, force: true })
439
+ if (previousDist) await rename(join(staging, 'dist'), dist)
440
+ } catch {
441
+ preserveBackup = true
442
+ throw new Error(`${error.message}\n旧构建保留在 ${staging},请检查后恢复。`)
443
+ }
444
+ }
445
+ throw error
446
+ } finally {
447
+ if (!preserveBackup) await rm(staging, { recursive: true, force: true })
448
+ }
449
+ }
450
+
169
451
  function runChild(command, args, cwd, env) {
170
452
  return new Promise((resolvePromise, reject) => {
171
453
  const child = spawn(command, args, { cwd, env, stdio: 'inherit', shell: process.platform === 'win32' })
@@ -184,38 +466,76 @@ function runChild(command, args, cwd, env) {
184
466
 
185
467
  async function devApp(appDir, args) {
186
468
  const root = resolve(appDir)
469
+ if (process.env.PROXY_APP_DEV_ACTIVE === root) {
470
+ throw new Error('package.json 的 dev:server 脚本必须直接调用 Vite 等开发服务器,不能调用 proxy-app dev 或 npm run dev。')
471
+ }
187
472
  const { manifest } = await readManifest(root)
188
- const supportedOptions = new Set(['--port', '--legacy-bridge'])
473
+ const supportedOptions = new Set(['--port', '--no-auth-cache'])
189
474
  for (const arg of args) {
190
475
  if (arg.startsWith('--') && !supportedOptions.has(arg)) {
191
476
  throw new Error(`unsupported dev option: ${arg}; local development always uses the real platform`)
192
477
  }
193
478
  }
194
- const binding = await projectBinding(root)
195
- if (binding.slug !== manifest.id) throw new Error('应用标识与本地绑定不一致,请先运行 proxy-app link 或重新创建应用')
479
+ // Development starts with the public manifest identity. Owner-authorized
480
+ // registration resolves the stable ID in memory before creating sessions.
481
+ const binding = { app_id: manifest.id, slug: manifest.id }
482
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
483
+ const devScript = ['dev:server', 'dev'].find(name => typeof pkg.scripts?.[name] === 'string' && pkg.scripts[name].trim())
484
+ if (!devScript) throw new Error('package.json 缺少 dev:server 脚本,请配置 Vite 等开发服务器启动命令。')
196
485
  const requestedPort = option(args, '--port', process.env.PROXY_APP_DEV_PORT || '5173')
197
486
  if (!/^\d+$/.test(requestedPort) || Number(requestedPort) < 1 || Number(requestedPort) > 65535) throw new Error('--port must be between 1 and 65535')
198
- const entryURL = process.env.PROXY_APP_DEV_URL || `http://127.0.0.1:${requestedPort}/`
487
+ const configuredEntryURL = process.env.PROXY_APP_DEV_URL?.trim() || ''
488
+ let portReservation = configuredEntryURL ? null : await reserveAvailablePort(Number(requestedPort))
489
+ const selectedPort = portReservation?.port || Number(requestedPort)
490
+ if (!configuredEntryURL && selectedPort !== Number(requestedPort)) {
491
+ console.warn(`端口 ${requestedPort} 已被占用,改用 ${selectedPort}。`)
492
+ }
493
+ const entryURL = configuredEntryURL || `http://127.0.0.1:${selectedPort}/`
199
494
  let parsedEntryURL
200
495
  try { parsedEntryURL = new URL(entryURL) } catch { throw new Error('PROXY_APP_DEV_URL must be a valid loopback http(s) URL') }
201
496
  if (!['http:', 'https:'].includes(parsedEntryURL.protocol) || !['localhost', '127.0.0.1', '::1'].includes(parsedEntryURL.hostname) || parsedEntryURL.username || parsedEntryURL.password) {
202
497
  throw new Error('PROXY_APP_DEV_URL must use localhost, 127.0.0.1, or ::1 without credentials')
203
498
  }
204
499
  const relaySecret = randomUUID()
205
- const baseURL = (process.env.PROXY_API_BASE_URL || 'http://localhost:9003/api').replace(/\/$/, '')
206
- const portalURL = process.env.PROXY_PORTAL_URL || process.env.PROXY_PUBLIC_URL || 'http://localhost:9004'
207
- const legacyBridge = args.includes('--legacy-bridge')
208
- const relay = await createLocalDevelopmentRelay({
209
- baseURL,
210
- portalURL,
211
- relaySecret,
212
- appID: binding.app_id,
213
- appSlug: manifest.id,
214
- version: manifest.version,
215
- manifest,
216
- entryURL,
217
- legacyBridge,
500
+ const baseURL = platformAPIBaseURL()
501
+ const portalURL = platformPortalURL()
502
+ const storeSettings = { baseURL, portalURL, appID: binding.app_id, projectPath: root }
503
+ const credentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore(storeSettings)
504
+ const developerCredentialStore = args.includes('--no-auth-cache') ? null : createLocalCredentialStore({
505
+ ...storeSettings, purpose: 'development',
218
506
  })
507
+ const cachedOperations = (store) => {
508
+ if (!store) return null
509
+ let fallbackReported = store.kind !== 'keychain'
510
+ const operation = (method) => async (...values) => {
511
+ try { return await store[method](...values) }
512
+ finally {
513
+ if (!fallbackReported && store.kind === 'memory') {
514
+ fallbackReported = true
515
+ console.warn(store.reason)
516
+ }
517
+ }
518
+ }
519
+ return { load: operation('load'), save: operation('save'), clear: operation('clear') }
520
+ }
521
+ let relay
522
+ try {
523
+ relay = await createLocalDevelopmentRelay({
524
+ baseURL,
525
+ portalURL,
526
+ relaySecret,
527
+ appID: binding.app_id,
528
+ appSlug: manifest.id,
529
+ version: manifest.version,
530
+ manifest,
531
+ entryURL,
532
+ credentialStore: cachedOperations(credentialStore),
533
+ developerCredentialStore: cachedOperations(developerCredentialStore),
534
+ })
535
+ } catch (error) {
536
+ await portReservation?.release()
537
+ throw error
538
+ }
219
539
  const {
220
540
  PROXY_USER_ACCESS_TOKEN: _userToken,
221
541
  PROXY_ACCESS_TOKEN: _legacyToken,
@@ -225,35 +545,81 @@ async function devApp(appDir, args) {
225
545
  PROXY_APP_DEV_URL: _entryURL,
226
546
  PROXY_APP_DEV_PORT: _devPort,
227
547
  PROXY_PLATFORM_GATEWAY_URL: _gatewayURL,
228
- PROXY_PLATFORM_RUNTIME_URL: _runtimeURL,
229
- PROXY_PLATFORM_BRIDGE_URL: _bridgeURL,
230
548
  PROXY_PLATFORM_AUTH_URL: _authURL,
231
549
  PROXY_PLATFORM_RELAY_SECRET: _inheritedRelaySecret,
232
550
  ...safeChildEnv
233
551
  } = process.env
234
552
  const childEnv = {
235
553
  ...safeChildEnv,
554
+ PROXY_APP_DEV_ACTIVE: root,
236
555
  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
556
  PROXY_PLATFORM_AUTH_URL: `${relay.relayURL}/auth`,
239
557
  PROXY_PLATFORM_RELAY_SECRET: relaySecret,
240
558
  }
241
- console.log(`Proxy App platform relay ready: ${relay.relayURL}`)
242
- console.log(`Local app: ${entryURL}`)
243
- console.log(`Runtime transport: ${legacyBridge ? 'legacy Bridge compatibility' : 'same-origin HTTP (/v1, /api/app-runtime/v1)'}`)
244
- console.log('Platform mode: sign in from the app to use real models, provider routing, quota and billing.')
559
+ const preparation = new AbortController()
560
+ let canceledSignal = ''
561
+ const cancel = (signal) => { canceledSignal = signal; preparation.abort() }
562
+ const interrupt = () => cancel('SIGINT')
563
+ const terminate = () => cancel('SIGTERM')
564
+ const removePreparationListeners = () => {
565
+ process.removeListener('SIGINT', interrupt)
566
+ process.removeListener('SIGTERM', terminate)
567
+ }
568
+ process.once('SIGINT', interrupt)
569
+ process.once('SIGTERM', terminate)
245
570
  try {
246
- await runChild('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', requestedPort, '--strictPort'], root, childEnv)
571
+ console.log(`正在检查开发应用:${manifest.id}@${manifest.version}`)
572
+ console.log(`平台 API 地址:${baseURL}`)
573
+ console.log(`开发授权缓存:${developerCredentialStore?.kind === 'keychain' ? 'macOS 钥匙串,与应用用户登录独立保存' : '仅当前进程内存'}`)
574
+ await relay.prepareDevelopment({
575
+ token: _userToken || _legacyToken || '',
576
+ signal: preparation.signal,
577
+ onAuthorizationRequired: ({ login_url }) => {
578
+ console.log('开发环境尚未授权,或已有开发授权已失效。请使用应用创建者账户打开以下链接:')
579
+ console.log('开发者授权入口:')
580
+ console.log(login_url)
581
+ console.log('完成开发者授权并通过应用登记检查后才会启动本地应用。按 Ctrl+C 取消。')
582
+ },
583
+ })
584
+ preparation.signal.throwIfAborted()
585
+ removePreparationListeners()
586
+ console.log('开发应用检查通过。应用用户将在应用内独立登录。')
587
+ console.log(`Proxy App platform relay ready: ${relay.relayURL}`)
588
+ console.log(`Local app: ${entryURL}`)
589
+ console.log('App transport: App Session Gateway (/v1); local relay auth is /__platform/auth.')
590
+ console.log('Platform mode: sign in from the app to use real models, provider routing, quota and billing.')
591
+ await portReservation?.release()
592
+ portReservation = null
593
+ await runChild('npm', ['run', devScript, '--', '--host', '127.0.0.1', '--port', String(selectedPort), '--strictPort'], root, childEnv)
594
+ } catch (error) {
595
+ if (!canceledSignal) {
596
+ const guidance = []
597
+ if (error?.stage === 'app_registration') {
598
+ if ([403, 404].includes(error.statusCode)) {
599
+ guidance.push('请核对 manifest.json 中的 id(应用 slug)与上述平台地址,确认应用已在该平台创建。开发不需要绑定或 repair。')
600
+ guidance.push('请使用应用创建者账户完成开发授权,并确认终端显示的平台地址与应用所在平台一致。')
601
+ guidance.push('如果设置了 PROXY_USER_ACCESS_TOKEN,请确认它属于应用创建者;旧账户令牌请清除或取消设置后重新运行普通 proxy-app dev。')
602
+ } else if (error.statusCode === 401) {
603
+ guidance.push('请确认 PROXY_USER_ACCESS_TOKEN 属于应用创建者;如果该变量是旧账户令牌,请清除或取消设置后重新运行普通 proxy-app dev。')
604
+ }
605
+ if (typeof error.requestId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(error.requestId)) {
606
+ guidance.push(`请求编号:${error.requestId}`)
607
+ }
608
+ }
609
+ if (guidance.length) error.message += '\n' + guidance.join('\n')
610
+ throw error
611
+ }
612
+ console.error('已取消开发环境初始化,本地应用未启动。')
613
+ process.exitCode = canceledSignal === 'SIGINT' ? 130 : 143
247
614
  } finally {
615
+ removePreparationListeners()
616
+ await portReservation?.release()
248
617
  await relay.close()
249
618
  }
250
619
  }
251
620
 
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
- }
621
+ async function packApp(appDir, output, { allowSource = false, allowSourceMaps = allowSource } = {}) {
622
+ const info = allowSource ? await validateApp(appDir, { source: true }) : await buildApp(appDir)
257
623
  const outputPath = resolve(output || join(info.root, `${info.manifest.id}-${info.manifest.version}.zip`))
258
624
  const files = {}
259
625
  const collect = async (directory, prefix = '') => {
@@ -271,7 +637,13 @@ async function packApp(appDir, output, { allowSource = false } = {}) {
271
637
  }
272
638
  if (info.artifactRoot === info.root) await collect(info.root)
273
639
  else await collect(info.artifactRoot)
274
- files['manifest.json'] = new Uint8Array(await readFile(join(info.root, 'manifest.json')))
640
+ if (JSON.stringify((await readManifest(info.root)).manifest) !== JSON.stringify(info.manifest)) {
641
+ throw new Error('manifest 或代码能力契约在打包期间发生变更,请重新运行 proxy-app pack。')
642
+ }
643
+ files['manifest.json'] = new TextEncoder().encode(JSON.stringify(info.manifest, null, 2) + '\n')
644
+ for (const [filePath, data] of Object.entries(files)) {
645
+ assertSafeArtifactFile(filePath, data, { allowSourceMaps })
646
+ }
275
647
  const bundle = zipSync(files, { level: 9 })
276
648
  if (bundle.length > MAX_BUNDLE_BYTES) throw new Error(`bundle is larger than ${MAX_BUNDLE_BYTES} bytes`)
277
649
  await mkdir(dirname(outputPath), { recursive: true })
@@ -280,14 +652,276 @@ async function packApp(appDir, output, { allowSource = false } = {}) {
280
652
  return { ...info, outputPath }
281
653
  }
282
654
 
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
+ function defaultAgentNamespace(identifier) {
857
+ return identifier.replace(/^app\./, '').replace(/[^a-z0-9]+/g, '_').slice(0, 48) || 'app'
858
+ }
859
+
860
+ async function replaceGeneratedNamespace(root, previous, next) {
861
+ if (!previous || previous === next) return
862
+ for (const relative of ['agent/guide.md', 'agent/workflow.md', 'README.md', 'src/agent-tools.mjs']) {
863
+ const target = join(root, relative)
864
+ const source = await readFile(target, 'utf8').catch(() => null)
865
+ if (source !== null) {
866
+ const replaced = source.replaceAll(`proxy_${previous}_`, `proxy_${next}_`)
867
+ .replaceAll(`"namespace": "${previous}"`, `"namespace": "${next}"`)
868
+ .replaceAll(`namespace: ${JSON.stringify(previous)}`, `namespace: ${JSON.stringify(next)}`)
869
+ .replaceAll(`'namespace': '${previous}'`, `'namespace': '${next}'`)
870
+ .replaceAll(`namespace: '${previous}'`, `namespace: '${next}'`)
871
+ if (replaced !== source) await writeFile(target, replaced)
872
+ }
873
+ }
874
+ }
875
+
876
+ async function replaceGeneratedIdentifier(root, previous, next) {
877
+ if (!previous || previous === next) return
878
+ const target = join(root, 'agent', 'examples.json')
879
+ const source = await readFile(target, 'utf8').catch(() => null)
880
+ if (source !== null) await writeFile(target, source.replaceAll(previous, next))
881
+ }
882
+
283
883
  async function writeProject(root, app) {
884
+ const cliPackage = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'))
284
885
  await mkdir(join(root, 'src'), { recursive: true })
285
- await writeFile(join(root, 'manifest.json'), JSON.stringify({ schema: 'proxy.app/v1', id: app.slug, version: '0.1.0', name: app.name, description: app.description || '', runtime: { type: 'iframe', entry: 'index.html' }, permissions: [], apis: [], backend: { type: 'platform' }, pricing: { mode: 'free', currency: 'CNY' } }, null, 2) + '\n')
886
+ await mkdir(join(root, 'agent'), { recursive: true })
887
+ const namespace = defaultAgentNamespace(app.slug)
888
+ const manifest = {
889
+ schema: 'proxy.app/v1', id: app.slug, version: '0.1.0', name: app.name, description: app.description || '',
890
+ }
891
+ await writeFile(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
286
892
  await writeFile(join(root, 'index.html'), '<!doctype html>\n<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Proxy App</title></head><body><main id="app"></main><script type="module" src="/src/main.ts"></script></body></html>\n')
287
- await writeFile(join(root, 'src', 'main.ts'), `import { createAppClient } from '@ducking-mind/proxy-app-sdk'\n\nconst client = createAppClient(${JSON.stringify(app.slug)}, { localDevelopment: import.meta.env.DEV, localAppVersion: '0.1.0' })\nconst root = document.querySelector<HTMLElement>('#app')\nif (root) root.textContent = ${JSON.stringify(app.name)}\nvoid client.platform.capabilities().then(console.log)\n`)
893
+ await writeFile(join(root, 'src', 'agent-tools.mjs'), [
894
+ '// This module is shared by the application and the package builder.',
895
+ '// Keep it independent of browser state and expose only JSON-serializable metadata.',
896
+ 'export const agentMetadata = Object.freeze({',
897
+ ' namespace: ' + JSON.stringify(namespace) + ", targets: ['web', 'desktop'], contract_version: '1.0.0',",
898
+ ' summary: ' + JSON.stringify(app.name + ' 的应用能力') + ', keywords: [' + JSON.stringify(app.name) + '],',
899
+ " guide: { entry: 'agent/guide.md', topics: [{ id: 'workflow', title: '调用流程', entry: 'agent/workflow.md' }] },",
900
+ " examples: 'agent/examples.json',",
901
+ '})',
902
+ '',
903
+ 'export const capabilities = [{',
904
+ " name: 'read_metadata', title: '读取应用元数据', description: '读取应用的标识、名称和版本。',",
905
+ " access: 'read', idempotent: true, targets: ['web', 'desktop'],",
906
+ " input_schema: { type: 'object', properties: {}, required: [], additionalProperties: false },",
907
+ " output_schema: { type: 'object', properties: { appId: { type: 'string' }, version: { type: 'string' }, name: { type: 'string' } }, required: ['appId', 'version', 'name'], additionalProperties: false },",
908
+ '}]',
909
+ '',
910
+ 'export function capability(name) {',
911
+ ' const declaration = capabilities.find(item => item.name === name)',
912
+ " if (!declaration) throw new Error('Unknown application capability: ' + name)",
913
+ ' return declaration',
914
+ '}',
915
+ ].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 const capabilities: AgentCapabilityDeclaration[]\nexport function capability(name: string): AgentCapabilityDeclaration\n`)
917
+ 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
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")
289
- await writeFile(join(root, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2020', module: 'ESNext', moduleResolution: 'Bundler', strict: true, lib: ['ES2020', 'DOM'], types: ['vite/client'], noEmit: true }, include: ['src', 'vite.config.ts'] }, null, 2) + '\n')
290
- await writeFile(join(root, 'package.json'), JSON.stringify({ name: app.slug.replaceAll('.', '-'), private: true, type: 'module', scripts: { dev: 'vite', typecheck: 'tsc --noEmit', build: 'npm run typecheck && vite build' }, dependencies: { '@ducking-mind/proxy-app-sdk': '^0.1.0' }, devDependencies: { typescript: '^5.2.2', vite: '^8.2.2' } }, null, 2) + '\n')
919
+ 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.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')
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、版本和名称。能力定义在 “src/agent-tools.mjs” 中维护,应用使用同一份定义注册处理器;构建命令自动生成发布契约,无需在 manifest 中重复声明能力。\n`)
922
+ await writeFile(join(root, 'agent', 'workflow.md'), `# 调用流程\n\n1. 先读取应用状态和本指南。\n2. 调用 “proxy_${namespace}_read_metadata” 确认当前版本。\n3. 新增写能力时,先读取业务 revision,并为每次新操作生成唯一 “idempotency_key”。\n4. Web 和本地 App 共用同一套 manifest;仅在能力确实支持时缩小 “targets”。\n`)
923
+ 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本项目已生成 Web 与本地 App 两个运行目标,以及一个 “read_metadata” 示例。能力的名称、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
925
  }
292
926
 
293
927
  async function replaceIdentifier(root, previous, next) {
@@ -314,26 +948,30 @@ async function initApp(appDir, args) {
314
948
  const name = option(args, '--name', root.split(sep).pop() || 'Proxy App')
315
949
  const app = { id: '', slug: `app.proxy.local.${randomUUID().replaceAll('-', '').slice(0, 16)}`, name, description: '' }
316
950
  await writeProject(root, app)
317
- console.log(JSON.stringify({ created: root, local_identifier: app.slug, next: [`cd ${root}`, 'npm install', `proxy-app create ${root}`, `proxy-app dev ${root}`, 'npm run build'] }, null, 2))
951
+ 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
952
  }
319
953
 
320
954
  async function createApp(appDir, args) {
321
955
  const root = resolve(appDir)
322
956
  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)
957
+ const { sourceManifest: manifest } = await readManifest(root)
324
958
  const localIdentifier = manifest.id
325
959
  const name = option(args, '--name', manifest.name || root.split(sep).pop() || 'Proxy App')
326
- const category = option(args, '--category', 'productivity')
960
+ const category = option(args, '--category', 'other')
327
961
  if (!ALLOWED_CATEGORIES.has(category)) throw new Error(`unsupported app category: ${category}`)
328
962
  const app = await apiJSON(APP_WORKSPACE_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description: option(args, '--description', manifest.description || ''), category }) })
329
- if (!app || typeof app.id !== 'string' || typeof app.slug !== 'string' || !/^[a-z0-9]+(?:[.-][a-z0-9]+)+(?:[.-][a-z0-9]+)*$/.test(app.slug)) {
330
- throw new Error('platform returned an invalid app binding; expected id and lowercase reverse-domain slug')
963
+ if (!app || typeof app.id !== 'string' || !app.id || !validPublicAppSlug(app.slug)) {
964
+ throw new Error('platform returned an invalid app binding; expected app_id and a 1–120 character lowercase slug')
331
965
  }
332
- manifest.id = app.slug
966
+ const previousNamespace = defaultAgentNamespace(localIdentifier)
967
+ const nextNamespace = defaultAgentNamespace(app.id)
968
+ await replaceGeneratedNamespace(root, previousNamespace, nextNamespace)
969
+ manifest.id = app.id
970
+ await replaceGeneratedIdentifier(root, localIdentifier, app.id)
333
971
  await writeFile(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
334
- await replaceIdentifier(root, localIdentifier, app.slug)
335
- await writeFile(join(root, '.proxy-app.json'), JSON.stringify({ app_id: app.id, slug: app.slug }, null, 2) + '\n')
336
- console.log(JSON.stringify({ created: root, app_id: app.id, app_identifier: app.slug, next: [`proxy-app upload ${root}`] }, null, 2))
972
+ await replaceIdentifier(root, localIdentifier, app.id)
973
+ await writeFile(join(root, '.proxy-app.json'), JSON.stringify(appBinding(app), null, 2) + '\n')
974
+ console.log(JSON.stringify({ created: root, identifier: app.slug, app_id: app.id, next: [`proxy-app upload ${root}`] }, null, 2))
337
975
  }
338
976
 
339
977
  async function linkApp(appDir, appID) {
@@ -342,101 +980,161 @@ async function linkApp(appDir, appID) {
342
980
  const app = (result.items || []).find((item) => item.id === appID)
343
981
  if (!app) throw new Error(`application not found: ${appID}`)
344
982
  const manifestPath = join(root, 'manifest.json')
345
- const { manifest } = await readManifest(root)
346
- if (typeof app.slug !== 'string' || !/^[a-z0-9]+(?:[.-][a-z0-9]+)+(?:[.-][a-z0-9]+)*$/.test(app.slug)) {
983
+ const { sourceManifest: manifest } = await readManifest(root)
984
+ if (!validPublicAppSlug(app.slug) || typeof app.id !== 'string' || !app.id) {
347
985
  throw new Error(`application has an invalid identifier: ${app.slug || '(missing)'}`)
348
986
  }
349
987
  const previous = manifest.id
350
- manifest.id = app.slug
988
+ const previousNamespace = defaultAgentNamespace(previous)
989
+ const nextNamespace = defaultAgentNamespace(app.id)
990
+ await replaceGeneratedNamespace(root, previousNamespace, nextNamespace)
991
+ manifest.id = app.id
992
+ await replaceGeneratedIdentifier(root, previous, app.id)
351
993
  await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
352
- await replaceIdentifier(root, previous, app.slug)
353
- await writeFile(join(root, '.proxy-app.json'), JSON.stringify({ app_id: app.id, slug: app.slug }, null, 2) + '\n')
354
- console.log(JSON.stringify({ linked: root, app_id: app.id, app_identifier: app.slug }, null, 2))
994
+ await replaceIdentifier(root, previous, app.id)
995
+ await writeFile(join(root, '.proxy-app.json'), JSON.stringify(appBinding(app), null, 2) + '\n')
996
+ console.log(JSON.stringify({ linked: root, identifier: app.slug, app_id: app.id }, null, 2))
355
997
  }
356
998
 
357
- async function projectBinding(appDir) {
358
- try {
359
- const config = JSON.parse(await readFile(join(resolve(appDir), '.proxy-app.json'), 'utf8'))
360
- if (typeof config.app_id === 'string' && config.app_id && typeof config.slug === 'string' && /^[a-z0-9]+(?:[.-][a-z0-9]+)+(?:[.-][a-z0-9]+)*$/.test(config.slug)) return config
361
- } catch {}
362
- throw new Error('app binding is missing or invalid; run proxy-app create or proxy-app link first')
999
+ // Repair is the user-facing binding command. It keeps the legacy slug in the
1000
+ // local file for compatibility, but users only need to provide app_id.
1001
+ async function repairApp(appDir, appID) {
1002
+ const root = resolve(appDir)
1003
+ const existing = await readFile(join(root, '.proxy-app.json'), 'utf8').then(JSON.parse).catch(() => ({}))
1004
+ const target = appID || existing.app_id
1005
+ if (!target) throw new Error('缺少 app_id,请使用 proxy-app repair <app-id>')
1006
+ await linkApp(root, target)
363
1007
  }
364
1008
 
365
- async function projectAppID(appDir) {
366
- return (await projectBinding(appDir)).app_id
1009
+ // Management commands use the platform app identity carried by manifest.id.
1010
+ // A sidecar binding may remain in an older checkout, but it is intentionally
1011
+ // ignored: existing apps can be uploaded/published directly after setting
1012
+ // manifest.id to the app's public slug (or stable app_id). This keeps the
1013
+ // development and release flows consistent and avoids making `link` a hidden
1014
+ // prerequisite for an otherwise valid package.
1015
+ async function resolveManagedApp(appDir) {
1016
+ const root = resolve(appDir)
1017
+ const { manifest } = await readManifest(root)
1018
+ const result = await apiJSON(APP_WORKSPACE_PATH)
1019
+ const items = Array.isArray(result?.items) ? result.items : []
1020
+ // Resolve the durable app ID from the public manifest identity. Existing
1021
+ // checkouts therefore work without a sidecar file or a link/repair command;
1022
+ // stale compatibility sidecars are deliberately ignored.
1023
+ const app = items.find((item) => matchesAppIdentity(manifest.id, item?.id, item?.slug, publicAppAliases(item)))
1024
+ if (!app) {
1025
+ throw new Error(`应用不存在或当前账户无权访问:${manifest.id}。请确认 manifest.id 是该应用的公开 slug;新应用请运行 proxy-app create。`)
1026
+ }
1027
+ return { root, manifest, app, binding: appBinding(app) }
367
1028
  }
368
1029
 
369
1030
  async function developerApp(appDir) {
370
- const appID = await projectAppID(appDir)
371
- const result = await apiJSON(APP_WORKSPACE_PATH)
372
- const app = (result.items || []).find((item) => item.id === appID)
373
- if (!app) throw new Error(`linked application was not found: ${appID}`)
374
- return app
1031
+ return (await resolveManagedApp(appDir)).app
375
1032
  }
376
1033
 
377
- async function submitAppVersion(appID, manifest) {
378
- const { token, baseURL } = apiSettings()
379
- const submit = await fetch(`${baseURL}${APP_WORKSPACE_PATH}/${encodeURIComponent(appID)}/versions/${encodeURIComponent(manifest.version)}/submit`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ pricing_mode: manifest.pricing?.mode || 'free', one_time_price: manifest.pricing?.one_time_price || 0, commission_rate: manifest.pricing?.commission_rate || 0, currency: manifest.pricing?.currency || 'CNY' }) })
380
- const submitBody = await submit.json().catch(() => ({}))
381
- if (!submit.ok) throw new Error(responseError(submitBody, `submit failed: HTTP ${submit.status}`))
382
- return submitBody.data || submitBody
1034
+ async function requestAppOpening(appID, version) {
1035
+ // The version is review material for the application opening request.
1036
+ // Pricing remains the application's saved configuration.
1037
+ return apiJSON(`${APP_WORKSPACE_PATH}/${encodeURIComponent(appID)}/opening`, {
1038
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1039
+ body: JSON.stringify({ version }),
1040
+ })
1041
+ }
1042
+
1043
+ function pendingAppOpening(app) {
1044
+ if (Object.hasOwn(app, 'opening_request')) {
1045
+ return app.opening_request?.status === 'pending' ? app.opening_request : null
1046
+ }
1047
+ // Old servers expose the candidate through a version status. Never prefer
1048
+ // that compatibility mirror over the application's canonical request.
1049
+ const version = (app.versions || []).find((item) => item.review_status === 'review')
1050
+ return version ? { version: version.version, status: 'pending' } : null
1051
+ }
1052
+
1053
+ function reportPendingOpening(app, version) {
1054
+ const pending = pendingAppOpening(app)
1055
+ if (!pending) return false
1056
+ if (pending.version !== version) {
1057
+ throw new Error(`应用开放申请正在审核(预览版本 ${pending.version}),请在“我的应用”撤回申请后再更换审核材料。`)
1058
+ }
1059
+ const result = { submitted: false, reason: '应用开放申请正在审核,无需重复提交。', app_id: app.id, opening_request: pending, version }
1060
+ console.log(JSON.stringify(result, null, 2))
1061
+ return result
383
1062
  }
384
1063
 
385
1064
  async function uploadApp(appDir, output, options = {}) {
386
- const binding = await projectBinding(appDir)
1065
+ const { app } = await resolveManagedApp(appDir)
387
1066
  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
- }
1067
+ const bundleInfo = await packApp(appDir, output, { ...options, allowSourceMaps: false })
392
1068
  const bundle = await readFile(bundleInfo.outputPath)
393
1069
  const form = new FormData(); form.append('file', new Blob([bundle], { type: 'application/zip' }), `${bundleInfo.manifest.id}-${bundleInfo.manifest.version}.zip`)
394
- const response = await fetch(`${baseURL}${APP_WORKSPACE_PATH}/${encodeURIComponent(binding.app_id)}/versions`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
1070
+ const response = await fetch(`${baseURL}${APP_WORKSPACE_PATH}/${encodeURIComponent(app.id)}/versions`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form })
395
1071
  const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(responseError(body, `upload failed: HTTP ${response.status}`))
396
- console.log(JSON.stringify({ uploaded: body.data || body, next: `proxy-app publish ${appDir}` }, null, 2))
397
- return body.data || body
1072
+ const uploaded = body.data || body
1073
+ console.log(JSON.stringify({
1074
+ uploaded,
1075
+ message: '版本已上传,线上版本未切换。测试后使用 publish 手动发布。',
1076
+ next: `proxy-app publish ${appDir} --version ${bundleInfo.manifest.version}`,
1077
+ }, null, 2))
1078
+ return uploaded
398
1079
  }
399
1080
 
400
- async function publishApp(appDir, output) {
401
- const info = await validateApp(appDir)
402
- const remoteApp = await developerApp(appDir)
403
- if (remoteApp.slug !== info.manifest.id) {
404
- throw new Error('应用标识不匹配,请先同步当前应用绑定,再重新 build 和 pack。')
1081
+ async function publishApp(appDir, output, selectedVersion) {
1082
+ // Existing uploaded versions are immutable. Only a new/local draft goes
1083
+ // through uploadApp's build; no dist is needed to publish a remote version.
1084
+ const info = await readManifest(resolve(appDir))
1085
+ const targetVersion = selectedVersion || info.manifest.version
1086
+ let remoteApp = await developerApp(appDir)
1087
+ let version = (remoteApp.versions || []).find((item) => item.version === targetVersion)
1088
+ if (selectedVersion && (!version || (!version.artifact_size && !version.artifact_sha256))) {
1089
+ throw new Error(`版本 ${targetVersion} 尚未上传。--version 只选择已有上传产物,请先上传该版本。`)
1090
+ }
1091
+ if (!remoteApp.current_version) {
1092
+ if (remoteApp.status === 'disabled') throw new Error('应用已被平台禁用,解除限制后才能申请开放。修复包可使用 proxy-app upload 上传。')
1093
+ const pending = reportPendingOpening(remoteApp, targetVersion)
1094
+ if (pending) return pending
405
1095
  }
406
- let version = (remoteApp.versions || []).find((item) => item.version === info.manifest.version)
407
1096
  let uploaded = null
408
- if (!version) {
1097
+ if (!version || (version.review_status === 'draft' && !version.artifact_size && !version.artifact_sha256)) {
409
1098
  uploaded = await uploadApp(appDir, output)
410
1099
  version = uploaded
1100
+ remoteApp = await developerApp(appDir)
1101
+ }
1102
+ if (!remoteApp.current_version) {
1103
+ const pending = reportPendingOpening(remoteApp, targetVersion)
1104
+ if (pending) return pending
1105
+ }
1106
+ if (remoteApp.current_version === targetVersion) {
1107
+ const result = { submitted: false, changed: false, reason: '该版本已是当前线上版本,无需切换。', app_id: remoteApp.id, version: targetVersion }
1108
+ console.log(JSON.stringify(result, null, 2))
1109
+ return result
411
1110
  }
412
- if (['review', 'published'].includes(version.review_status)) {
413
- console.log(JSON.stringify({ submitted: false, reason: `version is already ${version.review_status}`, app_id: remoteApp.id, version: info.manifest.version }, null, 2))
414
- return
1111
+ if (remoteApp.current_version) {
1112
+ if (!['draft', 'rejected', 'review', 'published'].includes(version.review_status)) throw new Error(`version cannot be published from status: ${version.review_status || 'unknown'}`)
1113
+ const published = await apiJSON(`${APP_WORKSPACE_PATH}/${encodeURIComponent(remoteApp.id)}/versions/${encodeURIComponent(targetVersion)}/publish`, { method: 'POST' })
1114
+ const result = { uploaded, published, submitted: false, changed: true, message: '线上版本已手动切换;应用保持当前上架或限制状态。', app_id: remoteApp.id, previous_version: remoteApp.current_version, version: targetVersion }
1115
+ console.log(JSON.stringify(result, null, 2))
1116
+ return result
415
1117
  }
416
- if (!['draft', 'rejected'].includes(version.review_status)) throw new Error(`version cannot be submitted from status: ${version.review_status || 'unknown'}`)
417
- const submitted = await submitAppVersion(remoteApp.id, info.manifest)
418
- console.log(JSON.stringify({ uploaded, submitted, app_id: remoteApp.id, version: info.manifest.version }, null, 2))
1118
+ 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'}`)
1119
+ const submitted = await requestAppOpening(remoteApp.id, targetVersion)
1120
+ const result = { uploaded, submitted: true, opening: submitted, changed: false, message: '应用开放申请已提交,所选版本用于预览和审核。', app_id: remoteApp.id, version: targetVersion }
1121
+ console.log(JSON.stringify(result, null, 2))
1122
+ return result
419
1123
  }
420
1124
 
421
1125
  async function statusApp(appDir) {
422
1126
  const root = resolve(appDir)
423
1127
  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
1128
  const app = await developerApp(root)
431
1129
  const version = (app.versions || []).find((item) => item.version === manifest.version)
432
- console.log(JSON.stringify({ linked: true, app_id: app.id, app_identifier: app.slug, app_status: app.status, local_version: manifest.version, remote_version: version || null }, null, 2))
1130
+ 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
1131
  }
434
1132
 
435
1133
  async function doctorApp(appDir) {
436
1134
  const root = resolve(appDir)
437
1135
  const checks = []
438
- const major = Number(process.versions.node.split('.')[0])
439
- checks.push({ name: 'node', ok: major >= 20, detail: process.version })
1136
+ const [major, minor] = process.versions.node.split('.').map(Number)
1137
+ checks.push({ name: 'node', ok: major > 22 || (major === 22 && minor >= 18), detail: process.version })
440
1138
  try {
441
1139
  const info = await validateApp(root)
442
1140
  checks.push({ name: 'manifest', ok: true, detail: `${info.manifest.id}@${info.manifest.version}` })
@@ -444,12 +1142,7 @@ async function doctorApp(appDir) {
444
1142
  } catch (error) {
445
1143
  checks.push({ name: 'manifest', ok: false, detail: error.message })
446
1144
  }
447
- try {
448
- const config = JSON.parse(await readFile(join(root, '.proxy-app.json'), 'utf8'))
449
- checks.push({ name: 'binding', ok: Boolean(config.app_id && config.slug), detail: config.app_id || 'invalid .proxy-app.json' })
450
- } catch {
451
- checks.push({ name: 'binding', ok: false, detail: 'not linked; create or link before upload' })
452
- }
1145
+ checks.push({ name: 'binding', ok: true, detail: '使用 manifest.id 解析平台应用,无需 link 或 repair' })
453
1146
  const valid = checks.every((item) => item.ok)
454
1147
  console.log(JSON.stringify({ ok: valid, ready_for_upload: valid, checks }, null, 2))
455
1148
  if (!valid) process.exitCode = 1
@@ -457,36 +1150,24 @@ async function doctorApp(appDir) {
457
1150
 
458
1151
  const [, , command, ...args] = process.argv
459
1152
  try {
460
- if (!command || command === '--help' || command === '-h') usage()
461
- else if (command === 'dev') await devApp(args[0] || '.', args.slice(1))
462
- else if (command === 'init') await initApp(args[0] || '.', args.slice(1))
463
- else if (command === 'create') {
464
- if (!args[0]) throw new Error('create requires <app-dir>')
465
- await createApp(args[0], args.slice(1))
466
- }
467
- else if (command === 'link') {
468
- if (!args[1]) throw new Error('link requires <app-dir> <app-id>')
469
- await linkApp(args[0], args[1])
470
- }
471
- else if (command === 'status') await statusApp(args[0] || '.')
472
- else if (command === 'doctor') await doctorApp(args[0] || '.')
473
- else if (command === 'validate') {
474
- const info = await validateApp(args[0] || '.')
475
- console.log(JSON.stringify({ valid: true, id: info.manifest.id, version: info.manifest.version, entry: info.entry }, null, 2))
476
- } else if (command === 'build') {
477
- await run('npm', ['run', 'build'], resolve(args[0] || '.'))
478
- await validateApp(args[0] || '.')
479
- } else if (command === 'pack') await packApp(args[0] || '.', outputArgument(args), { allowSource: args.includes('--source') })
480
- else if (command === 'upload') {
481
- if (!args[0]) throw new Error('upload requires <app-dir>')
482
- await uploadApp(args[0], outputArgument(args), { allowSource: args.includes('--source') })
483
- }
484
- else if (command === 'publish') {
485
- if (!args[0]) throw new Error('publish requires <app-dir>')
486
- await publishApp(args[0], args[1])
487
- } else {
488
- usage()
489
- throw new Error(`unknown command: ${command}`)
1153
+ if (!command || command === '--help' || command === '-h' || args.includes('--help') || args.includes('-h')) usage()
1154
+ else {
1155
+ const { appDir, appID, output, options } = commandArguments(command, args)
1156
+ if (command === 'dev') await devApp(appDir, options)
1157
+ else if (command === 'init') await initApp(appDir, options)
1158
+ else if (command === 'create') await createApp(appDir, options)
1159
+ else if (command === 'repair' || command === 'link') await repairApp(appDir, appID)
1160
+ else if (command === 'status') await statusApp(appDir)
1161
+ else if (command === 'doctor') await doctorApp(appDir)
1162
+ else if (command === 'validate') {
1163
+ const info = await validateApp(appDir)
1164
+ console.log(JSON.stringify({ valid: true, id: info.manifest.id, version: info.manifest.version, entry: info.entry }, null, 2))
1165
+ } else if (command === 'build') await buildApp(appDir)
1166
+ else if (command === 'pack') await packApp(appDir, output, { allowSource: options.includes('--source') })
1167
+ else if (command === 'upload') await uploadApp(appDir, output, { allowSource: options.includes('--source') })
1168
+ 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)
490
1171
  }
491
1172
  } catch (error) {
492
1173
  console.error(`proxy app: ${error instanceof Error ? error.message : String(error)}`)